From 6d65b5976e5d2c72a04b4d7970b1f4e6e966a968 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:06:50 +0100 Subject: [PATCH 01/42] Upgrade `actions/checkout` in workflows to version 5 and improve logging in `CakeHostBuilder`. --- .github/workflows/integration.yml | 2 +- .github/workflows/main.yml | 6 +++--- .github/workflows/pull-request.yml | 6 +++--- .github/workflows/release.yml | 2 +- .../CreativeCoders.CakeBuild/CakeHostBuilder.cs | 10 ++++++++++ 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index f71cd5c1..8a872df3 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -61,7 +61,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 474ef63e..b5974a44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' @@ -38,7 +38,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' @@ -61,7 +61,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d18d68fb..7c21c34b 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -13,7 +13,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' @@ -36,7 +36,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' @@ -59,7 +59,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1718a627..c1939e95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: TERM: xterm DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION: 1 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 - name: 'Cache: ~/.nuget/packages' diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs index 54d777fc..3fe46049 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs @@ -109,6 +109,15 @@ public void Setup(ICakeContext context, ISetupContext info) return; } + context.Information("Build Setup Summary"); + context.Information("==================="); + context.Information("Tasks for execution:"); + + foreach (var task in info.TasksToExecute) + { + context.Information($"- {task.Name}"); + } + var pushSettings = buildContext.GetSettings(); if (pushSettings != null) @@ -119,6 +128,7 @@ public void Setup(ICakeContext context, ISetupContext info) var version = buildContext.Version; context.Information("Version Info"); + context.Information("============"); context.Information($"Informational Version: {version.InformationalVersion}"); context.Information($"Assembly Version: {version.AssemblySemVer}"); From 51e5a6be06b23be43a4c8858991b4663fa3f4456 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sun, 11 Jan 2026 20:14:34 +0100 Subject: [PATCH 02/42] Add `PublishTask` and related settings for .NET publish functionality - Introduced `PublishTask` template with `DotNetPublish` implementation. - Added `IPublishTaskSettings` and `PublishingItem` for configuring publish directory and runtime options. - Enhanced `PackTask` to remove unnecessary newline for code clarity. --- .../Tasks/Templates/PackTask.cs | 1 - .../Tasks/Templates/PublishTask.cs | 58 +++++++++++++++++++ .../Settings/IPublishTaskSettings.cs | 10 ++++ .../Templates/Settings/PublishingItem.cs | 16 +++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/IPublishTaskSettings.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs index 8c418331..0d6c2568 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs @@ -26,7 +26,6 @@ private DotNetPackSettings CreateDotNetPackSettings(T context, IPackTaskSettings NoBuild = context.HasExecutedTask(typeof(BuildTask)), IncludeSymbols = true, SymbolPackageFormat = "snupkg", - MSBuildSettings = new DotNetMSBuildSettings { InformationalVersion = context.Version.InformationalVersion, diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs new file mode 100644 index 00000000..90621d6b --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs @@ -0,0 +1,58 @@ +using Cake.Common.Tools.DotNet; +using Cake.Common.Tools.DotNet.Publish; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +namespace CreativeCoders.CakeBuild.Tasks.Templates; + +public class PublishTask : FrostingTaskBase + where TBuildContext : CakeBuildContext +{ + protected override Task RunAsyncCore(TBuildContext context) + { + var publishSettings = context.GetRequiredSettings(); + + if (publishSettings.PublishingItems.Any()) + { + foreach (var publishingItem in publishSettings.PublishingItems) + { + context.DotNetPublish(publishingItem.ProjectPath.FullPath, + CreateDotNetPublishSettings(publishingItem, context)); + } + } + else + { + context.DotNetPublish(context.SolutionFile.FullPath, + CreateDotNetPublishSettings(publishSettings, context)); + } + + return Task.CompletedTask; + } + + private DotNetPublishSettings CreateDotNetPublishSettings(IPublishTaskSettings publishSettings, + TBuildContext context) + { + var dotNetPublishSettings = new DotNetPublishSettings + { + OutputDirectory = publishSettings.PublishOutputDir, + NoBuild = context.HasExecutedTask(typeof(BuildTask)), + Configuration = context.BuildConfiguration + }; + + return dotNetPublishSettings; + } + + protected virtual DotNetPublishSettings CreateDotNetPublishSettings(PublishingItem publishingItem, + TBuildContext context) + { + var dotNetPublishSettings = new DotNetPublishSettings + { + Runtime = publishingItem.Runtime, + OutputDirectory = publishingItem.OutputDir, + SelfContained = publishingItem.SelfContained, + NoBuild = context.HasExecutedTask(typeof(BuildTask)), + Configuration = context.BuildConfiguration + }; + + return dotNetPublishSettings; + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/IPublishTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/IPublishTaskSettings.cs new file mode 100644 index 00000000..cc8def34 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/IPublishTaskSettings.cs @@ -0,0 +1,10 @@ +using Cake.Core.IO; + +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public interface IPublishTaskSettings : IBuildContextAccessor +{ + DirectoryPath PublishOutputDir => Context.ArtifactsDir.Combine("dist"); + + IEnumerable PublishingItems => []; +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs new file mode 100644 index 00000000..c48438b6 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs @@ -0,0 +1,16 @@ +using Cake.Core.IO; + +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public class PublishingItem(FilePath projectPath, DirectoryPath outputDir) +{ + public FilePath ProjectPath { get; } = projectPath; + + public DirectoryPath OutputDir { get; } = outputDir; + + public string? Runtime { get; set; } + + public bool? SelfContained { get; set; } + + public bool ProduceArtifact { get; set; } +} From 93bf1ffbfd4c7c341146ff53abb1d7e6acd199e9 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:22:27 +0100 Subject: [PATCH 03/42] Refactor task execution to use type-specific settings and add task template improvements - Updated `FrostingTaskBase` to support type-specific settings with enhanced modularity. - Refactored existing tasks (`PackTask`, `CleanTask`, `NuGetPushTask`, etc.) to replace redundant context-based setting retrieval with direct usage of task settings. - Introduced new `FrostingTaskBase` for simplifying task development. - Added `PublishTask` default implementation and new `CreateDistPackagesTask` template for future enhancements. - Annotated relevant classes and settings with `[PublicAPI]` for improved IDE support and clarity. --- .../Tasks/Defaults/IDefaultTaskSettings.cs | 3 ++- .../Tasks/Defaults/PublishTask.cs | 7 +++++++ .../Tasks/FrostingTaskBase.cs | 14 ++++++++++++++ .../Tasks/Templates/CleanTask.cs | 8 +++----- .../Tasks/Templates/CodeCoverageTask.cs | 10 ++++------ .../Tasks/Templates/CreateDistPackagesTask.cs | 10 ++++++++++ .../Tasks/Templates/NuGetPushTask.cs | 11 +++++------ .../Tasks/Templates/PackTask.cs | 8 +++----- .../Tasks/Templates/PublishTask.cs | 12 +++++------- .../Tasks/Templates/Settings/PublishingItem.cs | 4 ++-- .../Tasks/Templates/TestTask.cs | 10 ++++------ 11 files changed, 59 insertions(+), 38 deletions(-) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/PublishTask.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/IDefaultTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/IDefaultTaskSettings.cs index 60885f0f..f93ee12d 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/IDefaultTaskSettings.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/IDefaultTaskSettings.cs @@ -7,4 +7,5 @@ public interface IDefaultTaskSettings : ITestTaskSettings, ICodeCoverageTaskSettings, IPackTaskSettings, - INuGetPushTaskSettings; + INuGetPushTaskSettings, + IPublishTaskSettings; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/PublishTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/PublishTask.cs new file mode 100644 index 00000000..da362081 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/PublishTask.cs @@ -0,0 +1,7 @@ +using Cake.Frosting; +using CreativeCoders.CakeBuild.Tasks.Templates; + +namespace CreativeCoders.CakeBuild.Tasks.Defaults; + +[TaskName("Publish")] +public class PublishTask : PublishTask; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs index 3f174630..758842df 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs @@ -34,3 +34,17 @@ public override void Finally(T context) context.AddExecutedTask(this); } } + +public abstract class FrostingTaskBase : FrostingTaskBase + where TBuildContext : CakeBuildContext + where TSettings : class +{ + protected sealed override Task RunAsyncCore(TBuildContext context) + { + var taskSettings = context.GetRequiredSettings(); + + return RunAsyncCore(context, taskSettings); + } + + protected abstract Task RunAsyncCore(TBuildContext context, TSettings taskSettings); +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CleanTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CleanTask.cs index e35304a5..56372507 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CleanTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CleanTask.cs @@ -5,13 +5,11 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class CleanTask : FrostingTaskBase where T : CakeBuildContext +public class CleanTask : FrostingTaskBase where T : CakeBuildContext { - protected override Task RunAsyncCore(T context) + protected override Task RunAsyncCore(T context, ICleanTaskSettings taskSettings) { - var settings = context.GetRequiredSettings(); - - DeleteDirectories(context, settings.DirectoriesToClean); + DeleteDirectories(context, taskSettings.DirectoriesToClean); context.DotNetClean(context.SolutionFile.FullPath); diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CodeCoverageTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CodeCoverageTask.cs index 3ef801b5..7eec30ef 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CodeCoverageTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CodeCoverageTask.cs @@ -4,19 +4,17 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class CodeCoverageTask : FrostingTaskBase +public class CodeCoverageTask : FrostingTaskBase where T : CakeBuildContext { - protected override Task RunAsyncCore(T context) + protected override Task RunAsyncCore(T context, ICodeCoverageTaskSettings taskSettings) { - var codeCoverageSettings = context.GetRequiredSettings(); - var reportGeneratorSettings = new ReportGeneratorSettings { - ReportTypes = codeCoverageSettings.ReportTypes.ToList() + ReportTypes = taskSettings.ReportTypes.ToList() }; - context.ReportGenerator(new GlobPattern(codeCoverageSettings.ReportGlobPattern), + context.ReportGenerator(new GlobPattern(taskSettings.ReportGlobPattern), context.CodeCoverageReportDir, reportGeneratorSettings); return Task.CompletedTask; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs new file mode 100644 index 00000000..fd304fa7 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.CakeBuild.Tasks.Templates; + +public class CreateDistPackagesTask : FrostingTaskBase + where TBuildContext : CakeBuildContext +{ + protected override Task RunAsyncCore(TBuildContext context) + { + throw new NotImplementedException(); + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/NuGetPushTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/NuGetPushTask.cs index 688fc212..33f6fa59 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/NuGetPushTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/NuGetPushTask.cs @@ -5,13 +5,12 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class NuGetPushTask : FrostingTaskBase where T : CakeBuildContext +public class NuGetPushTask : FrostingTaskBase + where T : CakeBuildContext { - protected override Task RunAsyncCore(T context) + protected override Task RunAsyncCore(T context, INuGetPushTaskSettings taskSettings) { - var pushSettings = context.GetRequiredSettings(); - - if (pushSettings.SkipPush) + if (taskSettings.SkipPush) { context.Information("Skip NuGet push"); return Task.CompletedTask; @@ -21,7 +20,7 @@ protected override Task RunAsyncCore(T context) var filePath = packSettings.OutputDirectory.GetFilePath("*.nupkg"); - context.DotNetNuGetPush(filePath, CreateNuGetPushSettings(pushSettings)); + context.DotNetNuGetPush(filePath, CreateNuGetPushSettings(taskSettings)); return Task.CompletedTask; } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs index 0d6c2568..4e21180a 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PackTask.cs @@ -5,14 +5,12 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class PackTask : FrostingTaskBase +public class PackTask : FrostingTaskBase where T : CakeBuildContext { - protected override Task RunAsyncCore(T context) + protected override Task RunAsyncCore(T context, IPackTaskSettings taskSettings) { - var packSettings = context.GetRequiredSettings(); - - context.DotNetPack(context.SolutionFile.FullPath, CreateDotNetPackSettings(context, packSettings)); + context.DotNetPack(context.SolutionFile.FullPath, CreateDotNetPackSettings(context, taskSettings)); return Task.CompletedTask; } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs index 90621d6b..4b5fab63 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs @@ -4,16 +4,14 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class PublishTask : FrostingTaskBase +public class PublishTask : FrostingTaskBase where TBuildContext : CakeBuildContext { - protected override Task RunAsyncCore(TBuildContext context) + protected override Task RunAsyncCore(TBuildContext context, IPublishTaskSettings taskSettings) { - var publishSettings = context.GetRequiredSettings(); - - if (publishSettings.PublishingItems.Any()) + if (taskSettings.PublishingItems.Any()) { - foreach (var publishingItem in publishSettings.PublishingItems) + foreach (var publishingItem in taskSettings.PublishingItems) { context.DotNetPublish(publishingItem.ProjectPath.FullPath, CreateDotNetPublishSettings(publishingItem, context)); @@ -22,7 +20,7 @@ protected override Task RunAsyncCore(TBuildContext context) else { context.DotNetPublish(context.SolutionFile.FullPath, - CreateDotNetPublishSettings(publishSettings, context)); + CreateDotNetPublishSettings(taskSettings, context)); } return Task.CompletedTask; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs index c48438b6..0223ec10 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs @@ -1,7 +1,9 @@ using Cake.Core.IO; +using JetBrains.Annotations; namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; +[PublicAPI] public class PublishingItem(FilePath projectPath, DirectoryPath outputDir) { public FilePath ProjectPath { get; } = projectPath; @@ -11,6 +13,4 @@ public class PublishingItem(FilePath projectPath, DirectoryPath outputDir) public string? Runtime { get; set; } public bool? SelfContained { get; set; } - - public bool ProduceArtifact { get; set; } } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/TestTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/TestTask.cs index e3fcc887..43407291 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/TestTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/TestTask.cs @@ -8,13 +8,11 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; [PublicAPI] -public class TestTask : FrostingTaskBase where T : CakeBuildContext +public class TestTask : FrostingTaskBase where T : CakeBuildContext { - protected override Task RunAsyncCore(T context) + protected override Task RunAsyncCore(T context, ITestTaskSettings taskSettings) { - var testSettings = context.GetRequiredSettings(); - - var testProjects = testSettings.TestProjects.OrderBy(x => x.FullPath).ToArray(); + var testProjects = taskSettings.TestProjects.OrderBy(x => x.FullPath).ToArray(); context.Information($"Found {testProjects.Length} test project(s)"); @@ -23,7 +21,7 @@ protected override Task RunAsyncCore(T context) context.Information($"Test project found: {testProject.GetFilename()}"); context.DotNetTest(testProject.FullPath, - CreateDotNetBuildSettings(context, testProject, testSettings)); + CreateDotNetBuildSettings(context, testProject, taskSettings)); } return Task.CompletedTask; From 346b0be159e5e9c987a3dad294f80d5362e18a04 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:33:15 +0100 Subject: [PATCH 04/42] Add `CreateDistPackagesTask` implementation with support for packaging in TAR.GZ and ZIP formats - Introduced `DistPackage` and `DistPackageFormat` for representing package configurations. - Added `ICreateDistPackagesTaskSettings` interface for task settings. - Updated `CreateDistPackagesTask` to handle packaging logic based on configurable formats. - Included necessary project references to `CreativeCoders.IO`. --- .../CreativeCoders.CakeBuild.csproj | 1 + .../Tasks/Templates/CreateDistPackagesTask.cs | 35 +++++++++++++++++-- .../Tasks/Templates/Settings/DistPackage.cs | 14 ++++++++ .../Templates/Settings/DistPackageFormat.cs | 7 ++++ .../ICreateDistPackagesTaskSettings.cs | 10 ++++++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackageFormat.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj index 69c651f0..ab1e4216 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj @@ -12,6 +12,7 @@ + diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs index fd304fa7..40b5858d 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs @@ -1,10 +1,39 @@ +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.Core.Collections; +using CreativeCoders.IO.Archives; + namespace CreativeCoders.CakeBuild.Tasks.Templates; -public class CreateDistPackagesTask : FrostingTaskBase +public class + CreateDistPackagesTask : FrostingTaskBase where TBuildContext : CakeBuildContext { - protected override Task RunAsyncCore(TBuildContext context) + protected override Task RunAsyncCore(TBuildContext context, ICreateDistPackagesTaskSettings taskSettings) { - throw new NotImplementedException(); + foreach (var distPackage in taskSettings.DistPackages) + { + switch (distPackage.Format) + { + case DistPackageFormat.TarGz: + new TarArchiveCreator() + .SetArchiveFileName(taskSettings.DistOutputPath + .CombineWithFilePath($"{distPackage.Name}.tar.gz").FullPath) + .AddFromDirectory(distPackage.DistFolder.FullPath, "*.*", true) + .Create(true); + break; + case DistPackageFormat.Zip: + new ZipArchiveCreator() + .SetArchiveFileName(taskSettings.DistOutputPath + .CombineWithFilePath($"{distPackage.Name}.zip").FullPath) + .AddFromDirectory(distPackage.DistFolder.FullPath, "*.*", true) + .Create(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(distPackage), + "Package format not supported"); + } + } + + return Task.CompletedTask; } } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs new file mode 100644 index 00000000..679730f0 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs @@ -0,0 +1,14 @@ +using Cake.Core.IO; +using JetBrains.Annotations; + +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +[PublicAPI] +public class DistPackage(string name, DirectoryPath distFolder) +{ + public DistPackageFormat Format { get; set; } + + public string Name { get; } = name; + + public DirectoryPath DistFolder { get; } = distFolder; +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackageFormat.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackageFormat.cs new file mode 100644 index 00000000..f5b5d639 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackageFormat.cs @@ -0,0 +1,7 @@ +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public enum DistPackageFormat +{ + TarGz, + Zip +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs new file mode 100644 index 00000000..f687d745 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs @@ -0,0 +1,10 @@ +using Cake.Core.IO; + +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public interface ICreateDistPackagesTaskSettings +{ + IEnumerable DistPackages { get; } + + DirectoryPath DistOutputPath { get; } +} From 805ef8d0b2f3456d54155fbb1a760a99f0f5747c Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:33:59 +0100 Subject: [PATCH 05/42] Migrate `CreativeCoders.IO.Archives` and `CreativeCoders.IO.Ports` into separate projects with refactored architecture - Removed `CreativeCoders.IO` project and reorganized its contents. - Introduced new `CreativeCoders.IO.Archives` project with abstractions for TAR/ZIP archive handling. - Added `CreativeCoders.IO.Ports` project for serial port functionalities. - Updated package references and dependencies to align with the new modular structure. - Enhanced service registration with dependency injection extensions for archives. --- Core.sln | 21 +- .../ArchiveEntry.cs | 12 ++ .../ArchiveWriterFactory.cs | 54 ++++++ .../ArchivesServiceCollectionExtensions.cs | 18 ++ .../CreativeCoders.IO.Archives.csproj | 17 ++ .../IArchiveReader.cs | 17 ++ .../IArchiveWriter.cs | 10 + .../IArchiveWriterFactory.cs | 26 +++ .../Tar/ITarArchiveReader.cs | 3 + .../Tar/ITarArchiveReaderFactory.cs | 8 + .../Tar/ITarArchiveWriter.cs | 8 + .../Tar/ITarArchiveWriterFactory.cs | 10 + .../Tar/TarArchiveReader.cs | 175 +++++++++++++++++ .../Tar/TarArchiveReaderFactory.cs | 18 ++ .../Tar/TarArchiveWriter.cs | 116 ++++++++++++ .../Tar/TarArchiveWriterFactory.cs | 20 ++ .../Tar/TarFileHelper.cs | 13 ++ .../Tar/TarGzArchiveReader.cs | 179 ++++++++++++++++++ .../Zip/IZipArchiveReader.cs | 3 + .../Zip/IZipArchiveReaderFactory.cs | 12 ++ .../Zip/IZipArchiveWriter.cs | 12 ++ .../Zip/IZipArchiveWriterFactory.cs | 10 + .../Zip/ZipArchiveReader.cs | 89 +++++++++ .../Zip/ZipArchiveReaderFactory.cs | 24 +++ .../Zip/ZipArchiveWriter.cs | 59 ++++++ .../Zip/ZipArchiveWriterFactory.cs | 19 ++ .../CreativeCoders.IO.Ports.csproj} | 8 +- .../ExtendedSerialPort.cs | 7 +- .../ISerialPort.cs | 0 .../ReactiveSerialPort.cs | 7 +- .../SerialPortExtensions.cs | 0 .../SerialPortWrapper.cs | 9 +- .../Archives/DefaultTarArchiveWriter.cs | 93 --------- .../DefaultTarArchiveWriterBuilder.cs | 43 ----- .../Archives/ITarArchiveBuilder.cs | 15 -- .../Archives/ITarArchiveCreator.cs | 15 -- .../Archives/ITarArchiveWriter.cs | 16 -- .../Archives/ITarFileInfoAccess.cs | 13 -- .../Archives/IZipArchiveCreator.cs | 13 -- .../Archives/SharpZipLibTarArchiveWriter.cs | 50 ----- .../Archives/TarArchiveCreator.cs | 48 ----- .../Archives/TarArchiveWriterBase.cs | 82 -------- .../Archives/TarFileOwnerInfo.cs | 15 -- .../Archives/WindowsTarFileInfoAccess.cs | 33 ---- .../Archives/ZipArchiveCreator.cs | 36 ---- .../CreativeCoders.IO.UnitTests.csproj | 5 +- 46 files changed, 956 insertions(+), 505 deletions(-) create mode 100644 source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/CreativeCoders.IO.Archives.csproj create mode 100644 source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/IArchiveWriter.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReaderFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriter.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReaderFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriter.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs create mode 100644 source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs rename source/IO/{CreativeCoders.IO/CreativeCoders.IO.csproj => CreativeCoders.IO.Ports/CreativeCoders.IO.Ports.csproj} (60%) rename source/IO/{CreativeCoders.IO/Ports => CreativeCoders.IO.Ports}/ExtendedSerialPort.cs (64%) rename source/IO/{CreativeCoders.IO/Ports => CreativeCoders.IO.Ports}/ISerialPort.cs (100%) rename source/IO/{CreativeCoders.IO/Ports => CreativeCoders.IO.Ports}/ReactiveSerialPort.cs (83%) rename source/IO/{CreativeCoders.IO/Ports => CreativeCoders.IO.Ports}/SerialPortExtensions.cs (100%) rename source/IO/{CreativeCoders.IO/Ports => CreativeCoders.IO.Ports}/SerialPortWrapper.cs (76%) delete mode 100644 source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriter.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriterBuilder.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/ITarArchiveBuilder.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/ITarArchiveCreator.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/ITarArchiveWriter.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/ITarFileInfoAccess.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/IZipArchiveCreator.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/SharpZipLibTarArchiveWriter.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/TarArchiveCreator.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/TarArchiveWriterBase.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/TarFileOwnerInfo.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/WindowsTarFileInfoAccess.cs delete mode 100644 source/IO/CreativeCoders.IO/Archives/ZipArchiveCreator.cs diff --git a/Core.sln b/Core.sln index f0ff9a93..a82e3677 100644 --- a/Core.sln +++ b/Core.sln @@ -147,8 +147,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.Daemon.Windo EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IO", "IO", "{F31389F0-613B-48B1-AA99-6BFC3D8D7D1A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.IO", "source\IO\CreativeCoders.IO\CreativeCoders.IO.csproj", "{3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.Net.JsonRpc", "source\Net\CreativeCoders.Net.JsonRpc\CreativeCoders.Net.JsonRpc.csproj", "{0D77E6F7-5D76-4DF0-BE57-BA459951CD2F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "NoSql", "NoSql", "{F708D571-2BF4-4F2D-9711-B00D245A0BB3}" @@ -258,6 +256,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ci", "ci", "{F3331DB3-C40A- EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "build\Build.csproj", "{4A397E49-CB4F-4348-9798-A7F2B5F276DA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.IO.Archives", "source\IO\CreativeCoders.IO.Archives\CreativeCoders.IO.Archives.csproj", "{EC120660-306E-4E48-8FBB-43D9F3962CEE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.IO.Ports", "source\IO\CreativeCoders.IO.Ports\CreativeCoders.IO.Ports.csproj", "{C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -428,10 +430,6 @@ Global {30E75E1A-146F-422E-8C14-3AE0F7FB99B1}.Debug|Any CPU.Build.0 = Debug|Any CPU {30E75E1A-146F-422E-8C14-3AE0F7FB99B1}.Release|Any CPU.ActiveCfg = Release|Any CPU {30E75E1A-146F-422E-8C14-3AE0F7FB99B1}.Release|Any CPU.Build.0 = Release|Any CPU - {3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0}.Release|Any CPU.Build.0 = Release|Any CPU {0D77E6F7-5D76-4DF0-BE57-BA459951CD2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0D77E6F7-5D76-4DF0-BE57-BA459951CD2F}.Debug|Any CPU.Build.0 = Debug|Any CPU {0D77E6F7-5D76-4DF0-BE57-BA459951CD2F}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -576,6 +574,14 @@ Global {58C01CB8-B65A-44FE-9BE0-22929CD474A2}.Release|Any CPU.ActiveCfg = Release|Any CPU {4A397E49-CB4F-4348-9798-A7F2B5F276DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4A397E49-CB4F-4348-9798-A7F2B5F276DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC120660-306E-4E48-8FBB-43D9F3962CEE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC120660-306E-4E48-8FBB-43D9F3962CEE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC120660-306E-4E48-8FBB-43D9F3962CEE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC120660-306E-4E48-8FBB-43D9F3962CEE}.Release|Any CPU.Build.0 = Release|Any CPU + {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -637,7 +643,6 @@ Global {74B31A58-28CB-4B90-B2A4-C64F339FC93D} = {99FFD9AA-7AEB-4A9D-881D-00B7277C3494} {30E75E1A-146F-422E-8C14-3AE0F7FB99B1} = {99FFD9AA-7AEB-4A9D-881D-00B7277C3494} {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} = {2A7105AA-05B6-469A-93F5-719723A4D90D} - {3B1E60AF-5B5C-4BED-8AAF-79167CF5E2C0} = {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} {0D77E6F7-5D76-4DF0-BE57-BA459951CD2F} = {354E69EA-9EA1-4F7E-A3C2-372DFB259C35} {F708D571-2BF4-4F2D-9711-B00D245A0BB3} = {D913C0D3-85B9-4E89-82D6-8371FB6C99E7} {E6D5CE84-542C-4125-B094-D8C67A11AF13} = {F708D571-2BF4-4F2D-9711-B00D245A0BB3} @@ -684,6 +689,8 @@ Global {58C01CB8-B65A-44FE-9BE0-22929CD474A2} = {72E179CA-7AE6-412A-856D-7BD13838E8E3} {F3331DB3-C40A-4712-A545-EE5C628B0F39} = {92288F2D-DE57-4018-855B-BD7F2344265F} {4A397E49-CB4F-4348-9798-A7F2B5F276DA} = {DB7A40EE-F469-4B87-A629-250BBB9ECC92} + {EC120660-306E-4E48-8FBB-43D9F3962CEE} = {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} + {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4} = {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} diff --git a/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs new file mode 100644 index 00000000..378a5f22 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs @@ -0,0 +1,12 @@ +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives; + +public class ArchiveEntry(string fullName) +{ + public string FileName { get; } = Path.GetFileName(fullName); + + public string FullName { get; } = Ensure.IsNotNullOrEmpty(fullName); + + public string DirectoryName { get; } = Path.GetDirectoryName(fullName) ?? string.Empty; +} diff --git a/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs new file mode 100644 index 00000000..8907c761 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs @@ -0,0 +1,54 @@ +using System.IO.Compression; +using CreativeCoders.Core; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.IO.Archives.Zip; + +namespace CreativeCoders.IO.Archives; + +public class ArchiveWriterFactory( + IZipArchiveWriterFactory zipArchiveWriterFactory, + ITarArchiveWriterFactory tarArchiveWriterFactory) : IArchiveWriterFactory +{ + private readonly IZipArchiveWriterFactory _zipArchiveWriterFactory = + Ensure.NotNull(zipArchiveWriterFactory); + + private readonly ITarArchiveWriterFactory _tarArchiveWriterFactory = + Ensure.NotNull(tarArchiveWriterFactory); + + public ITarArchiveWriter CreateTarWriter(Stream outputStream, bool useGZipCompression = true, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return _tarArchiveWriterFactory.CreateWriter(outputStream, useGZipCompression, compressionLevel); + } + + public ITarArchiveWriter CreateTarWriter(string archiveFileName, bool? useGZipCompression = null, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return _tarArchiveWriterFactory.CreateWriter(archiveFileName, useGZipCompression, compressionLevel); + } + + public ITarArchiveWriter CreateTarGzWriter(Stream outputStream, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return CreateTarWriter(outputStream, true, compressionLevel); + } + + public ITarArchiveWriter CreateTarGzWriter(string archiveFileName, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return CreateTarWriter(archiveFileName, true, compressionLevel); + } + + public IZipArchiveWriter CreateZipWriter(Stream outputStream, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return _zipArchiveWriterFactory.CreateWriter(outputStream, compressionLevel); + } + + public IZipArchiveWriter CreateZipWriter(string archiveFileName, + CompressionLevel compressionLevel = CompressionLevel.Optimal, + bool overwriteExisting = true) + { + return _zipArchiveWriterFactory.CreateWriter(archiveFileName, compressionLevel, overwriteExisting); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs b/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs new file mode 100644 index 00000000..8fcca15a --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs @@ -0,0 +1,18 @@ +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.IO.Archives.Zip; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.IO.Archives; + +public static class ArchivesServiceCollectionExtensions +{ + public static IServiceCollection AddArchives(this IServiceCollection services) + { + return services + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/CreativeCoders.IO.Archives.csproj b/source/IO/CreativeCoders.IO.Archives/CreativeCoders.IO.Archives.csproj new file mode 100644 index 00000000..8ef18b86 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/CreativeCoders.IO.Archives.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + diff --git a/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs new file mode 100644 index 00000000..965f3756 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs @@ -0,0 +1,17 @@ +namespace CreativeCoders.IO.Archives; + +public interface IArchiveReader : IDisposable, IAsyncDisposable +{ + IAsyncEnumerable GetEntriesAsync(); + + IEnumerable GetEntries(); + + Task OpenEntryStreamAsync(ArchiveEntry entry); + + Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true); + + Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, + bool overwriteExisting = true); + + Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = true); +} diff --git a/source/IO/CreativeCoders.IO.Archives/IArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/IArchiveWriter.cs new file mode 100644 index 00000000..551093ca --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/IArchiveWriter.cs @@ -0,0 +1,10 @@ +namespace CreativeCoders.IO.Archives; + +public interface IArchiveWriter : IDisposable, IAsyncDisposable +{ + Task AddFileAsync(string fileName, string fileNameInArchive); + + Task AddFileAsync(Stream stream, string fileNameInArchive); + + Task AddFromDirectoryAsync(string path, string basePathToRemove); +} diff --git a/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs new file mode 100644 index 00000000..1e062674 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs @@ -0,0 +1,26 @@ +using System.IO.Compression; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.IO.Archives.Zip; + +namespace CreativeCoders.IO.Archives; + +public interface IArchiveWriterFactory +{ + ITarArchiveWriter CreateTarWriter(Stream outputStream, bool useGZipCompression = true, + CompressionLevel compressionLevel = CompressionLevel.Optimal); + + ITarArchiveWriter CreateTarWriter(string archiveFileName, bool? useGZipCompression = null, + CompressionLevel compressionLevel = CompressionLevel.Optimal); + + ITarArchiveWriter CreateTarGzWriter(Stream outputStream, + CompressionLevel compressionLevel = CompressionLevel.Optimal); + + ITarArchiveWriter CreateTarGzWriter(string archiveFileName, + CompressionLevel compressionLevel = CompressionLevel.Optimal); + + IZipArchiveWriter CreateZipWriter(Stream outputStream, + CompressionLevel compressionLevel = CompressionLevel.Optimal); + + IZipArchiveWriter CreateZipWriter(string archiveFileName, + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReader.cs new file mode 100644 index 00000000..581861c8 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReader.cs @@ -0,0 +1,3 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public interface ITarArchiveReader : IArchiveReader; diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReaderFactory.cs new file mode 100644 index 00000000..037fff38 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveReaderFactory.cs @@ -0,0 +1,8 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public interface ITarArchiveReaderFactory +{ + ITarArchiveReader CreateReader(Stream inputStream, bool useGZipCompression = true); + + ITarArchiveReader CreateReader(string archiveFileName, bool? useGZipCompression = null); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriter.cs new file mode 100644 index 00000000..bf5f4252 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriter.cs @@ -0,0 +1,8 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public interface ITarArchiveWriter : IArchiveWriter +{ + Task AddFileAsync(Stream stream, string fileNameInArchive, Action configureEntry); + + Task AddFileAsync(Stream stream, string fileNameInArchive, TarEntryInfo entryInfo); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs new file mode 100644 index 00000000..246ab517 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs @@ -0,0 +1,10 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Tar; + +public interface ITarArchiveWriterFactory +{ + ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, CompressionLevel compressionLevel = CompressionLevel.Optimal); + + ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, CompressionLevel compressionLevel = CompressionLevel.Optimal); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs new file mode 100644 index 00000000..e6e9b0ef --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -0,0 +1,175 @@ +using System.Formats.Tar; + +namespace CreativeCoders.IO.Archives.Tar; + +public sealed class TarArchiveReader(Stream inputStream) : ITarArchiveReader +{ + private TarReader _tarReader = new TarReader(inputStream, true); + + private bool _needsReset; + + private void ResetTarReader() + { + if (!_needsReset) + { + _needsReset = true; + return; + } + + _tarReader.Dispose(); + + if (inputStream.CanSeek) + { + inputStream.Seek(0, SeekOrigin.Begin); + } + else + { + throw new InvalidOperationException("The input stream is not seekable."); + } + + _tarReader = new TarReader(inputStream, true); + } + + private async Task ResetTarReaderAsync() + { + if (!_needsReset) + { + _needsReset = true; + return; + } + + await _tarReader.DisposeAsync().ConfigureAwait(false); + + if (inputStream.CanSeek) + { + inputStream.Seek(0, SeekOrigin.Begin); + } + else + { + throw new InvalidOperationException("The input stream is not seekable."); + } + + _tarReader = new TarReader(inputStream, true); + } + + private async IAsyncEnumerable GetAllTarEntriesAsync() + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + yield return tarEntry; + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + } + + public async IAsyncEnumerable GetEntriesAsync() + { + await foreach (var tarEntry in GetAllTarEntriesAsync().ConfigureAwait(false)) + { + yield return new ArchiveEntry(tarEntry.Name); + } + } + + public IEnumerable GetEntries() + { + ResetTarReader(); + + var tarEntry = _tarReader.GetNextEntry(); + + while (tarEntry is not null) + { + yield return new ArchiveEntry(tarEntry.Name); + + tarEntry = _tarReader.GetNextEntry(); + } + } + + private async Task GetTarEntryAsync(ArchiveEntry entry) + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + if (tarEntry.Name == entry.FullName) + { + return tarEntry; + } + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + + return null; + } + + public Task OpenEntryStreamAsync(ArchiveEntry entry) + { + throw new NotImplementedException(); + } + + public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) + { + var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); + + if (tarEntry == null) + { + throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the archive."); + } + + await ExtractFileCoreAsync(tarEntry, outputFilePath, overwriteExisting).ConfigureAwait(false); + } + + private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + { + var outputDirectory = Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + Directory.CreateDirectory(outputDirectory); + } + + return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + } + + public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, + bool overwriteExisting = true) + { + var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + + await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); + + return outputFileName; + } + + public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = true) + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + var outputFileName = Path.Combine(outputBaseDirectory, tarEntry.Name); + + await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + } + + public void Dispose() + { + _tarReader.Dispose(); + inputStream.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _tarReader.DisposeAsync().ConfigureAwait(false); + await inputStream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs new file mode 100644 index 00000000..aa696107 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs @@ -0,0 +1,18 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public class TarArchiveReaderFactory : ITarArchiveReaderFactory +{ + public ITarArchiveReader CreateReader(Stream inputStream, bool useGZipCompression = true) + { + return useGZipCompression + ? new TarGzArchiveReader(inputStream) + : new TarArchiveReader(inputStream); + } + + public ITarArchiveReader CreateReader(string archiveFileName, bool? useGZipCompression = null) + { + return CreateReader( + File.OpenRead(archiveFileName), useGZipCompression ?? + TarFileHelper.IsGZipFile(archiveFileName)); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs new file mode 100644 index 00000000..57218f2b --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -0,0 +1,116 @@ +using System.Formats.Tar; + +namespace CreativeCoders.IO.Archives.Tar; + +public sealed class TarArchiveWriter(Stream outputStream) : ITarArchiveWriter +{ + private readonly TarWriter _tarWriter = new TarWriter(outputStream, TarEntryFormat.Pax); + + public Task AddFileAsync(string fileName, string fileNameInArchive) + { + return _tarWriter.WriteEntryAsync(fileName, ConvertPathToTarEntryName(fileNameInArchive)); + } + + public Task AddFileAsync(Stream stream, string fileNameInArchive) + { + return AddFileCoreAsync(stream, fileNameInArchive, null); + } + + private Task AddFileCoreAsync(Stream stream, string fileNameInArchive, + Action? configureEntry) + { + TarEntryInfo? entryInfo = null; + + if (configureEntry != null) + { + entryInfo = new TarEntryInfo(); + configureEntry(entryInfo); + } + + var tarEntry = CreateTarEntry(stream, fileNameInArchive, entryInfo); + + return _tarWriter.WriteEntryAsync(tarEntry); + } + + private static PaxTarEntry CreateTarEntry(Stream stream, string fileNameInArchive, + TarEntryInfo? entryInfo) + { + return entryInfo == null + ? new PaxTarEntry(TarEntryType.RegularFile, ConvertPathToTarEntryName(fileNameInArchive)) + { + DataStream = stream + } + : new PaxTarEntry(TarEntryType.RegularFile, ConvertPathToTarEntryName(fileNameInArchive), + entryInfo.ExtendedAttributes) + { + DataStream = stream, + UserName = entryInfo.UserName, + Uid = entryInfo.UserId, + GroupName = entryInfo.GroupName, + Gid = entryInfo.GroupId, + Mode = entryInfo.FileMode + }; + } + + public async Task AddFromDirectoryAsync(string path, string basePathToRemove) + { + var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); + + foreach (var file in files) + { + var entryName = file[basePathToRemove.Length..].TrimStart(Path.DirectorySeparatorChar); + await AddFileAsync(file, entryName).ConfigureAwait(false); + } + } + + private static string ConvertPathToTarEntryName(string fileNameInArchive) + { + return Path.DirectorySeparatorChar != '/' + ? fileNameInArchive.Replace(Path.DirectorySeparatorChar, '/') + : fileNameInArchive; + } + + public void Dispose() + { + _tarWriter.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _tarWriter.DisposeAsync().ConfigureAwait(false); + } + + public Task AddFileAsync(Stream stream, string fileNameInArchive, Action configureEntry) + { + return AddFileCoreAsync(stream, fileNameInArchive, configureEntry); + } + + public Task AddFileAsync(Stream stream, string fileNameInArchive, TarEntryInfo entryInfo) + { + return AddFileCoreAsync(stream, fileNameInArchive, x => + { + x.ExtendedAttributes = entryInfo.ExtendedAttributes; + x.FileMode = entryInfo.FileMode; + x.GroupId = entryInfo.GroupId; + x.GroupName = entryInfo.GroupName; + x.UserId = entryInfo.UserId; + x.UserName = entryInfo.UserName; + }); + } +} + +public class TarEntryInfo +{ + public string UserName { get; set; } = string.Empty; + + public int UserId { get; set; } + + public string GroupName { get; set; } = string.Empty; + + public int GroupId { get; set; } + + public UnixFileMode FileMode { get; set; } + + public IReadOnlyDictionary ExtendedAttributes { get; set; } = + new Dictionary(); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs new file mode 100644 index 00000000..79757791 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs @@ -0,0 +1,20 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Tar; + +public class TarArchiveWriterFactory : ITarArchiveWriterFactory +{ + public ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return new TarArchiveWriter(useGZipCompression ? new GZipStream(outputStream, compressionLevel) : outputStream); + } + + public ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return CreateWriter( + File.Create(archiveFileName), useGZipCompression ?? + TarFileHelper.IsGZipFile(archiveFileName), compressionLevel); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs new file mode 100644 index 00000000..05c8d48e --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs @@ -0,0 +1,13 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public static class TarFileHelper +{ + public static bool IsGZipFile(string archiveFileName) + { + var fileExt = Path.GetExtension(archiveFileName); + return fileExt.Equals("gz", StringComparison.CurrentCultureIgnoreCase) || + fileExt.Equals("tgz", StringComparison.CurrentCultureIgnoreCase) || + fileExt.EndsWith(".gz", StringComparison.CurrentCultureIgnoreCase) || + fileExt.EndsWith(".tgz", StringComparison.CurrentCultureIgnoreCase); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs new file mode 100644 index 00000000..01c64685 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -0,0 +1,179 @@ +using System.Formats.Tar; +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Tar; + +public sealed class TarGzArchiveReader : ITarArchiveReader +{ + private TarReader _tarReader; + + private bool _needsReset; + + private Stream _gzInputStream; + + private readonly Stream _sourceStream; + + public TarGzArchiveReader(Stream inputStream) + { + _gzInputStream = new GZipStream(inputStream, CompressionMode.Decompress, true); + _sourceStream = inputStream; + _tarReader = new TarReader(_gzInputStream, true); + } + + private void ResetTarReader() + { + if (!_needsReset) + { + _needsReset = true; + return; + } + + _tarReader.Dispose(); + + _gzInputStream.Dispose(); + _sourceStream.Seek(0, SeekOrigin.Begin); + _gzInputStream = new GZipStream(_sourceStream, CompressionMode.Decompress, true); + + _tarReader = new TarReader(_gzInputStream, true); + } + + private async Task ResetTarReaderAsync() + { + if (!_needsReset) + { + _needsReset = true; + return; + } + + await _tarReader.DisposeAsync().ConfigureAwait(false); + + await _gzInputStream.DisposeAsync().ConfigureAwait(false); + _sourceStream.Seek(0, SeekOrigin.Begin); + _gzInputStream = new GZipStream(_sourceStream, CompressionMode.Decompress, true); + + _tarReader = new TarReader(_gzInputStream, true); + } + + private async IAsyncEnumerable GetAllTarEntriesAsync() + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + yield return tarEntry; + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + } + + public async IAsyncEnumerable GetEntriesAsync() + { + await foreach (var tarEntry in GetAllTarEntriesAsync().ConfigureAwait(false)) + { + yield return new ArchiveEntry(tarEntry.Name); + } + } + + public IEnumerable GetEntries() + { + ResetTarReader(); + + var tarEntry = _tarReader.GetNextEntry(); + + while (tarEntry is not null) + { + yield return new ArchiveEntry(tarEntry.Name); + + tarEntry = _tarReader.GetNextEntry(); + } + } + + private async Task GetTarEntryAsync(ArchiveEntry entry) + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + if (tarEntry.Name == entry.FullName) + { + return tarEntry; + } + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + + return null; + } + + public Task OpenEntryStreamAsync(ArchiveEntry entry) + { + throw new NotImplementedException(); + } + + public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) + { + var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); + + if (tarEntry == null) + { + throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the archive."); + } + + await ExtractFileCoreAsync(tarEntry, outputFilePath, overwriteExisting).ConfigureAwait(false); + } + + private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + { + var outputDirectory = Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + Directory.CreateDirectory(outputDirectory); + } + + return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + } + + public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, + bool overwriteExisting = true) + { + var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + + await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); + + return outputFileName; + } + + public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = true) + { + await ResetTarReaderAsync().ConfigureAwait(false); + + var tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + + while (tarEntry is not null) + { + var outputFileName = Path.Combine(outputBaseDirectory, tarEntry.Name); + + await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + } + + public void Dispose() + { + _tarReader.Dispose(); + _gzInputStream.Dispose(); + _sourceStream.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _tarReader.DisposeAsync().ConfigureAwait(false); + await _gzInputStream.DisposeAsync().ConfigureAwait(false); + await _sourceStream.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReader.cs new file mode 100644 index 00000000..42b83fec --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReader.cs @@ -0,0 +1,3 @@ +namespace CreativeCoders.IO.Archives.Zip; + +public interface IZipArchiveReader : IArchiveReader; diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReaderFactory.cs new file mode 100644 index 00000000..cfc7ef4f --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveReaderFactory.cs @@ -0,0 +1,12 @@ +namespace CreativeCoders.IO.Archives.Zip; + +public interface IZipArchiveReaderFactory +{ + IZipArchiveReader CreateReader(Stream inputStream); + + IZipArchiveReader CreateReader(string archiveFileName); + + Task CreateReaderAsync(Stream inputStream); + + Task CreateReaderAsync(string archiveFileName); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriter.cs new file mode 100644 index 00000000..f51451d7 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriter.cs @@ -0,0 +1,12 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Zip; + +public interface IZipArchiveWriter : IArchiveWriter +{ + Task AddFileAsync(string fileName, string fileNameInArchive, CompressionLevel compressionLevel); + + Task AddFileAsync(Stream stream, string fileNameInArchive, CompressionLevel compressionLevel); + + Task AddFromDirectoryAsync(string path, string basePathToRemove, CompressionLevel compressionLevel); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs new file mode 100644 index 00000000..21a27ca7 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs @@ -0,0 +1,10 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Zip; + +public interface IZipArchiveWriterFactory +{ + IZipArchiveWriter CreateWriter(Stream outputStream, CompressionLevel compressionLevel = CompressionLevel.Optimal); + + IZipArchiveWriter CreateWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs new file mode 100644 index 00000000..a42e7714 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -0,0 +1,89 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Zip; + +public sealed class ZipArchiveReader(ZipArchive zipArchive) : IZipArchiveReader +{ + private readonly ZipArchive _zipArchive = zipArchive ?? throw new ArgumentNullException(nameof(zipArchive)); + + public static ZipArchiveReader Create(Stream inputStream) + { + var zipArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, false); + + return new ZipArchiveReader(zipArchive); + } + + public static async Task CreateAsync(Stream inputStream) + { + var zipArchive = await ZipArchive.CreateAsync(inputStream, ZipArchiveMode.Read, false, null) + .ConfigureAwait(false); + + return new ZipArchiveReader(zipArchive); + } + + public IAsyncEnumerable GetEntriesAsync() + { + return GetEntries().ToAsyncEnumerable(); + } + + public IEnumerable GetEntries() + { + return _zipArchive.Entries + .Select(x => + new ArchiveEntry(x.FullName)); + } + + public Task OpenEntryStreamAsync(ArchiveEntry entry) + { + var zipEntry = _zipArchive.GetEntry(entry.FullName); + + return zipEntry == null + ? throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the zip archive.") + : zipEntry.OpenAsync(); + } + + public Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) + { + var zipEntry = _zipArchive.GetEntry(entry.FullName); + + return zipEntry == null + ? throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the zip archive.") + : ExtractFileCoreAsync(zipEntry, outputFilePath, overwriteExisting); + } + + private static Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, bool overwriteExisting) + { + var outputDirectory = Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + Directory.CreateDirectory(outputDirectory); + } + + return zipEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + } + + public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, + bool overwriteExisting = true) + { + var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + + await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); + + return outputFileName; + } + + public Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = true) + { + return _zipArchive.ExtractToDirectoryAsync(outputBaseDirectory, overwriteExisting); + } + + public void Dispose() + { + _zipArchive.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _zipArchive.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs new file mode 100644 index 00000000..9e0746fb --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs @@ -0,0 +1,24 @@ +namespace CreativeCoders.IO.Archives.Zip; + +public class ZipArchiveReaderFactory : IZipArchiveReaderFactory +{ + public IZipArchiveReader CreateReader(Stream inputStream) + { + return ZipArchiveReader.Create(inputStream); + } + + public IZipArchiveReader CreateReader(string archiveFileName) + { + return ZipArchiveReader.Create(File.OpenRead(archiveFileName)); + } + + public async Task CreateReaderAsync(Stream inputStream) + { + return await ZipArchiveReader.CreateAsync(inputStream).ConfigureAwait(false); + } + + public Task CreateReaderAsync(string archiveFileName) + { + return CreateReaderAsync(File.OpenRead(archiveFileName)); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs new file mode 100644 index 00000000..4984ab03 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs @@ -0,0 +1,59 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Zip; + +public sealed class ZipArchiveWriter( + Stream outputStream, + CompressionLevel defaultCompressionLevel) : IZipArchiveWriter +{ + private readonly ZipArchive _archive = new ZipArchive(outputStream, ZipArchiveMode.Create); + + public Task AddFileAsync(string fileName, string fileNameInArchive, CompressionLevel compressionLevel) + { + return _archive.CreateEntryFromFileAsync(fileName, fileNameInArchive, compressionLevel); + } + + public async Task AddFileAsync(Stream stream, string fileNameInArchive, CompressionLevel compressionLevel) + { + await using var fileStream = await _archive.CreateEntry(fileNameInArchive, compressionLevel).OpenAsync() + .ConfigureAwait(false); + + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + public async Task AddFromDirectoryAsync(string path, string basePathToRemove, CompressionLevel compressionLevel) + { + var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); + + foreach (var file in files) + { + var entryName = file[basePathToRemove.Length..].TrimStart(Path.DirectorySeparatorChar); + await AddFileAsync(file, entryName, compressionLevel).ConfigureAwait(false); + } + } + + public void Dispose() + { + _archive.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _archive.DisposeAsync().ConfigureAwait(false); + } + + public Task AddFileAsync(string fileName, string fileNameInArchive) + { + return AddFileAsync(fileName, fileNameInArchive, defaultCompressionLevel); + } + + public Task AddFileAsync(Stream stream, string fileNameInArchive) + { + return AddFileAsync(stream, fileNameInArchive, defaultCompressionLevel); + } + + public Task AddFromDirectoryAsync(string path, string basePathToRemove) + { + return AddFromDirectoryAsync(path, basePathToRemove, defaultCompressionLevel); + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs new file mode 100644 index 00000000..6d47d791 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs @@ -0,0 +1,19 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Zip; + +public class ZipArchiveWriterFactory : IZipArchiveWriterFactory +{ + public IZipArchiveWriter CreateWriter(Stream outputStream, + CompressionLevel compressionLevel = CompressionLevel.Optimal) + { + return new ZipArchiveWriter(outputStream, compressionLevel); + } + + public IZipArchiveWriter CreateWriter(string archiveFileName, + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true) + { + return CreateWriter(overwriteExisting ? File.Create(archiveFileName) : File.OpenWrite(archiveFileName), + compressionLevel); + } +} diff --git a/source/IO/CreativeCoders.IO/CreativeCoders.IO.csproj b/source/IO/CreativeCoders.IO.Ports/CreativeCoders.IO.Ports.csproj similarity index 60% rename from source/IO/CreativeCoders.IO/CreativeCoders.IO.csproj rename to source/IO/CreativeCoders.IO.Ports/CreativeCoders.IO.Ports.csproj index 3c7c17de..83d2209e 100644 --- a/source/IO/CreativeCoders.IO/CreativeCoders.IO.csproj +++ b/source/IO/CreativeCoders.IO.Ports/CreativeCoders.IO.Ports.csproj @@ -1,17 +1,13 @@ - + - Library enable enable - - - + - diff --git a/source/IO/CreativeCoders.IO/Ports/ExtendedSerialPort.cs b/source/IO/CreativeCoders.IO.Ports/ExtendedSerialPort.cs similarity index 64% rename from source/IO/CreativeCoders.IO/Ports/ExtendedSerialPort.cs rename to source/IO/CreativeCoders.IO.Ports/ExtendedSerialPort.cs index 9bd9e3b3..457d342f 100644 --- a/source/IO/CreativeCoders.IO/Ports/ExtendedSerialPort.cs +++ b/source/IO/CreativeCoders.IO.Ports/ExtendedSerialPort.cs @@ -4,13 +4,8 @@ namespace CreativeCoders.IO.Ports; [ExcludeFromCodeCoverage] -public class ExtendedSerialPort : SerialPort, ISerialPort +public class ExtendedSerialPort(string portName) : SerialPort(portName), ISerialPort { - public ExtendedSerialPort(string portName) : base(portName) - { - - } - public IEnumerable ReadAllBytes() { return SerialPortExtensions.ReadAllBytes(this); diff --git a/source/IO/CreativeCoders.IO/Ports/ISerialPort.cs b/source/IO/CreativeCoders.IO.Ports/ISerialPort.cs similarity index 100% rename from source/IO/CreativeCoders.IO/Ports/ISerialPort.cs rename to source/IO/CreativeCoders.IO.Ports/ISerialPort.cs diff --git a/source/IO/CreativeCoders.IO/Ports/ReactiveSerialPort.cs b/source/IO/CreativeCoders.IO.Ports/ReactiveSerialPort.cs similarity index 83% rename from source/IO/CreativeCoders.IO/Ports/ReactiveSerialPort.cs rename to source/IO/CreativeCoders.IO.Ports/ReactiveSerialPort.cs index bb4fff17..9741a31f 100644 --- a/source/IO/CreativeCoders.IO/Ports/ReactiveSerialPort.cs +++ b/source/IO/CreativeCoders.IO.Ports/ReactiveSerialPort.cs @@ -13,14 +13,11 @@ public sealed class ReactiveSerialPort : IObservable, IDisposable [ExcludeFromCodeCoverage] public ReactiveSerialPort(string portName) - : this(new ExtendedSerialPort(Ensure.NotNull(portName, nameof(portName)))) - { - - } + : this(new ExtendedSerialPort(Ensure.NotNull(portName))) { } public ReactiveSerialPort(ISerialPort serialPort) { - _serialPort = Ensure.NotNull(serialPort, nameof(serialPort)); + _serialPort = Ensure.NotNull(serialPort); _dataObservable = _serialPort.ToDataObservable(); } diff --git a/source/IO/CreativeCoders.IO/Ports/SerialPortExtensions.cs b/source/IO/CreativeCoders.IO.Ports/SerialPortExtensions.cs similarity index 100% rename from source/IO/CreativeCoders.IO/Ports/SerialPortExtensions.cs rename to source/IO/CreativeCoders.IO.Ports/SerialPortExtensions.cs diff --git a/source/IO/CreativeCoders.IO/Ports/SerialPortWrapper.cs b/source/IO/CreativeCoders.IO.Ports/SerialPortWrapper.cs similarity index 76% rename from source/IO/CreativeCoders.IO/Ports/SerialPortWrapper.cs rename to source/IO/CreativeCoders.IO.Ports/SerialPortWrapper.cs index bd52bf21..705ad438 100644 --- a/source/IO/CreativeCoders.IO/Ports/SerialPortWrapper.cs +++ b/source/IO/CreativeCoders.IO.Ports/SerialPortWrapper.cs @@ -7,14 +7,9 @@ namespace CreativeCoders.IO.Ports; [ExcludeFromCodeCoverage] [PublicAPI] -public sealed class SerialPortWrapper : ISerialPort +public sealed class SerialPortWrapper(SerialPort serialPort) : ISerialPort { - private readonly SerialPort _serialPort; - - public SerialPortWrapper(SerialPort serialPort) - { - _serialPort = Ensure.NotNull(serialPort); - } + private readonly SerialPort _serialPort = Ensure.NotNull(serialPort); public void Dispose() { diff --git a/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriter.cs b/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriter.cs deleted file mode 100644 index 0f66e1ca..00000000 --- a/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriter.cs +++ /dev/null @@ -1,93 +0,0 @@ -using CreativeCoders.Core; -using TapeArchive; - -namespace CreativeCoders.IO.Archives; - -public sealed class DefaultTarArchiveWriter : TarArchiveWriterBase -{ - private readonly Stream _tarStream; - - private readonly ArchiveBuilder _archiveBuilder; - - public DefaultTarArchiveWriter(Stream tarStream, bool preserveFileMode, bool withOwnerAndGroup) - : base(preserveFileMode, withOwnerAndGroup) - { - _tarStream = Ensure.NotNull(tarStream); - - _archiveBuilder = new ArchiveBuilder(_tarStream, true); - } - - public override async ValueTask DisposeAsync() - { - await _archiveBuilder.CompleteAsync().ConfigureAwait(false); - - await _archiveBuilder.DisposeAsync().ConfigureAwait(false); - - await _tarStream.DisposeAsync().ConfigureAwait(false); - } - - public override async Task AddFileAsync(string fileNameInArchive, Stream fileContent, long contentSize, - int fileMode, TarFileOwnerInfo fileOwnerInfo, DateTime modificationTime) - { - var itemName = ItemName.FromFileSystem(fileNameInArchive, false); - - await _archiveBuilder.WriteItemAsync( - new UstarItem(PrePosixType.RegularFile, itemName) - { - Content = fileContent, - Size = contentSize, - Mode = fileMode, - UserId = fileOwnerInfo.UserId, - GroupId = fileOwnerInfo.GroupId, - UserName = fileOwnerInfo.UserName, - GroupName = fileOwnerInfo.GroupName, - ModificationTime = modificationTime - }, - null) - .ConfigureAwait(false); - } - - // public Task AddFromDirectoryAsync(string path) - // { - // return AddFromDirectoryAsync(path, string.Empty); - // } - // - // public async Task AddFromDirectoryAsync(string path, string removePrefix) - // { - // await FileSys - // .Directory - // .EnumerateFiles(path) - // .ForEachAsync(async x => await AddFileAsync(x, RemovePrefix(x, removePrefix)).ConfigureAwait(false)) - // .ConfigureAwait(false); - // - // await FileSys - // .Directory - // .EnumerateDirectories(path) - // .ForEachAsync(async x => await AddFromDirectoryAsync(x, removePrefix).ConfigureAwait(false)) - // .ConfigureAwait(false); - // } - - // private static string RemovePrefix(string path, string prefix) - // { - // if (string.IsNullOrEmpty(prefix) || !path.StartsWith(prefix, StringComparison.CurrentCulture)) - // { - // return path; - // } - // - // return path[prefix.Length..]; - // } - // - // private int GetFileMode(string fileName) - // { - // return _preserveFileMode - // ? _tarFileInfoAccess.GetFileMode(fileName) - // : 0; - // } - // - // private TarFileOwnerInfo GetOwner(string fileName) - // { - // return _withOwnerAndGroup - // ? _tarFileInfoAccess.GetOwner(fileName) - // : new TarFileOwnerInfo(); - // } -} diff --git a/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriterBuilder.cs b/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriterBuilder.cs deleted file mode 100644 index 707a10aa..00000000 --- a/source/IO/CreativeCoders.IO/Archives/DefaultTarArchiveWriterBuilder.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.IO.Compression; - -namespace CreativeCoders.IO.Archives; - -public class DefaultTarArchiveWriterBuilder : ITarArchiveWriterBuilder -{ - private bool _withGZip; - - private bool _withOwnerAndGroup; - - private bool _preserveFileMode; - - public ITarArchiveWriterBuilder WithGZip() - { - _withGZip = true; - - return this; - } - - public ITarArchiveWriterBuilder WithOwnerAndGroup() - { - _withOwnerAndGroup = true; - - return this; - } - - public ITarArchiveWriterBuilder PreserveFileMode() - { - _preserveFileMode = true; - - return this; - } - - public ITarArchiveWriter Build(Stream outputStream) - { - var tarStream = _withGZip - ? new GZipStream(outputStream, CompressionLevel.Optimal) - : outputStream; - - return new DefaultTarArchiveWriter(tarStream, _preserveFileMode, _withOwnerAndGroup); - //return new SharpZipLibTarArchiveWriter(tarStream, _preserveFileMode, _withOwnerAndGroup); - } -} diff --git a/source/IO/CreativeCoders.IO/Archives/ITarArchiveBuilder.cs b/source/IO/CreativeCoders.IO/Archives/ITarArchiveBuilder.cs deleted file mode 100644 index a3fa0806..00000000 --- a/source/IO/CreativeCoders.IO/Archives/ITarArchiveBuilder.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public interface ITarArchiveWriterBuilder -{ - ITarArchiveWriterBuilder WithGZip(); - - ITarArchiveWriterBuilder WithOwnerAndGroup(); - - ITarArchiveWriterBuilder PreserveFileMode(); - - ITarArchiveWriter Build(Stream outputStream); -} diff --git a/source/IO/CreativeCoders.IO/Archives/ITarArchiveCreator.cs b/source/IO/CreativeCoders.IO/Archives/ITarArchiveCreator.cs deleted file mode 100644 index 166f8d7c..00000000 --- a/source/IO/CreativeCoders.IO/Archives/ITarArchiveCreator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public interface ITarArchiveCreator -{ - ITarArchiveCreator SetArchiveFileName(string archiveFileName); - - ITarArchiveCreator AddFromDirectory(string path, string searchPattern, bool recursive); - - void Create(); - - void Create(bool gzipArchive); -} diff --git a/source/IO/CreativeCoders.IO/Archives/ITarArchiveWriter.cs b/source/IO/CreativeCoders.IO/Archives/ITarArchiveWriter.cs deleted file mode 100644 index f287829f..00000000 --- a/source/IO/CreativeCoders.IO/Archives/ITarArchiveWriter.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public interface ITarArchiveWriter : IAsyncDisposable -{ - Task AddFileAsync(string fileName, string fileNameInArchive); - - Task AddFileAsync(string fileNameInArchive, Stream fileContent, long contentSize, int fileMode, - TarFileOwnerInfo fileOwnerInfo, DateTime modificationTime); - - Task AddFromDirectoryAsync(string path, string removePrefix); - - Task AddFromDirectoryAsync(string path); -} diff --git a/source/IO/CreativeCoders.IO/Archives/ITarFileInfoAccess.cs b/source/IO/CreativeCoders.IO/Archives/ITarFileInfoAccess.cs deleted file mode 100644 index 916b51fa..00000000 --- a/source/IO/CreativeCoders.IO/Archives/ITarFileInfoAccess.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public interface ITarFileInfoAccess -{ - int GetFileMode(string path); - - TarFileOwnerInfo GetOwner(string path); - - DateTime GetModificationTime(string path); -} diff --git a/source/IO/CreativeCoders.IO/Archives/IZipArchiveCreator.cs b/source/IO/CreativeCoders.IO/Archives/IZipArchiveCreator.cs deleted file mode 100644 index 446f7a63..00000000 --- a/source/IO/CreativeCoders.IO/Archives/IZipArchiveCreator.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public interface IZipArchiveCreator -{ - IZipArchiveCreator SetArchiveFileName(string archiveFileName); - - IZipArchiveCreator AddFromDirectory(string path, string searchPattern, bool recursive); - - void Create(); -} diff --git a/source/IO/CreativeCoders.IO/Archives/SharpZipLibTarArchiveWriter.cs b/source/IO/CreativeCoders.IO/Archives/SharpZipLibTarArchiveWriter.cs deleted file mode 100644 index 8a79a971..00000000 --- a/source/IO/CreativeCoders.IO/Archives/SharpZipLibTarArchiveWriter.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text; -using CreativeCoders.Core; -using CreativeCoders.Core.IO; -using ICSharpCode.SharpZipLib.Tar; -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public sealed class SharpZipLibTarArchiveWriter : TarArchiveWriterBase -{ - private readonly Stream _tarStream; - - private readonly TarOutputStream _tarOutputStream; - - public SharpZipLibTarArchiveWriter(Stream tarStream, bool preserveFileMode, bool withOwnerAndGroup) - : base(preserveFileMode, withOwnerAndGroup) - { - _tarStream = Ensure.NotNull(tarStream); - - _tarOutputStream = new TarOutputStream(_tarStream, Encoding.ASCII); - } - - public override async ValueTask DisposeAsync() - { - await _tarOutputStream.DisposeAsync().ConfigureAwait(false); - await _tarStream.DisposeAsync().ConfigureAwait(false); - } - - public override async Task AddFileAsync(string fileNameInArchive, Stream fileContent, long contentSize, int fileMode, - TarFileOwnerInfo fileOwnerInfo, DateTime modificationTime) - { - var tarEntry = TarEntry.CreateTarEntry(fileNameInArchive.Replace(FileSys.Path.DirectorySeparatorChar, '/')); - - tarEntry.UserName = fileOwnerInfo.UserName; - tarEntry.UserId = fileOwnerInfo.UserId; - tarEntry.GroupName = fileOwnerInfo.GroupName; - tarEntry.GroupId = fileOwnerInfo.GroupId; - tarEntry.TarHeader.Mode = fileMode; - tarEntry.ModTime = modificationTime.ToUniversalTime(); - - tarEntry.Size = contentSize; - - await _tarOutputStream.PutNextEntryAsync(tarEntry, CancellationToken.None).ConfigureAwait(false); - - await fileContent.CopyToAsync(_tarOutputStream).ConfigureAwait(false); - - await _tarOutputStream.CloseEntryAsync(CancellationToken.None).ConfigureAwait(false); - } -} diff --git a/source/IO/CreativeCoders.IO/Archives/TarArchiveCreator.cs b/source/IO/CreativeCoders.IO/Archives/TarArchiveCreator.cs deleted file mode 100644 index 56ae47ec..00000000 --- a/source/IO/CreativeCoders.IO/Archives/TarArchiveCreator.cs +++ /dev/null @@ -1,48 +0,0 @@ -using CreativeCoders.Core.IO; -using SharpCompress.Archives; -using SharpCompress.Archives.Tar; -using SharpCompress.Common; -using SharpCompress.Writers.GZip; -using SharpCompress.Writers.Tar; - -namespace CreativeCoders.IO.Archives; - -public class TarArchiveCreator : ITarArchiveCreator -{ - private readonly TarArchive _archive = TarArchive.Create(); - - private string? _archiveFileName; - - public ITarArchiveCreator SetArchiveFileName(string archiveFileName) - { - _archiveFileName = archiveFileName; - - return this; - } - - public ITarArchiveCreator AddFromDirectory(string path, string searchPattern, bool recursive) - { - _archive.AddAllFromDirectory(path, searchPattern, - recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); - - return this; - } - - public void Create() - { - Create(false); - } - - public void Create(bool gzipArchive) - { - ArgumentNullException.ThrowIfNull(_archiveFileName); - - using var outputStream = FileSys.File.Create(_archiveFileName); - - _archive.SaveTo( - outputStream, - gzipArchive - ? new GZipWriterOptions() - : new TarWriterOptions(CompressionType.None, true)); - } -} diff --git a/source/IO/CreativeCoders.IO/Archives/TarArchiveWriterBase.cs b/source/IO/CreativeCoders.IO/Archives/TarArchiveWriterBase.cs deleted file mode 100644 index fee3e0aa..00000000 --- a/source/IO/CreativeCoders.IO/Archives/TarArchiveWriterBase.cs +++ /dev/null @@ -1,82 +0,0 @@ -using CreativeCoders.Core.Collections; -using CreativeCoders.Core.IO; -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public abstract class TarArchiveWriterBase : ITarArchiveWriter -{ - private readonly bool _preserveFileMode; - - private readonly bool _withOwnerAndGroup; - - private readonly ITarFileInfoAccess _tarFileInfoAccess; - - protected TarArchiveWriterBase(bool preserveFileMode, bool withOwnerAndGroup) - { - _preserveFileMode = preserveFileMode; - _withOwnerAndGroup = withOwnerAndGroup; - - _tarFileInfoAccess = new WindowsTarFileInfoAccess(); - } - - public abstract ValueTask DisposeAsync(); - - public async Task AddFileAsync(string fileName, string fileNameInArchive) - { - var fs = FileSys.File.OpenRead(fileName); - await using var _ = fs.ConfigureAwait(false); - - await AddFileAsync(fileNameInArchive, fs, fs.Length, GetFileMode(fileName), GetOwner(fileName), - _tarFileInfoAccess.GetModificationTime(fileName)) - .ConfigureAwait(false); - } - - public abstract Task AddFileAsync(string fileNameInArchive, Stream fileContent, long contentSize, - int fileMode, TarFileOwnerInfo fileOwnerInfo, DateTime modificationTime); - - public async Task AddFromDirectoryAsync(string path, string removePrefix) - { - await FileSys - .Directory - .EnumerateFiles(path) - .ForEachAsync(async x => await AddFileAsync(x, RemovePrefix(x, removePrefix)).ConfigureAwait(false)) - .ConfigureAwait(false); - - await FileSys - .Directory - .EnumerateDirectories(path) - .ForEachAsync(async x => await AddFromDirectoryAsync(x, removePrefix).ConfigureAwait(false)) - .ConfigureAwait(false); - } - - public Task AddFromDirectoryAsync(string path) - { - return AddFromDirectoryAsync(path, string.Empty); - } - - protected static string RemovePrefix(string path, string prefix) - { - if (string.IsNullOrEmpty(prefix) || !path.StartsWith(prefix, StringComparison.CurrentCulture)) - { - return path; - } - - return path[prefix.Length..]; - } - - protected int GetFileMode(string fileName) - { - return _preserveFileMode - ? _tarFileInfoAccess.GetFileMode(fileName) - : 0; - } - - protected TarFileOwnerInfo GetOwner(string fileName) - { - return _withOwnerAndGroup - ? _tarFileInfoAccess.GetOwner(fileName) - : new TarFileOwnerInfo(); - } -} diff --git a/source/IO/CreativeCoders.IO/Archives/TarFileOwnerInfo.cs b/source/IO/CreativeCoders.IO/Archives/TarFileOwnerInfo.cs deleted file mode 100644 index 5da31e82..00000000 --- a/source/IO/CreativeCoders.IO/Archives/TarFileOwnerInfo.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JetBrains.Annotations; - -namespace CreativeCoders.IO.Archives; - -[PublicAPI] -public class TarFileOwnerInfo -{ - public int UserId { get; init; } - - public int GroupId { get; init; } - - public string UserName { get; init; } = string.Empty; - - public string GroupName { get; init; } = string.Empty; -} diff --git a/source/IO/CreativeCoders.IO/Archives/WindowsTarFileInfoAccess.cs b/source/IO/CreativeCoders.IO/Archives/WindowsTarFileInfoAccess.cs deleted file mode 100644 index 56e6e60b..00000000 --- a/source/IO/CreativeCoders.IO/Archives/WindowsTarFileInfoAccess.cs +++ /dev/null @@ -1,33 +0,0 @@ -using CreativeCoders.Core.IO; -using TapeArchive; - -namespace CreativeCoders.IO.Archives; - -public class WindowsTarFileInfoAccess : ITarFileInfoAccess -{ - public int GetFileMode(string path) - { - return UnixPermissions.GroupExecute | - UnixPermissions.GroupRead | - UnixPermissions.GroupWrite | - UnixPermissions.OwnerExecute | - UnixPermissions.OwnerRead | - UnixPermissions.OwnerWrite | - UnixPermissions.OtherExecute | - UnixPermissions.OtherRead | - UnixPermissions.OtherWrite; - } - - public TarFileOwnerInfo GetOwner(string path) - { - return new TarFileOwnerInfo - { - UserName = Environment.UserName - }; - } - - public DateTime GetModificationTime(string path) - { - return FileSys.File.GetLastWriteTime(path); - } -} diff --git a/source/IO/CreativeCoders.IO/Archives/ZipArchiveCreator.cs b/source/IO/CreativeCoders.IO/Archives/ZipArchiveCreator.cs deleted file mode 100644 index b30d20fd..00000000 --- a/source/IO/CreativeCoders.IO/Archives/ZipArchiveCreator.cs +++ /dev/null @@ -1,36 +0,0 @@ -using CreativeCoders.Core.IO; -using SharpCompress.Archives; -using SharpCompress.Archives.Zip; - -namespace CreativeCoders.IO.Archives; - -public class ZipArchiveCreator : IZipArchiveCreator -{ - private readonly ZipArchive _archive = ZipArchive.Create(); - - private string? _archiveFileName; - - public IZipArchiveCreator SetArchiveFileName(string archiveFileName) - { - _archiveFileName = archiveFileName; - - return this; - } - - public IZipArchiveCreator AddFromDirectory(string path, string searchPattern, bool recursive) - { - _archive.AddAllFromDirectory(path, searchPattern, - recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); - - return this; - } - - public void Create() - { - ArgumentNullException.ThrowIfNull(_archiveFileName); - - using var outputStream = FileSys.File.Create(_archiveFileName); - - _archive.SaveTo(outputStream); - } -} diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index 33165a6f..b6ed061f 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -20,10 +20,11 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + + From c61c7e318c461e1deda9ab4a2865778618602465 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 10:38:44 +0100 Subject: [PATCH 06/42] Use `Ensure.NotNull` for null validation in archive classes - Updated constructors in `ZipArchiveWriter`, `ZipArchiveReader`, `TarArchiveWriter`, `TarArchiveReader`, and `TarGzArchiveReader` to use `Ensure.NotNull` for null validation. - Improved code clarity and standardized null handling across archive operations. --- .../CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs | 6 ++++-- .../CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs | 3 ++- .../Tar/TarGzArchiveReader.cs | 8 +++++--- .../CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs | 6 ++++-- .../CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs | 10 +++++++--- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs index e6e9b0ef..17c06654 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -1,10 +1,11 @@ using System.Formats.Tar; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; public sealed class TarArchiveReader(Stream inputStream) : ITarArchiveReader { - private TarReader _tarReader = new TarReader(inputStream, true); + private TarReader _tarReader = new TarReader(Ensure.NotNull(inputStream), true); private bool _needsReset; @@ -112,7 +113,8 @@ public Task OpenEntryStreamAsync(ArchiveEntry entry) throw new NotImplementedException(); } - public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) + public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, + bool overwriteExisting = true) { var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs index 57218f2b..d33ec77e 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -1,10 +1,11 @@ using System.Formats.Tar; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; public sealed class TarArchiveWriter(Stream outputStream) : ITarArchiveWriter { - private readonly TarWriter _tarWriter = new TarWriter(outputStream, TarEntryFormat.Pax); + private readonly TarWriter _tarWriter = new TarWriter(Ensure.NotNull(outputStream), TarEntryFormat.Pax); public Task AddFileAsync(string fileName, string fileNameInArchive) { diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs index 01c64685..17179f3f 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -1,5 +1,6 @@ using System.Formats.Tar; using System.IO.Compression; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; @@ -15,8 +16,8 @@ public sealed class TarGzArchiveReader : ITarArchiveReader public TarGzArchiveReader(Stream inputStream) { - _gzInputStream = new GZipStream(inputStream, CompressionMode.Decompress, true); - _sourceStream = inputStream; + _sourceStream = Ensure.NotNull(inputStream); + _gzInputStream = new GZipStream(_sourceStream, CompressionMode.Decompress, true); _tarReader = new TarReader(_gzInputStream, true); } @@ -114,7 +115,8 @@ public Task OpenEntryStreamAsync(ArchiveEntry entry) throw new NotImplementedException(); } - public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) + public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, + bool overwriteExisting = true) { var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index a42e7714..b11778fd 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -1,10 +1,11 @@ using System.IO.Compression; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Zip; public sealed class ZipArchiveReader(ZipArchive zipArchive) : IZipArchiveReader { - private readonly ZipArchive _zipArchive = zipArchive ?? throw new ArgumentNullException(nameof(zipArchive)); + private readonly ZipArchive _zipArchive = Ensure.NotNull(zipArchive); public static ZipArchiveReader Create(Stream inputStream) { @@ -51,7 +52,8 @@ public Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool ove : ExtractFileCoreAsync(zipEntry, outputFilePath, overwriteExisting); } - private static Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, bool overwriteExisting) + private static Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, + bool overwriteExisting) { var outputDirectory = Path.GetDirectoryName(outputFilePath); if (outputDirectory != null) diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs index 4984ab03..b87f97e5 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Zip; @@ -6,7 +7,8 @@ public sealed class ZipArchiveWriter( Stream outputStream, CompressionLevel defaultCompressionLevel) : IZipArchiveWriter { - private readonly ZipArchive _archive = new ZipArchive(outputStream, ZipArchiveMode.Create); + private readonly ZipArchive + _archive = new ZipArchive(Ensure.NotNull(outputStream), ZipArchiveMode.Create); public Task AddFileAsync(string fileName, string fileNameInArchive, CompressionLevel compressionLevel) { @@ -15,13 +17,15 @@ public Task AddFileAsync(string fileName, string fileNameInArchive, CompressionL public async Task AddFileAsync(Stream stream, string fileNameInArchive, CompressionLevel compressionLevel) { - await using var fileStream = await _archive.CreateEntry(fileNameInArchive, compressionLevel).OpenAsync() + await using var fileStream = await _archive.CreateEntry(fileNameInArchive, compressionLevel) + .OpenAsync() .ConfigureAwait(false); await stream.CopyToAsync(fileStream).ConfigureAwait(false); } - public async Task AddFromDirectoryAsync(string path, string basePathToRemove, CompressionLevel compressionLevel) + public async Task AddFromDirectoryAsync(string path, string basePathToRemove, + CompressionLevel compressionLevel) { var files = Directory.GetFiles(path, "*", SearchOption.AllDirectories); From c34ef9c0b88ebd2054ca0f543311e6d31e6f53e7 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:46:34 +0100 Subject: [PATCH 07/42] - Refactor `FrostingTaskBase` to enable skipping tasks without settings via the new `SkipIfNoSettings` property. - Update `CreateDistPackagesTask` to use `IArchiveWriterFactory` for TAR.GZ and ZIP packaging. - Simplify `DefaultHostSetup` to summarize task and version setup during execution. - Fix incorrect `ProjectReference` paths in `CreativeCoders.CakeBuild.csproj`. - Enhance dependency injection for `IFileSystem` to use `IFileSystemEx`. - Add `PublicAPI` annotations to improve IDE support across tasks and settings. --- .../CakeHostBuilder.cs | 47 ----------------- .../CakeHostExtensions.cs | 1 + .../CreativeCoders.CakeBuild.csproj | 2 +- .../DefaultHostSetup.cs | 50 +++++++++++++++++++ .../Tasks/Defaults/CreateDistPackagesTask.cs | 9 ++++ .../Tasks/FrostingTaskBase.cs | 18 ++++++- .../Tasks/Templates/CreateDistPackagesTask.cs | 44 ++++++++++------ .../Tasks/Templates/PublishTask.cs | 4 ++ .../IO/IOServiceCollectionExtensions.cs | 2 +- .../Tar/TarArchiveReaderFactory.cs | 6 ++- 10 files changed, 114 insertions(+), 69 deletions(-) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/DefaultHostSetup.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateDistPackagesTask.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs index 3fe46049..0a9905ab 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs @@ -1,10 +1,6 @@ -using Cake.Common.Diagnostics; -using Cake.Core; using Cake.Frosting; -using CreativeCoders.CakeBuild.Tasks.Templates.Settings; using CreativeCoders.Core; using CreativeCoders.Core.IO; -using JetBrains.Annotations; namespace CreativeCoders.CakeBuild; @@ -93,46 +89,3 @@ public CakeHost Build() return _cakeHost; } } - -[UsedImplicitly] -public class DefaultHostSetup : IFrostingSetup -{ - public void Setup(ICakeContext context, ISetupContext info) - { - if (context is not CakeBuildContext buildContext) - { - return; - } - - if (!buildContext.PrintSetupSummary) - { - return; - } - - context.Information("Build Setup Summary"); - context.Information("==================="); - context.Information("Tasks for execution:"); - - foreach (var task in info.TasksToExecute) - { - context.Information($"- {task.Name}"); - } - - var pushSettings = buildContext.GetSettings(); - - if (pushSettings != null) - { - context.Information($"Skip Push: {pushSettings.SkipPush}"); - } - - var version = buildContext.Version; - - context.Information("Version Info"); - context.Information("============"); - - context.Information($"Informational Version: {version.InformationalVersion}"); - context.Information($"Assembly Version: {version.AssemblySemVer}"); - context.Information($"File Version: {version.AssemblySemFileVer}"); - context.Information($"Package Version: {version.SemVer}"); - } -} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs index edb6dcbf..e078cc93 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs @@ -3,6 +3,7 @@ using CreativeCoders.CakeBuild.BuildServer; using CreativeCoders.CakeBuild.Tasks.Defaults; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.IO.Archives; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj index ab1e4216..8c9c56f8 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj @@ -12,7 +12,7 @@ - + diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/DefaultHostSetup.cs b/source/CakeBuild/CreativeCoders.CakeBuild/DefaultHostSetup.cs new file mode 100644 index 00000000..68f18137 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/DefaultHostSetup.cs @@ -0,0 +1,50 @@ +using Cake.Common.Diagnostics; +using Cake.Core; +using Cake.Frosting; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using JetBrains.Annotations; + +namespace CreativeCoders.CakeBuild; + +[UsedImplicitly] +public class DefaultHostSetup : IFrostingSetup +{ + public void Setup(ICakeContext context, ISetupContext info) + { + if (context is not CakeBuildContext buildContext) + { + return; + } + + if (!buildContext.PrintSetupSummary) + { + return; + } + + context.Information("Build Setup Summary"); + context.Information("==================="); + context.Information("Tasks for execution:"); + + foreach (var task in info.TasksToExecute) + { + context.Information($"- {task.Name}"); + } + + var pushSettings = buildContext.GetSettings(); + + if (pushSettings != null) + { + context.Information($"Skip Push: {pushSettings.SkipPush}"); + } + + var version = buildContext.Version; + + context.Information("Version Info"); + context.Information("============"); + + context.Information($"Informational Version: {version.InformationalVersion}"); + context.Information($"Assembly Version: {version.AssemblySemVer}"); + context.Information($"File Version: {version.AssemblySemFileVer}"); + context.Information($"Package Version: {version.SemVer}"); + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateDistPackagesTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateDistPackagesTask.cs new file mode 100644 index 00000000..0cc69028 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateDistPackagesTask.cs @@ -0,0 +1,9 @@ +using Cake.Frosting; +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.IO.Archives; + +namespace CreativeCoders.CakeBuild.Tasks.Defaults; + +[TaskName("CreateDistPackages")] +public class CreateDistPackagesTask(IArchiveWriterFactory archiveWriterFactory) + : CreateDistPackagesTask(archiveWriterFactory); diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs index 758842df..6a443c5e 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs @@ -35,16 +35,30 @@ public override void Finally(T context) } } +[PublicAPI] public abstract class FrostingTaskBase : FrostingTaskBase where TBuildContext : CakeBuildContext where TSettings : class { protected sealed override Task RunAsyncCore(TBuildContext context) { - var taskSettings = context.GetRequiredSettings(); + var taskSettings = context.GetSettings(); + + if (taskSettings != null) + { + return RunAsyncCore(context, taskSettings); + } - return RunAsyncCore(context, taskSettings); + if (!SkipIfNoSettings) + { + throw new InvalidOperationException($"No settings found for task '{ReadTaskName()}'"); + } + + context.Information($"Skip task '{ReadTaskName()}' because no settings found"); + return Task.CompletedTask; } protected abstract Task RunAsyncCore(TBuildContext context, TSettings taskSettings); + + protected virtual bool SkipIfNoSettings => false; } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs index 40b5858d..1be463ef 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs @@ -1,39 +1,51 @@ using CreativeCoders.CakeBuild.Tasks.Templates.Settings; -using CreativeCoders.Core.Collections; +using CreativeCoders.Core; using CreativeCoders.IO.Archives; namespace CreativeCoders.CakeBuild.Tasks.Templates; public class - CreateDistPackagesTask : FrostingTaskBase + CreateDistPackagesTask(IArchiveWriterFactory archiveWriterFactory) + : FrostingTaskBase where TBuildContext : CakeBuildContext { - protected override Task RunAsyncCore(TBuildContext context, ICreateDistPackagesTaskSettings taskSettings) + private readonly IArchiveWriterFactory _archiveWriterFactory = Ensure.NotNull(archiveWriterFactory); + + protected override async Task RunAsyncCore(TBuildContext context, + ICreateDistPackagesTaskSettings taskSettings) { foreach (var distPackage in taskSettings.DistPackages) { switch (distPackage.Format) { case DistPackageFormat.TarGz: - new TarArchiveCreator() - .SetArchiveFileName(taskSettings.DistOutputPath - .CombineWithFilePath($"{distPackage.Name}.tar.gz").FullPath) - .AddFromDirectory(distPackage.DistFolder.FullPath, "*.*", true) - .Create(true); + await CreateArchive(() => _archiveWriterFactory.CreateTarGzWriter( + taskSettings.DistOutputPath.CombineWithFilePath($"{distPackage.Name}.tar.gz") + .FullPath), distPackage) + .ConfigureAwait(false); break; case DistPackageFormat.Zip: - new ZipArchiveCreator() - .SetArchiveFileName(taskSettings.DistOutputPath - .CombineWithFilePath($"{distPackage.Name}.zip").FullPath) - .AddFromDirectory(distPackage.DistFolder.FullPath, "*.*", true) - .Create(); + await CreateArchive(() => _archiveWriterFactory.CreateZipWriter( + taskSettings.DistOutputPath.CombineWithFilePath($"{distPackage.Name}.zip") + .FullPath), distPackage) + .ConfigureAwait(false); break; default: - throw new ArgumentOutOfRangeException(nameof(distPackage), - "Package format not supported"); + throw new InvalidOperationException("Package format not supported"); } } + } - return Task.CompletedTask; + private static async Task CreateArchive(Func archiveWriterFactory, + DistPackage distPackage) + { + var archiveWriter = archiveWriterFactory(); + await using var writer = archiveWriter.ConfigureAwait(false); + + await archiveWriter + .AddFromDirectoryAsync(distPackage.DistFolder.FullPath, distPackage.DistFolder.FullPath) + .ConfigureAwait(false); } + + protected override bool SkipIfNoSettings => true; } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs index 4b5fab63..f3b3dc6c 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs @@ -1,9 +1,11 @@ using Cake.Common.Tools.DotNet; using Cake.Common.Tools.DotNet.Publish; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using JetBrains.Annotations; namespace CreativeCoders.CakeBuild.Tasks.Templates; +[PublicAPI] public class PublishTask : FrostingTaskBase where TBuildContext : CakeBuildContext { @@ -53,4 +55,6 @@ protected virtual DotNetPublishSettings CreateDotNetPublishSettings(PublishingIt return dotNetPublishSettings; } + + protected override bool SkipIfNoSettings => true; } diff --git a/source/Core/CreativeCoders.Core/IO/IOServiceCollectionExtensions.cs b/source/Core/CreativeCoders.Core/IO/IOServiceCollectionExtensions.cs index c592a0a4..5a3bd61d 100644 --- a/source/Core/CreativeCoders.Core/IO/IOServiceCollectionExtensions.cs +++ b/source/Core/CreativeCoders.Core/IO/IOServiceCollectionExtensions.cs @@ -10,8 +10,8 @@ public static class IOServiceCollectionExtensions { public static IServiceCollection AddFileSystem(this IServiceCollection services) { - services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); return services; } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs index aa696107..a8a9f796 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs @@ -1,3 +1,5 @@ +using CreativeCoders.Core.IO; + namespace CreativeCoders.IO.Archives.Tar; public class TarArchiveReaderFactory : ITarArchiveReaderFactory @@ -12,7 +14,7 @@ public ITarArchiveReader CreateReader(Stream inputStream, bool useGZipCompressio public ITarArchiveReader CreateReader(string archiveFileName, bool? useGZipCompression = null) { return CreateReader( - File.OpenRead(archiveFileName), useGZipCompression ?? - TarFileHelper.IsGZipFile(archiveFileName)); + FileSys.File.OpenRead(archiveFileName), useGZipCompression ?? + TarFileHelper.IsGZipFile(archiveFileName)); } } From 6a1cf0fbbd6f44ad538383f98ca11db56542e433 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:05:32 +0100 Subject: [PATCH 08/42] Enhance TAR/ZIP archive handling with `IFileSystem` integration and `copyData` support in `OpenEntryStreamAsync` --- .../ArchivesServiceCollectionExtensions.cs | 4 ++- .../IArchiveReader.cs | 2 +- .../Tar/TarArchiveReader.cs | 35 +++++++++++++++---- .../Tar/TarArchiveReaderFactory.cs | 13 ++++--- .../Tar/TarArchiveWriterFactory.cs | 15 +++++--- .../Tar/TarGzArchiveReader.cs | 22 ++++++++++-- .../Zip/ZipArchiveReader.cs | 2 +- 7 files changed, 72 insertions(+), 21 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs b/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs index 8fcca15a..bb083007 100644 --- a/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs +++ b/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.Core.IO; +using CreativeCoders.IO.Archives.Tar; using CreativeCoders.IO.Archives.Zip; using Microsoft.Extensions.DependencyInjection; @@ -9,6 +10,7 @@ public static class ArchivesServiceCollectionExtensions public static IServiceCollection AddArchives(this IServiceCollection services) { return services + .AddFileSystem() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs index 965f3756..23f9fc22 100644 --- a/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/IArchiveReader.cs @@ -6,7 +6,7 @@ public interface IArchiveReader : IDisposable, IAsyncDisposable IEnumerable GetEntries(); - Task OpenEntryStreamAsync(ArchiveEntry entry); + Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false); Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true); diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs index 17c06654..062dc4f0 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -1,10 +1,13 @@ using System.Formats.Tar; +using System.IO.Abstractions; using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; -public sealed class TarArchiveReader(Stream inputStream) : ITarArchiveReader +public sealed class TarArchiveReader(Stream inputStream, IFileSystem fileSystem) : ITarArchiveReader { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + private TarReader _tarReader = new TarReader(Ensure.NotNull(inputStream), true); private bool _needsReset; @@ -108,9 +111,27 @@ public IEnumerable GetEntries() return null; } - public Task OpenEntryStreamAsync(ArchiveEntry entry) + public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) { - throw new NotImplementedException(); + var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); + + if (tarEntry == null) + { + throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the archive."); + } + + var entryDataStream = tarEntry.DataStream ?? + throw new FileNotFoundException($"Entry '{entry.FullName}' has no stream."); + + if (!copyData) + { + return entryDataStream; + } + + var memoryStream = new MemoryStream(); + await entryDataStream.CopyToAsync(memoryStream).ConfigureAwait(false); + + return memoryStream; } public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, @@ -126,12 +147,12 @@ public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, await ExtractFileCoreAsync(tarEntry, outputFilePath, overwriteExisting).ConfigureAwait(false); } - private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + private Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) { var outputDirectory = Path.GetDirectoryName(outputFilePath); if (outputDirectory != null) { - Directory.CreateDirectory(outputDirectory); + _fileSystem.Directory.CreateDirectory(outputDirectory); } return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); @@ -140,7 +161,7 @@ private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePat public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { - var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); @@ -155,7 +176,7 @@ public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExis while (tarEntry is not null) { - var outputFileName = Path.Combine(outputBaseDirectory, tarEntry.Name); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, tarEntry.Name); await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs index a8a9f796..0a75a1c8 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs @@ -1,20 +1,23 @@ -using CreativeCoders.Core.IO; +using System.IO.Abstractions; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; -public class TarArchiveReaderFactory : ITarArchiveReaderFactory +public class TarArchiveReaderFactory(IFileSystem fileSystem) : ITarArchiveReaderFactory { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + public ITarArchiveReader CreateReader(Stream inputStream, bool useGZipCompression = true) { return useGZipCompression ? new TarGzArchiveReader(inputStream) - : new TarArchiveReader(inputStream); + : new TarArchiveReader(inputStream, _fileSystem); } public ITarArchiveReader CreateReader(string archiveFileName, bool? useGZipCompression = null) { return CreateReader( - FileSys.File.OpenRead(archiveFileName), useGZipCompression ?? - TarFileHelper.IsGZipFile(archiveFileName)); + _fileSystem.File.OpenRead(archiveFileName), useGZipCompression ?? + TarFileHelper.IsGZipFile(archiveFileName)); } } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs index 79757791..8fe3404e 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs @@ -1,20 +1,27 @@ +using System.IO.Abstractions; using System.IO.Compression; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Tar; -public class TarArchiveWriterFactory : ITarArchiveWriterFactory +public class TarArchiveWriterFactory(IFileSystem fileSystem) : ITarArchiveWriterFactory { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + public ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, CompressionLevel compressionLevel = CompressionLevel.Optimal) { - return new TarArchiveWriter(useGZipCompression ? new GZipStream(outputStream, compressionLevel) : outputStream); + return new TarArchiveWriter(useGZipCompression + ? new GZipStream(outputStream, compressionLevel) + : outputStream); } public ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, CompressionLevel compressionLevel = CompressionLevel.Optimal) { return CreateWriter( - File.Create(archiveFileName), useGZipCompression ?? - TarFileHelper.IsGZipFile(archiveFileName), compressionLevel); + _fileSystem.File.Create(archiveFileName), useGZipCompression ?? + TarFileHelper.IsGZipFile(archiveFileName), + compressionLevel); } } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs index 17179f3f..5b53bb4c 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -110,9 +110,27 @@ public IEnumerable GetEntries() return null; } - public Task OpenEntryStreamAsync(ArchiveEntry entry) + public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) { - throw new NotImplementedException(); + var tarEntry = await GetTarEntryAsync(entry).ConfigureAwait(false); + + if (tarEntry == null) + { + throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the archive."); + } + + var entryDataStream = tarEntry.DataStream ?? + throw new FileNotFoundException($"Entry '{entry.FullName}' has no stream."); + + if (!copyData) + { + return entryDataStream; + } + + var memoryStream = new MemoryStream(); + await entryDataStream.CopyToAsync(memoryStream).ConfigureAwait(false); + + return memoryStream; } public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index b11778fd..2f494425 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -34,7 +34,7 @@ public IEnumerable GetEntries() new ArchiveEntry(x.FullName)); } - public Task OpenEntryStreamAsync(ArchiveEntry entry) + public Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) { var zipEntry = _zipArchive.GetEntry(entry.FullName); From 76b089748789ba2ded7ea93cd17386f7566bac1f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:18:59 +0100 Subject: [PATCH 09/42] Remove redundant `ProjectReference` to `CreativeCoders.IO` in unit test project. --- .../CreativeCoders.IO.UnitTests.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index b6ed061f..7d60a988 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -25,7 +25,6 @@ - From 4a877dd178dd5a138ade705b4845c72f70b38f74 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:33:05 +0100 Subject: [PATCH 10/42] Add unit test files and exclude `ArchiveWriterFactory` from code coverage - Added placeholder unit test classes and test files for `TarArchiveWriter`. - Excluded `ArchiveWriterFactory` from code coverage analysis with `[ExcludeFromCodeCoverage]`. --- .../IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs | 4 +++- .../Archives/Tar/TarArchiveWriterTests.cs | 6 ++++++ .../Archives/TestFiles/TestSource01.html | 3 +++ .../Archives/TestFiles/TestText01.txt | 2 ++ 4 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt diff --git a/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs index 8907c761..1a29b440 100644 --- a/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs @@ -1,10 +1,12 @@ -using System.IO.Compression; +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; using CreativeCoders.Core; using CreativeCoders.IO.Archives.Tar; using CreativeCoders.IO.Archives.Zip; namespace CreativeCoders.IO.Archives; +[ExcludeFromCodeCoverage] public class ArchiveWriterFactory( IZipArchiveWriterFactory zipArchiveWriterFactory, ITarArchiveWriterFactory tarArchiveWriterFactory) : IArchiveWriterFactory diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs new file mode 100644 index 00000000..103ec6f2 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs @@ -0,0 +1,6 @@ +namespace CreativeCoders.IO.UnitTests.Archives.Tar; + +public class TarArchiveWriterTests +{ + +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html new file mode 100644 index 00000000..e7409a14 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html @@ -0,0 +1,3 @@ +

Caption

+ +

Test

diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt new file mode 100644 index 00000000..8c91d300 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt @@ -0,0 +1,2 @@ +Hello World! +qwertz From cab4934b5bc3c5170ebb3712eaa9b80575334e75 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:34:43 +0100 Subject: [PATCH 11/42] Update test file inclusion in `CreativeCoders.IO.UnitTests.csproj` - Mark `TestSource01.html` and `TestText01.txt` as content items instead of none. --- .../CreativeCoders.IO.UnitTests.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index 7d60a988..e5a61233 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -27,4 +27,11 @@ + + + + + + + From 7fb13b282da06e588df141120ba59e8c8c0a84f0 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 12:57:23 +0100 Subject: [PATCH 12/42] Add comprehensive unit tests for `TarArchiveWriter` and introduce new cleanup utilities - Added tests for various TAR archive creation operations, including files, streams, directories, and metadata configuration. - Updated `TarArchiveWriter` to support `leaveOpen` behavior through its constructor. - Introduced `FileCleanUp` and `DirectoryCleanUp` utilities for temporary resource cleanup in tests. --- .../IO/DirectoryCleanUp.cs | 24 ++ .../CreativeCoders.Core/IO/FileCleanUp.cs | 15 +- .../Tar/TarArchiveWriter.cs | 5 +- .../Archives/Tar/TarArchiveWriterTests.cs | 253 +++++++++++++++++- .../CreativeCoders.IO.UnitTests.csproj | 9 +- 5 files changed, 287 insertions(+), 19 deletions(-) create mode 100644 source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs diff --git a/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs new file mode 100644 index 00000000..d60c79a2 --- /dev/null +++ b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs @@ -0,0 +1,24 @@ +using System; + +namespace CreativeCoders.Core.IO; + +public sealed class DirectoryCleanUp(string directoryName, bool recursive = true, bool throwException = false) + : IDisposable +{ + public void Dispose() + { + try + { + FileSys.Directory.Delete(DirectoryName, recursive); + } + catch (Exception) + { + if (throwException) + { + throw; + } + } + } + + public string DirectoryName { get; } = Ensure.IsNotNullOrWhitespace(directoryName); +} diff --git a/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs b/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs index 7b15de1b..2a2538df 100644 --- a/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs +++ b/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs @@ -2,16 +2,8 @@ namespace CreativeCoders.Core.IO; -public sealed class FileCleanUp : IDisposable +public sealed class FileCleanUp(string fileName, bool throwException = false) : IDisposable { - private readonly bool _throwException; - - public FileCleanUp(string fileName, bool throwException = false) - { - FileName = fileName; - _throwException = throwException; - } - public void Dispose() { try @@ -20,13 +12,12 @@ public void Dispose() } catch (Exception) { - if (_throwException) + if (throwException) { throw; } } - } - public string FileName { get; } + public string FileName { get; } = Ensure.IsNotNullOrWhitespace(fileName); } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs index d33ec77e..4bee232e 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -3,9 +3,10 @@ namespace CreativeCoders.IO.Archives.Tar; -public sealed class TarArchiveWriter(Stream outputStream) : ITarArchiveWriter +public sealed class TarArchiveWriter(Stream outputStream, bool leaveOpen = false) : ITarArchiveWriter { - private readonly TarWriter _tarWriter = new TarWriter(Ensure.NotNull(outputStream), TarEntryFormat.Pax); + private readonly TarWriter _tarWriter = + new TarWriter(Ensure.NotNull(outputStream), TarEntryFormat.Pax, leaveOpen); public Task AddFileAsync(string fileName, string fileNameInArchive) { diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs index 103ec6f2..d06fc0d8 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs @@ -1,6 +1,257 @@ +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.IO.Archives.Tar; + namespace CreativeCoders.IO.UnitTests.Archives.Tar; +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +[Collection("FileSys")] public class TarArchiveWriterTests { - + [Fact] + public async Task AddFileAsync_FromFileName_FileIsAddedToArchive() + { + // Arrange + var tempFile = Path.GetTempFileName(); + const string fileContent = "Test content"; + await File.WriteAllTextAsync(tempFile, fileContent); + + using var fileCleanup = new FileCleanUp(tempFile); + + const string fileNameInArchive = "test_dir/test_file.txt"; + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFileAsync(tempFile, fileNameInArchive); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entry = await reader.GetNextEntryAsync(); + + entry + .Should().NotBeNull(); + + entry!.Name + .Should().Be("test_dir/test_file.txt"); + + entry.EntryType + .Should().Be(TarEntryType.RegularFile); + + await using var entryStream = entry.DataStream!; + using var readerStream = new StreamReader(entryStream, Encoding.UTF8); + var content = await readerStream.ReadToEndAsync(); + + content + .Should().Be(fileContent); + } + + [Fact] + public async Task AddFileAsync_FromStream_FileIsAddedToArchive() + { + // Arrange + const string fileContent = "Stream content"; + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + const string fileNameInArchive = "stream_file.txt"; + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFileAsync(inputStream, fileNameInArchive); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entry = await reader.GetNextEntryAsync(); + + entry + .Should().NotBeNull(); + + entry!.Name + .Should().Be(fileNameInArchive); + + await using var entryStream = entry.DataStream!; + using var readerStream = new StreamReader(entryStream, Encoding.UTF8); + var content = await readerStream.ReadToEndAsync(); + + content + .Should().Be(fileContent); + } + + [Fact] + public async Task AddFileAsync_WithConfigureEntry_MetadataIsSet() + { + // Arrange + const string fileContent = "Content with metadata"; + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + const string fileNameInArchive = "metadata_file.txt"; + using var outputStream = new MemoryStream(); + + const string userName = "testuser"; + const string groupName = "testgroup"; + const int userId = 1000; + const int groupId = 1000; + const UnixFileMode fileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFileAsync(inputStream, fileNameInArchive, x => + { + x.UserName = userName; + x.GroupName = groupName; + x.UserId = userId; + x.GroupId = groupId; + x.FileMode = fileMode; + }); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entry = await reader.GetNextEntryAsync() as PaxTarEntry; + + entry + .Should().NotBeNull(); + + entry!.UserName + .Should().Be(userName); + + entry.GroupName + .Should().Be(groupName); + + entry.Uid + .Should().Be(userId); + + entry.Gid + .Should().Be(groupId); + + entry.Mode + .Should().Be(fileMode); + } + + [Fact] + public async Task AddFileAsync_WithTarEntryInfo_MetadataIsSet() + { + // Arrange + const string fileContent = "Content with TarEntryInfo"; + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + const string fileNameInArchive = "info_file.txt"; + using var outputStream = new MemoryStream(); + + var entryInfo = new TarEntryInfo + { + UserName = "userinfo", + GroupName = "groupinfo", + UserId = 123, + GroupId = 456, + FileMode = UnixFileMode.UserRead + }; + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFileAsync(inputStream, fileNameInArchive, entryInfo); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entry = await reader.GetNextEntryAsync() as PaxTarEntry; + + entry + .Should().NotBeNull(); + + entry!.UserName + .Should().Be(entryInfo.UserName); + + entry.GroupName + .Should().Be(entryInfo.GroupName); + + entry.Uid + .Should().Be(entryInfo.UserId); + + entry.Gid + .Should().Be(entryInfo.GroupId); + + entry.Mode + .Should().Be(entryInfo.FileMode); + } + + [Fact] + public async Task AddFromDirectoryAsync_DirectoryIsAddedToArchive() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var subDir = Path.Combine(tempDir, "subdir"); + Directory.CreateDirectory(subDir); + + using var directoryCleanup = new DirectoryCleanUp(tempDir); + + var file1 = Path.Combine(tempDir, "file1.txt"); + var file2 = Path.Combine(subDir, "file2.txt"); + + await File.WriteAllTextAsync(file1, "content1"); + await File.WriteAllTextAsync(file2, "content2"); + + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFromDirectoryAsync(tempDir, tempDir); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entries = new List(); + TarEntry? entry; + while ((entry = await reader.GetNextEntryAsync()) != null) + { + entries.Add(entry); + } + + entries + .Should().HaveCount(2); + + entries + .Should().Contain(x => x.Name == "file1.txt"); + + entries + .Should().Contain(x => x.Name == "subdir/file2.txt"); + } + + [Fact] + public async Task DisposeAsync_ArchiveIsCorrectlyFinalized() + { + // Arrange + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new TarArchiveWriter(outputStream, true)) + { + await writer.AddFileAsync(new MemoryStream(Encoding.UTF8.GetBytes("test")), "test.txt"); + } + + // Assert + outputStream.Position = 0; + await using var reader = new TarReader(outputStream); + var entry = await reader.GetNextEntryAsync(); + + entry + .Should().NotBeNull(); + + (await reader.GetNextEntryAsync()) + .Should().BeNull(); // End of archive reached correctly + } } diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index e5a61233..c6e34c52 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -25,13 +25,14 @@ + - - - - + + + + From da8df820d5d55135f10a593363a09330cf6dcc60 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:04:59 +0100 Subject: [PATCH 13/42] Remove unused test files and update project file accordingly in `CreativeCoders.IO.UnitTests` --- .../Archives/TestFiles/TestSource01.html | 3 --- .../Archives/TestFiles/TestText01.txt | 2 -- .../CreativeCoders.IO.UnitTests.csproj | 6 ++++-- 3 files changed, 4 insertions(+), 7 deletions(-) delete mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html delete mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html deleted file mode 100644 index e7409a14..00000000 --- a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestSource01.html +++ /dev/null @@ -1,3 +0,0 @@ -

Caption

- -

Test

diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt b/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt deleted file mode 100644 index 8c91d300..00000000 --- a/tests/CreativeCoders.IO.UnitTests/Archives/TestFiles/TestText01.txt +++ /dev/null @@ -1,2 +0,0 @@ -Hello World! -qwertz diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index c6e34c52..6b2a52c1 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -30,9 +30,11 @@ - - + + + + From 42277429da93c3eb67d293ac27c899b3cf1c4f4b Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:20:47 +0100 Subject: [PATCH 14/42] Add comprehensive unit tests for `ZipArchiveWriter` and update `leaveOpen` support - Introduced unit tests covering file, stream, directory operations, and compression level handling in `ZipArchiveWriter`. - Updated `ZipArchiveWriter`, `IZipArchiveWriterFactory`, `ZipArchiveWriterFactory`, and related methods to support `leaveOpen` behavior. - Added `TarEntryInfo` class and minor updates to existing file handling logic to improve consistency. - Updated `.gitignore` to exclude `.ai-plans`. --- .gitignore | 2 + .../ArchiveWriterFactory.cs | 4 +- .../IArchiveWriterFactory.cs | 2 +- .../Tar/ITarArchiveWriterFactory.cs | 6 +- .../Tar/TarArchiveWriter.cs | 16 -- .../Tar/TarArchiveWriterFactory.cs | 6 +- .../Tar/TarEntryInfo.cs | 17 ++ .../Zip/IZipArchiveWriterFactory.cs | 2 +- .../Zip/ZipArchiveWriter.cs | 5 +- .../Zip/ZipArchiveWriterFactory.cs | 7 +- .../Archives/Zip/ZipArchiveWriterTests.cs | 178 ++++++++++++++++++ .../CreativeCoders.IO.UnitTests.csproj | 4 - 12 files changed, 215 insertions(+), 34 deletions(-) create mode 100644 source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs diff --git a/.gitignore b/.gitignore index 5e99b447..ff766f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,5 @@ ASALocalRun/ .nuke .DS_Store + +.ai-plans diff --git a/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs index 1a29b440..73ff97e5 100644 --- a/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs @@ -42,9 +42,9 @@ public ITarArchiveWriter CreateTarGzWriter(string archiveFileName, } public IZipArchiveWriter CreateZipWriter(Stream outputStream, - CompressionLevel compressionLevel = CompressionLevel.Optimal) + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false) { - return _zipArchiveWriterFactory.CreateWriter(outputStream, compressionLevel); + return _zipArchiveWriterFactory.CreateWriter(outputStream, compressionLevel, leaveOpen); } public IZipArchiveWriter CreateZipWriter(string archiveFileName, diff --git a/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs index 1e062674..2c423e6c 100644 --- a/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/IArchiveWriterFactory.cs @@ -19,7 +19,7 @@ ITarArchiveWriter CreateTarGzWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal); IZipArchiveWriter CreateZipWriter(Stream outputStream, - CompressionLevel compressionLevel = CompressionLevel.Optimal); + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false); IZipArchiveWriter CreateZipWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true); diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs index 246ab517..3a6c4f7d 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs @@ -4,7 +4,9 @@ namespace CreativeCoders.IO.Archives.Tar; public interface ITarArchiveWriterFactory { - ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, CompressionLevel compressionLevel = CompressionLevel.Optimal); + ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false); - ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, CompressionLevel compressionLevel = CompressionLevel.Optimal); + ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, + CompressionLevel compressionLevel = CompressionLevel.Optimal); } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs index 4bee232e..d14371ae 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -100,19 +100,3 @@ public Task AddFileAsync(Stream stream, string fileNameInArchive, TarEntryInfo e }); } } - -public class TarEntryInfo -{ - public string UserName { get; set; } = string.Empty; - - public int UserId { get; set; } - - public string GroupName { get; set; } = string.Empty; - - public int GroupId { get; set; } - - public UnixFileMode FileMode { get; set; } - - public IReadOnlyDictionary ExtendedAttributes { get; set; } = - new Dictionary(); -} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs index 8fe3404e..32234e68 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs @@ -9,11 +9,11 @@ public class TarArchiveWriterFactory(IFileSystem fileSystem) : ITarArchiveWriter private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); public ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompression = true, - CompressionLevel compressionLevel = CompressionLevel.Optimal) + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false) { return new TarArchiveWriter(useGZipCompression - ? new GZipStream(outputStream, compressionLevel) - : outputStream); + ? new GZipStream(outputStream, compressionLevel, leaveOpen) + : outputStream, leaveOpen); } public ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs new file mode 100644 index 00000000..787a45b7 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs @@ -0,0 +1,17 @@ +namespace CreativeCoders.IO.Archives.Tar; + +public class TarEntryInfo +{ + public string UserName { get; set; } = string.Empty; + + public int UserId { get; set; } + + public string GroupName { get; set; } = string.Empty; + + public int GroupId { get; set; } + + public UnixFileMode FileMode { get; set; } + + public IReadOnlyDictionary ExtendedAttributes { get; set; } = + new Dictionary(); +} diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs index 21a27ca7..ee718fbc 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/IZipArchiveWriterFactory.cs @@ -4,7 +4,7 @@ namespace CreativeCoders.IO.Archives.Zip; public interface IZipArchiveWriterFactory { - IZipArchiveWriter CreateWriter(Stream outputStream, CompressionLevel compressionLevel = CompressionLevel.Optimal); + IZipArchiveWriter CreateWriter(Stream outputStream, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false); IZipArchiveWriter CreateWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true); } diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs index b87f97e5..a2a86554 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs @@ -5,10 +5,11 @@ namespace CreativeCoders.IO.Archives.Zip; public sealed class ZipArchiveWriter( Stream outputStream, - CompressionLevel defaultCompressionLevel) : IZipArchiveWriter + CompressionLevel defaultCompressionLevel, + bool leaveOpen = false) : IZipArchiveWriter { private readonly ZipArchive - _archive = new ZipArchive(Ensure.NotNull(outputStream), ZipArchiveMode.Create); + _archive = new ZipArchive(Ensure.NotNull(outputStream), ZipArchiveMode.Create, leaveOpen); public Task AddFileAsync(string fileName, string fileNameInArchive, CompressionLevel compressionLevel) { diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs index 6d47d791..1fbabde1 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs @@ -5,15 +5,16 @@ namespace CreativeCoders.IO.Archives.Zip; public class ZipArchiveWriterFactory : IZipArchiveWriterFactory { public IZipArchiveWriter CreateWriter(Stream outputStream, - CompressionLevel compressionLevel = CompressionLevel.Optimal) + CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false) { - return new ZipArchiveWriter(outputStream, compressionLevel); + return new ZipArchiveWriter(outputStream, compressionLevel, leaveOpen); } public IZipArchiveWriter CreateWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true) { - return CreateWriter(overwriteExisting ? File.Create(archiveFileName) : File.OpenWrite(archiveFileName), + return CreateWriter( + overwriteExisting ? File.Create(archiveFileName) : File.OpenWrite(archiveFileName), compressionLevel); } } diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs new file mode 100644 index 00000000..152ac17a --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs @@ -0,0 +1,178 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.IO.Archives.Zip; + +namespace CreativeCoders.IO.UnitTests.Archives.Zip; + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +[Collection("FileSys")] +public class ZipArchiveWriterTests +{ + [Fact] + public async Task AddFileAsync_FromFileName_FileIsAddedToArchive() + { + // Arrange + var tempFile = Path.GetTempFileName(); + const string fileContent = "Test content"; + await File.WriteAllTextAsync(tempFile, fileContent); + + using var fileCleanup = new FileCleanUp(tempFile); + + const string fileNameInArchive = "test_dir/test_file.txt"; + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.Optimal, true)) + { + await writer.AddFileAsync(tempFile, fileNameInArchive); + } + + // Assert + outputStream.Position = 0; + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + var entry = archive.GetEntry("test_dir/test_file.txt"); + + entry + .Should().NotBeNull(); + + entry!.FullName + .Should().Be("test_dir/test_file.txt"); + + await using var entryStream = await entry.OpenAsync(); + using var readerStream = new StreamReader(entryStream, Encoding.UTF8); + var content = await readerStream.ReadToEndAsync(); + + content + .Should().Be(fileContent); + } + + [Fact] + public async Task AddFileAsync_FromStream_FileIsAddedToArchive() + { + // Arrange + const string fileContent = "Stream content"; + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + const string fileNameInArchive = "stream_file.txt"; + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.Optimal, true)) + { + await writer.AddFileAsync(inputStream, fileNameInArchive); + } + + // Assert + outputStream.Position = 0; + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + var entry = archive.GetEntry(fileNameInArchive); + + entry + .Should().NotBeNull(); + + entry!.FullName + .Should().Be(fileNameInArchive); + + await using var entryStream = await entry.OpenAsync(); + using var readerStream = new StreamReader(entryStream, Encoding.UTF8); + var content = await readerStream.ReadToEndAsync(); + + content + .Should().Be(fileContent); + } + + [Fact] + public async Task AddFileAsync_WithCompressionLevel_FileIsAddedWithCorrectCompression() + { + // Arrange + const string fileContent = "Compressed content"; + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes(fileContent)); + const string fileNameInArchive = "compressed_file.txt"; + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.NoCompression, true)) + { + await writer.AddFileAsync(inputStream, fileNameInArchive, CompressionLevel.SmallestSize); + } + + // Assert + outputStream.Position = 0; + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + var entry = archive.GetEntry(fileNameInArchive); + + entry + .Should().NotBeNull(); + + await using var entryStream = await entry!.OpenAsync(); + using var readerStream = new StreamReader(entryStream, Encoding.UTF8); + var content = await readerStream.ReadToEndAsync(); + + content + .Should().Be(fileContent); + } + + [Fact] + public async Task AddFromDirectoryAsync_DirectoryIsAddedToArchive() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), "ZipArchiveWriterTests_" + Guid.NewGuid()); + Directory.CreateDirectory(tempDir); + var subDir = Path.Combine(tempDir, "SubDir"); + Directory.CreateDirectory(subDir); + + await File.WriteAllTextAsync(Path.Combine(tempDir, "file1.txt"), "Content 1"); + await File.WriteAllTextAsync(Path.Combine(subDir, "file2.txt"), "Content 2"); + + using var dirCleanup = new DirectoryCleanUp(tempDir); + + using var outputStream = new MemoryStream(); + + // Act + await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.Optimal, true)) + { + await writer.AddFromDirectoryAsync(tempDir, tempDir); + } + + // Assert + outputStream.Position = 0; + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + + archive.Entries.Count + .Should().Be(2); + + var entry1 = archive.GetEntry("file1.txt"); + entry1 + .Should().NotBeNull(); + + var entry2 = archive.GetEntry("SubDir/file2.txt") ?? + archive.GetEntry("SubDir" + Path.DirectorySeparatorChar + "file2.txt"); + + entry2 + .Should().NotBeNull(); + } + + [Fact] + public async Task DisposeAsync_ArchiveIsCorrectlyFinalized() + { + // Arrange + using var outputStream = new MemoryStream(); + const string fileNameInArchive = "finalized_file.txt"; + + // Act + await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.Optimal, true)) + { + await writer.AddFileAsync(new MemoryStream(Encoding.UTF8.GetBytes("Finalized content")), + fileNameInArchive); + } + + // Assert + outputStream.Position = 0; + await using var archive = new ZipArchive(outputStream, ZipArchiveMode.Read); + + archive.GetEntry(fileNameInArchive) + .Should().NotBeNull(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index 6b2a52c1..97fcbfac 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -33,8 +33,4 @@ - - - - From cf65efae5dc3a23e1fc9e3e78ae38c838d38fed4 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:27:04 +0100 Subject: [PATCH 15/42] Exclude `Dispose` methods in `ZipArchiveWriter` and `TarArchiveWriter` from code coverage analysis --- source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs | 2 ++ source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs index d14371ae..5fe412d6 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Formats.Tar; using CreativeCoders.Core; @@ -72,6 +73,7 @@ private static string ConvertPathToTarEntryName(string fileNameInArchive) : fileNameInArchive; } + [ExcludeFromCodeCoverage] public void Dispose() { _tarWriter.Dispose(); diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs index a2a86554..76a1e1b1 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.IO.Compression; using CreativeCoders.Core; @@ -37,6 +38,7 @@ public async Task AddFromDirectoryAsync(string path, string basePathToRemove, } } + [ExcludeFromCodeCoverage] public void Dispose() { _archive.Dispose(); From 2613b7bb957e3ae1634840c7ed31ab4dace2b9f6 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:40:36 +0100 Subject: [PATCH 16/42] Add comprehensive unit tests for `TarArchiveReader` and improve file extraction handling - Introduced tests covering archive entry listing, stream opening, single file extraction, and full archive extraction in `TarArchiveReader`. - Updated `ExtractFileCoreAsync` to reset entry streams before reading and improve support for `IFileSystem`. - Modified `OpenEntryStreamAsync` to ensure memory stream positioning before returning. --- .../Tar/TarArchiveReader.cs | 24 ++- .../Archives/Tar/TarArchiveReaderTests.cs | 200 ++++++++++++++++++ .../CreativeCoders.IO.UnitTests.csproj | 1 + 3 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderTests.cs diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs index 062dc4f0..a6a19cd2 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -123,6 +123,11 @@ public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData var entryDataStream = tarEntry.DataStream ?? throw new FileNotFoundException($"Entry '{entry.FullName}' has no stream."); + if (entryDataStream.CanSeek) + { + entryDataStream.Position = 0; + } + if (!copyData) { return entryDataStream; @@ -130,6 +135,7 @@ public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData var memoryStream = new MemoryStream(); await entryDataStream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; return memoryStream; } @@ -147,15 +153,27 @@ public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, await ExtractFileCoreAsync(tarEntry, outputFilePath, overwriteExisting).ConfigureAwait(false); } - private Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + private async Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) { - var outputDirectory = Path.GetDirectoryName(outputFilePath); + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); if (outputDirectory != null) { _fileSystem.Directory.CreateDirectory(outputDirectory); } - return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + var entryDataStream = tarEntry.DataStream ?? + throw new FileNotFoundException($"Entry '{tarEntry.Name}' has no stream."); + + var fileMode = overwriteExisting ? FileMode.Create : FileMode.CreateNew; + + await using var fileStream = _fileSystem.FileStream.New(outputFilePath, fileMode, FileAccess.Write); + + if (entryDataStream.CanSeek) + { + entryDataStream.Position = 0; + } + + await entryDataStream.CopyToAsync(fileStream).ConfigureAwait(false); } public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderTests.cs new file mode 100644 index 00000000..659a080f --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderTests.cs @@ -0,0 +1,200 @@ +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; +using System.IO.Abstractions; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.IO.Archives; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.UnitTests; +using FakeItEasy; + +namespace CreativeCoders.IO.UnitTests.Archives.Tar; + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +[Collection("FileSys")] +public class TarArchiveReaderTests +{ + private readonly MockFileSystemEx _fileSystem = new MockFileSystemEx(); + + private static async Task CreateTestTarArchiveAsync() + { + var stream = new MemoryStream(); + await using (var writer = new TarWriter(stream, TarEntryFormat.Pax, true)) + { + var entry1 = new PaxTarEntry(TarEntryType.RegularFile, "file1.txt") + { + DataStream = new MemoryStream("Content 1"u8.ToArray()) + }; + await writer.WriteEntryAsync(entry1); + + var entry2 = new PaxTarEntry(TarEntryType.RegularFile, "dir/file2.txt") + { + DataStream = new MemoryStream("Content 2"u8.ToArray()) + }; + await writer.WriteEntryAsync(entry2); + } + + stream.Position = 0; + return stream; + } + + [Fact] + public async Task GetEntriesAsync_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + + // Act + var entries = await reader.GetEntriesAsync().ToListAsync(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + public async Task GetEntries_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + + // Act + var entries = reader.GetEntries().ToList(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + public async Task OpenEntryStreamAsync_ExistingEntry_ReturnsCorrectStream() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + var entry = new ArchiveEntry("file1.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + content + .Should().Be("Content 1"); + } + + [Fact] + public async Task OpenEntryStreamAsync_WithCopyData_ReturnsCopyOfStream() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + var entry = new ArchiveEntry("dir/file2.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry, true); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + content + .Should().Be("Content 2"); + + entryStream + .Should().BeOfType(); + } + + [Fact] + public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + var entry = new ArchiveEntry("file1.txt"); + const string outputFilePath = "/output/file1.txt"; + + // Act + await reader.ExtractFileAsync(entry, outputFilePath); + + // Assert + _fileSystem.File.Exists(outputFilePath) + .Should().BeTrue(); + + (await _fileSystem.File.ReadAllTextAsync(outputFilePath)) + .Should().Be("Content 1"); + } + + [Fact] + public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativePath() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + var entry = new ArchiveEntry("dir/file2.txt"); + const string outputBaseDirectory = "/output_base"; + + // Act + var resultPath = await reader.ExtractFileWithPathAsync(entry, outputBaseDirectory); + + // Assert + _fileSystem.File.Exists(resultPath) + .Should().BeTrue(); + + resultPath + .Should().Be(_fileSystem.Path.Combine(outputBaseDirectory, "dir/file2.txt")); + + (await _fileSystem.File.ReadAllTextAsync(resultPath)) + .Should().Be("Content 2"); + } + + [Fact] + public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + const string outputBaseDirectory = "/extract_all"; + + // Act + await reader.ExtractAllAsync(outputBaseDirectory); + + // Assert + _fileSystem.File.Exists(_fileSystem.Path.Combine(outputBaseDirectory, "file1.txt")) + .Should().BeTrue(); + _fileSystem.File.Exists(_fileSystem.Path.Combine(outputBaseDirectory, "dir/file2.txt")) + .Should().BeTrue(); + + (await _fileSystem.File.ReadAllTextAsync(_fileSystem.Path.Combine(outputBaseDirectory, "file1.txt"))) + .Should().Be("Content 1"); + + (await _fileSystem.File.ReadAllTextAsync(_fileSystem.Path.Combine(outputBaseDirectory, + "dir/file2.txt"))) + .Should().Be("Content 2"); + } + + [Fact] + [SuppressMessage("ReSharper", "AccessToDisposedClosure")] + public async Task OpenEntryStreamAsync_NonExistingEntry_ThrowsFileNotFoundException() + { + // Arrange + using var archiveStream = await CreateTestTarArchiveAsync(); + await using var reader = new TarArchiveReader(archiveStream, _fileSystem); + var entry = new ArchiveEntry("non_existing.txt"); + + // Act + var act = () => reader.OpenEntryStreamAsync(entry); + + // Assert + await act + .Should().ThrowAsync(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj index 97fcbfac..bd4edd14 100644 --- a/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj +++ b/tests/CreativeCoders.IO.UnitTests/CreativeCoders.IO.UnitTests.csproj @@ -26,6 +26,7 @@ + From 86925c215fe4bb9faee52abb76f997eacc7025f8 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:19:30 +0100 Subject: [PATCH 17/42] Integrate `IFileSystem` into `ZipArchiveReader` and `ZipArchiveReaderFactory` - Refactored both classes to support dependency injection with `IFileSystem`. - Updated file handling methods to use `IFileSystem` abstractions, improving testability and flexibility. - Adjusted relevant `Create` and `CreateAsync` methods to include optional `IFileSystem` parameters. --- .../Zip/ZipArchiveReader.cs | 23 +++++++++++-------- .../Zip/ZipArchiveReaderFactory.cs | 15 ++++++++---- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index 2f494425..e0535f12 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -1,25 +1,28 @@ -using System.IO.Compression; +using System.IO.Abstractions; +using System.IO.Compression; using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Zip; -public sealed class ZipArchiveReader(ZipArchive zipArchive) : IZipArchiveReader +public sealed class ZipArchiveReader(ZipArchive zipArchive, IFileSystem fileSystem) : IZipArchiveReader { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + private readonly ZipArchive _zipArchive = Ensure.NotNull(zipArchive); - public static ZipArchiveReader Create(Stream inputStream) + public static ZipArchiveReader Create(Stream inputStream, IFileSystem? fileSystem = null) { var zipArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, false); - return new ZipArchiveReader(zipArchive); + return new ZipArchiveReader(zipArchive, fileSystem ?? new FileSystem()); } - public static async Task CreateAsync(Stream inputStream) + public static async Task CreateAsync(Stream inputStream, IFileSystem? fileSystem = null) { var zipArchive = await ZipArchive.CreateAsync(inputStream, ZipArchiveMode.Read, false, null) .ConfigureAwait(false); - return new ZipArchiveReader(zipArchive); + return new ZipArchiveReader(zipArchive, fileSystem ?? new FileSystem()); } public IAsyncEnumerable GetEntriesAsync() @@ -52,13 +55,13 @@ public Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool ove : ExtractFileCoreAsync(zipEntry, outputFilePath, overwriteExisting); } - private static Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, + private Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, bool overwriteExisting) { - var outputDirectory = Path.GetDirectoryName(outputFilePath); + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); if (outputDirectory != null) { - Directory.CreateDirectory(outputDirectory); + _fileSystem.Directory.CreateDirectory(outputDirectory); } return zipEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); @@ -67,7 +70,7 @@ private static Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string output public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { - var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs index 9e0746fb..05855276 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs @@ -1,24 +1,29 @@ +using System.IO.Abstractions; +using CreativeCoders.Core; + namespace CreativeCoders.IO.Archives.Zip; -public class ZipArchiveReaderFactory : IZipArchiveReaderFactory +public class ZipArchiveReaderFactory(IFileSystem fileSystem) : IZipArchiveReaderFactory { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + public IZipArchiveReader CreateReader(Stream inputStream) { - return ZipArchiveReader.Create(inputStream); + return ZipArchiveReader.Create(inputStream, _fileSystem); } public IZipArchiveReader CreateReader(string archiveFileName) { - return ZipArchiveReader.Create(File.OpenRead(archiveFileName)); + return CreateReader(_fileSystem.File.OpenRead(archiveFileName)); } public async Task CreateReaderAsync(Stream inputStream) { - return await ZipArchiveReader.CreateAsync(inputStream).ConfigureAwait(false); + return await ZipArchiveReader.CreateAsync(inputStream, _fileSystem).ConfigureAwait(false); } public Task CreateReaderAsync(string archiveFileName) { - return CreateReaderAsync(File.OpenRead(archiveFileName)); + return CreateReaderAsync(_fileSystem.File.OpenRead(archiveFileName)); } } From e861cd33279b36b73022f1c9ce1d5f1cc77a8a53 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:35:25 +0100 Subject: [PATCH 18/42] Add unit tests for `ZipArchiveReader` and enhance cleanup utilities - Introduced comprehensive tests for `ZipArchiveReader`, covering entry listing, stream handling, and file extraction scenarios. - Updated `FileCleanUp` and `DirectoryCleanUp` to include existence checks before deletion to prevent unnecessary exceptions. --- .../IO/DirectoryCleanUp.cs | 5 +- .../CreativeCoders.Core/IO/FileCleanUp.cs | 5 +- .../Archives/Zip/ZipArchiveReaderTests.cs | 193 ++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs diff --git a/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs index d60c79a2..d807238c 100644 --- a/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs +++ b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs @@ -9,7 +9,10 @@ public void Dispose() { try { - FileSys.Directory.Delete(DirectoryName, recursive); + if (FileSys.Directory.Exists(DirectoryName)) + { + FileSys.Directory.Delete(DirectoryName, recursive); + } } catch (Exception) { diff --git a/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs b/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs index 2a2538df..5858de7e 100644 --- a/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs +++ b/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs @@ -8,7 +8,10 @@ public void Dispose() { try { - FileSys.File.Delete(FileName); + if (FileSys.File.Exists(FileName)) + { + FileSys.File.Delete(FileName); + } } catch (Exception) { diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs new file mode 100644 index 00000000..92d5c37a --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs @@ -0,0 +1,193 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Text; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.IO.Archives; +using CreativeCoders.IO.Archives.Zip; +using CreativeCoders.UnitTests; + +namespace CreativeCoders.IO.UnitTests.Archives.Zip; + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class ZipArchiveReaderTests +{ + private static async Task CreateTestZipArchiveAsync() + { + var stream = new MemoryStream(); + await using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + var entry1 = archive.CreateEntry("file1.txt"); + await using (var entryStream = await entry1.OpenAsync()) + { + await entryStream.WriteAsync("Content 1"u8.ToArray()); + } + + var entry2 = archive.CreateEntry("dir/file2.txt"); + await using (var entryStream = await entry2.OpenAsync()) + { + await entryStream.WriteAsync("Content 2"u8.ToArray()); + } + } + + stream.Position = 0; + return stream; + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task GetEntriesAsync_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + + // Act + var entries = await reader.GetEntriesAsync().ToArrayAsync(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + public async Task GetEntries_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = await ZipArchiveReader.CreateAsync(archiveStream); + + // Act + var entries = reader.GetEntries().ToArray(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task OpenEntryStreamAsync_ExistingEntry_ReturnsCorrectStream() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + var entry = new ArchiveEntry("file1.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + content + .Should().Be("Content 1"); + } + + [Fact] + public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = await ZipArchiveReader.CreateAsync(archiveStream); + var entry = new ArchiveEntry("file1.txt"); + var tempFile = Path.GetTempFileName(); + + using var fileCleanup = new FileCleanUp(tempFile); + + // Act + await reader.ExtractFileAsync(entry, tempFile); + + // Assert + var content = await File.ReadAllTextAsync(tempFile); + + content + .Should().Be("Content 1"); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativePath() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + var entry = new ArchiveEntry("dir/file2.txt"); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + using var dirCleanup = new DirectoryCleanUp(tempDir); + + // Act + var extractedFile = await reader.ExtractFileWithPathAsync(entry, tempDir); + + // Assert + extractedFile + .Should().Be(Path.Combine(tempDir, "dir", "file2.txt")); + + File.Exists(extractedFile) + .Should().BeTrue(); + + var content = await File.ReadAllTextAsync(extractedFile); + + content + .Should().Be("Content 2"); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + using var dirCleanup = new DirectoryCleanUp(tempDir); + + // Act + await reader.ExtractAllAsync(tempDir); + + // Assert + var file1 = Path.Combine(tempDir, "file1.txt"); + var file2 = Path.Combine(tempDir, "dir", "file2.txt"); + + File.Exists(file1) + .Should().BeTrue(); + + File.Exists(file2) + .Should().BeTrue(); + + (await File.ReadAllTextAsync(file1)) + .Should().Be("Content 1"); + + (await File.ReadAllTextAsync(file2)) + .Should().Be("Content 2"); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task OpenEntryStreamAsync_NonExistingEntry_ThrowsFileNotFoundException() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + var entry = new ArchiveEntry("non_existing.txt"); + + // Act & Assert + await reader.Awaiting(x => x.OpenEntryStreamAsync(entry)) + .Should().ThrowAsync(); + } +} From 920e22a82b595d5e0fd4b0e14c0acd96e30df66e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:37:30 +0100 Subject: [PATCH 19/42] Exclude `Dispose` methods in `ZipArchiveReader` and `TarArchiveReader` from code coverage --- source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs | 4 +++- source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs index a6a19cd2..2e8f379c 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -1,4 +1,5 @@ -using System.Formats.Tar; +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; using System.IO.Abstractions; using CreativeCoders.Core; @@ -202,6 +203,7 @@ public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExis } } + [ExcludeFromCodeCoverage] public void Dispose() { _tarReader.Dispose(); diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index e0535f12..6431d42a 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -1,4 +1,5 @@ -using System.IO.Abstractions; +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; using System.IO.Compression; using CreativeCoders.Core; @@ -82,6 +83,7 @@ public Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = return _zipArchive.ExtractToDirectoryAsync(outputBaseDirectory, overwriteExisting); } + [ExcludeFromCodeCoverage] public void Dispose() { _zipArchive.Dispose(); From 8f756939f72d30e0ec6d54f039b84636feb5a223 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:42:21 +0100 Subject: [PATCH 20/42] Fix path construction in `ZipArchiveReaderTests` to use `Path.Combine` for cross-platform compatibility --- .../Archives/Zip/ZipArchiveReaderTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs index 92d5c37a..6e64165e 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs @@ -121,7 +121,7 @@ public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativ // Arrange using var archiveStream = await CreateTestZipArchiveAsync(); await using var reader = ZipArchiveReader.Create(archiveStream); - var entry = new ArchiveEntry("dir/file2.txt"); + var entry = new ArchiveEntry(Path.Combine("dir", "file2.txt")); var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); From b7ee922cf0dca91d29edca65e670c8de23fe0cac Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:48:39 +0100 Subject: [PATCH 21/42] Improve path handling in `ZipArchiveReaderTests` by using `Path.Combine` for cross-platform consistency --- .../Archives/Zip/ZipArchiveReaderTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs index 6e64165e..0a98bd40 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs @@ -23,7 +23,7 @@ private static async Task CreateTestZipArchiveAsync() await entryStream.WriteAsync("Content 1"u8.ToArray()); } - var entry2 = archive.CreateEntry("dir/file2.txt"); + var entry2 = archive.CreateEntry(Path.Combine("dir", "file2.txt")); await using (var entryStream = await entry2.OpenAsync()) { await entryStream.WriteAsync("Content 2"u8.ToArray()); @@ -51,7 +51,7 @@ public async Task GetEntriesAsync_ArchiveWithEntries_ReturnsAllEntries() .Should().HaveCount(2); entries.Select(x => x.FullName) - .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + .Should().BeEquivalentTo("file1.txt", Path.Combine("dir", "file2.txt")); } [Fact] @@ -69,7 +69,7 @@ public async Task GetEntries_ArchiveWithEntries_ReturnsAllEntries() .Should().HaveCount(2); entries.Select(x => x.FullName) - .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + .Should().BeEquivalentTo("file1.txt", Path.Combine("dir", "file2.txt")); } [Fact] From ffa55fa0b77bbf522beb28c475c3cb64b1de8ae7 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 16:58:31 +0100 Subject: [PATCH 22/42] Add GitHub release creation logic and utilities - Introduced `CreateGitHubReleaseTask` for automated GitHub release creation with draft support. - Added `GitHubReleaseAsset` and `GitHubReleaseFileAsset` to manage release assets and file streams. - Integrated `MimeMapping` and `Octokit` for MIME type handling and GitHub API interactions. - Updated project file to include necessary package references for `MimeMapping` and `Octokit`. --- .../CreativeCoders.CakeBuild.csproj | 2 + .../Templates/CreateGitHubReleaseTask.cs | 97 +++++++++++++++++++ .../Templates/Settings/GitHubReleaseAsset.cs | 23 +++++ .../ICreateGitHubReleaseTaskSettings.cs | 14 +++ 4 files changed, 136 insertions(+) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/GitHubReleaseAsset.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj index 8c9c56f8..32004047 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj @@ -8,6 +8,8 @@ + + diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs new file mode 100644 index 00000000..c814de9e --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -0,0 +1,97 @@ +using Cake.Common.Build; +using Cake.Common.Diagnostics; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.Core; +using Octokit; + +namespace CreativeCoders.CakeBuild.Tasks.Templates; + +public class + CreateGitHubReleaseTask(IGitHubClient gitHubClient) + : FrostingTaskBase + where TBuildContext : CakeBuildContext +{ + private readonly IGitHubClient _gitHubClient = Ensure.NotNull(gitHubClient); + + protected override async Task RunAsyncCore(TBuildContext context, + ICreateGitHubReleaseTaskSettings taskSettings) + { + await CreateReleaseAsync(context, taskSettings).ConfigureAwait(false); + } + + private async Task CreateReleaseAsync(TBuildContext context, + ICreateGitHubReleaseTaskSettings taskSettings) + { + var release = await CreateReleaseDraftAsync(context, + taskSettings.ReleaseVersion, + taskSettings.ReleaseName, + taskSettings.ReleaseBody, + taskSettings.IsPreRelease) + .ConfigureAwait(false); + + await UploadReleaseAssets(release, taskSettings.ReleaseAssets).ConfigureAwait(false); + + await _gitHubClient.Repository.Release + .Edit(context.GitHubActions().Environment.Workflow.RepositoryOwner, GetRepositoryName(context), + release.Id, + new ReleaseUpdate { Draft = false }).ConfigureAwait(false); + } + + private async Task UploadReleaseAssets(Release release, + IEnumerable githubReleaseAssets) + { + foreach (var releaseAsset in githubReleaseAssets) + { + var dataStream = releaseAsset.GetAssetStream(); + + await using var dataStreamDisposable = dataStream.ConfigureAwait(false); + + var releaseAssetUpload = new ReleaseAssetUpload + { + ContentType = releaseAsset.ContentType, + FileName = releaseAsset.AssetFileName, + RawData = dataStream + }; + + _ = await _gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) + .ConfigureAwait(false); + } + } + + private async Task CreateReleaseDraftAsync(TBuildContext context, string releaseVersion, + string name, string body, + bool isPreRelease) + { + var repositoryOwner = context.GitHubActions().Environment.Workflow.RepositoryOwner; + var repositoryName = GetRepositoryName(context); + + context.Debug("Create github release"); + context.Debug("Repo Owner: {RepositoryOwner}", repositoryOwner); + context.Debug("Repo name: {RepositoryName}", repositoryName); + context.Debug("Version: {ReleaseVersion}", releaseVersion); + context.Debug("Name: {Name}", name); + context.Debug("Body: {Body}", body); + + return await _gitHubClient.Repository.Release + .Create(repositoryOwner, repositoryName, + new NewRelease(releaseVersion) + { + Name = name, + Body = body, + Draft = true, + Prerelease = isPreRelease + }) + .ConfigureAwait(false); + } + + private static string GetRepositoryName(TBuildContext context) + { + var repositoryName = context.GitHubActions().Environment.Workflow.Repository; + var index = repositoryName.LastIndexOf('/'); + + return index > -1 + ? repositoryName[(index + 1)..] + : throw new InvalidOperationException( + $"No repository name found in '{repositoryName}'"); + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/GitHubReleaseAsset.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/GitHubReleaseAsset.cs new file mode 100644 index 00000000..cd64a2fa --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/GitHubReleaseAsset.cs @@ -0,0 +1,23 @@ +using CreativeCoders.Core; +using CreativeCoders.Core.IO; + +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public abstract class GitHubReleaseAsset(string assetFileName, string? contentType = null) +{ + public string AssetFileName { get; } = Ensure.IsNotNullOrWhitespace(assetFileName); + + public string ContentType { get; } = contentType ?? MimeMapping.MimeUtility.GetMimeMapping(assetFileName); + + public abstract Stream GetAssetStream(); +} + +public class GitHubReleaseFileAsset(string fileName, string? assetFileName) + : GitHubReleaseAsset(assetFileName ?? FileSys.Path.GetFileName(fileName), + MimeMapping.MimeUtility.GetMimeMapping(fileName)) +{ + public override Stream GetAssetStream() + { + return FileSys.File.OpenRead(fileName); + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs new file mode 100644 index 00000000..54a6976f --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs @@ -0,0 +1,14 @@ +namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; + +public interface ICreateGitHubReleaseTaskSettings +{ + string ReleaseName { get; } + + string ReleaseBody => string.Empty; + + bool IsPreRelease => false; + + string ReleaseVersion { get; } + + IEnumerable ReleaseAssets => []; +} From f090e92683b0f50337e1838e4373e481813a5510 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Fri, 16 Jan 2026 17:09:47 +0100 Subject: [PATCH 23/42] Refactor GitHub release task to use `IGitHubClientFactory` for improved token handling and flexibility --- .../GitHub/IGitHubClientFactory.cs | 19 ++++++++++++++ .../Tasks/Defaults/CreateGitHubReleaseTask.cs | 9 +++++++ .../Templates/CreateGitHubReleaseTask.cs | 26 +++++++++++-------- .../ICreateGitHubReleaseTaskSettings.cs | 4 ++- 4 files changed, 46 insertions(+), 12 deletions(-) create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/GitHub/IGitHubClientFactory.cs create mode 100644 source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateGitHubReleaseTask.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/GitHub/IGitHubClientFactory.cs b/source/CakeBuild/CreativeCoders.CakeBuild/GitHub/IGitHubClientFactory.cs new file mode 100644 index 00000000..4b2a5b2a --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/GitHub/IGitHubClientFactory.cs @@ -0,0 +1,19 @@ +using Octokit; + +namespace CreativeCoders.CakeBuild.GitHub; + +public interface IGitHubClientFactory +{ + IGitHubClient Create(string gitHubToken); +} + +public class GitHubClientFactory() : IGitHubClientFactory +{ + public IGitHubClient Create(string gitHubToken) + { + return new GitHubClient(new ProductHeaderValue("CreativeCoders.CakeBuild")) + { + Credentials = new Credentials(gitHubToken) + }; + } +} diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateGitHubReleaseTask.cs new file mode 100644 index 00000000..bcb0db46 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Defaults/CreateGitHubReleaseTask.cs @@ -0,0 +1,9 @@ +using Cake.Frosting; +using CreativeCoders.CakeBuild.GitHub; +using CreativeCoders.CakeBuild.Tasks.Templates; + +namespace CreativeCoders.CakeBuild.Tasks.Defaults; + +[TaskName("CreateGitHubRelease")] +public class CreateGitHubReleaseTask(IGitHubClientFactory gitHubClientFactory) + : CreateGitHubReleaseTask(gitHubClientFactory); diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs index c814de9e..5f1af93f 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -1,5 +1,6 @@ using Cake.Common.Build; using Cake.Common.Diagnostics; +using CreativeCoders.CakeBuild.GitHub; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; using CreativeCoders.Core; using Octokit; @@ -7,37 +8,39 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates; public class - CreateGitHubReleaseTask(IGitHubClient gitHubClient) + CreateGitHubReleaseTask(IGitHubClientFactory gitHubClientFactory) : FrostingTaskBase where TBuildContext : CakeBuildContext { - private readonly IGitHubClient _gitHubClient = Ensure.NotNull(gitHubClient); + private readonly IGitHubClientFactory _gitHubClientFactory = Ensure.NotNull(gitHubClientFactory); protected override async Task RunAsyncCore(TBuildContext context, ICreateGitHubReleaseTaskSettings taskSettings) { - await CreateReleaseAsync(context, taskSettings).ConfigureAwait(false); + var gitHubClient = _gitHubClientFactory.Create(taskSettings.GitHubToken); + + await CreateReleaseAsync(gitHubClient, context, taskSettings).ConfigureAwait(false); } - private async Task CreateReleaseAsync(TBuildContext context, + private async Task CreateReleaseAsync(IGitHubClient gitHubClient, TBuildContext context, ICreateGitHubReleaseTaskSettings taskSettings) { - var release = await CreateReleaseDraftAsync(context, + var release = await CreateReleaseDraftAsync(gitHubClient, context, taskSettings.ReleaseVersion, taskSettings.ReleaseName, taskSettings.ReleaseBody, taskSettings.IsPreRelease) .ConfigureAwait(false); - await UploadReleaseAssets(release, taskSettings.ReleaseAssets).ConfigureAwait(false); + await UploadReleaseAssets(gitHubClient, release, taskSettings.ReleaseAssets).ConfigureAwait(false); - await _gitHubClient.Repository.Release + await gitHubClient.Repository.Release .Edit(context.GitHubActions().Environment.Workflow.RepositoryOwner, GetRepositoryName(context), release.Id, new ReleaseUpdate { Draft = false }).ConfigureAwait(false); } - private async Task UploadReleaseAssets(Release release, + private async Task UploadReleaseAssets(IGitHubClient gitHubClient, Release release, IEnumerable githubReleaseAssets) { foreach (var releaseAsset in githubReleaseAssets) @@ -53,12 +56,13 @@ private async Task UploadReleaseAssets(Release release, RawData = dataStream }; - _ = await _gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) + _ = await gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) .ConfigureAwait(false); } } - private async Task CreateReleaseDraftAsync(TBuildContext context, string releaseVersion, + private async Task CreateReleaseDraftAsync(IGitHubClient gitHubClient, TBuildContext context, + string releaseVersion, string name, string body, bool isPreRelease) { @@ -72,7 +76,7 @@ private async Task CreateReleaseDraftAsync(TBuildContext context, strin context.Debug("Name: {Name}", name); context.Debug("Body: {Body}", body); - return await _gitHubClient.Repository.Release + return await gitHubClient.Repository.Release .Create(repositoryOwner, repositoryName, new NewRelease(releaseVersion) { diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs index 54a6976f..f0452b74 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs @@ -1,7 +1,9 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; -public interface ICreateGitHubReleaseTaskSettings +public interface ICreateGitHubReleaseTaskSettings : IBuildContextAccessor { + public string GitHubToken => Context.Environment.GetEnvironmentVariable("GITHUB_TOKEN"); + string ReleaseName { get; } string ReleaseBody => string.Empty; From 73139d60ae78de9db84694cdb749222420c30a02 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:32:36 +0100 Subject: [PATCH 24/42] Integrate `IFileSystem` abstraction into archive writer factories and extend Cake tasks - Added `IFileSystem` dependency to `ZipArchiveWriterFactory` and `TarArchiveWriterFactory` to improve testability and directory handling. - Enhanced Cake build system with `ICreateDistPackagesTaskSettings` and integrated `PublishTask`, `CreateDistPackagesTask`, and `CreateGitHubReleaseTask`. - Updated `CakeHostExtensions` to include default services and new task configurations. --- samples/CakeBuildSample/SampleBuildContext.cs | 29 ++++++++++++++++- .../CakeHostBuilder.cs | 2 ++ .../CakeHostExtensions.cs | 32 +++++++++---------- .../ICreateDistPackagesTaskSettings.cs | 6 ++-- .../Tar/TarArchiveWriterFactory.cs | 7 ++++ .../Zip/ZipArchiveWriterFactory.cs | 13 +++++++- 6 files changed, 68 insertions(+), 21 deletions(-) diff --git a/samples/CakeBuildSample/SampleBuildContext.cs b/samples/CakeBuildSample/SampleBuildContext.cs index 398b52bb..3c372c2f 100644 --- a/samples/CakeBuildSample/SampleBuildContext.cs +++ b/samples/CakeBuildSample/SampleBuildContext.cs @@ -11,7 +11,8 @@ namespace CakeBuildSample; [UsedImplicitly] -public class SampleBuildContext(ICakeContext context) : CakeBuildContext(context), IDefaultTaskSettings +public class SampleBuildContext(ICakeContext context) + : CakeBuildContext(context), IDefaultTaskSettings, ICreateDistPackagesTaskSettings { public IList DirectoriesToClean => this.CastAs() .GetDefaultDirectoriesToClean().AddRange(RootDir.Combine(".tests")); @@ -30,4 +31,30 @@ public class SampleBuildContext(ICakeContext context) : CakeBuildContext(context public bool SkipPush => this.BuildSystem().IsPullRequest || this.BuildSystem().IsLocalBuild || this.GitHubActions().Environment.Runner.OS != "Linux"; + + public DirectoryPath PublishOutputDir => ArtifactsDir.Combine("publish"); + + private const string CliHostSampleAppProjectName = "CliHostSampleApp"; + + public IEnumerable PublishingItems => + [ + new PublishingItem( + RootDir.Combine("samples").Combine("CliHostSampleApp") + .CombineWithFilePath("CliHostSampleApp.csproj"), + PublishOutputDir.Combine(CliHostSampleAppProjectName)) + ]; + + public IEnumerable DistPackages => + [ + new DistPackage("clihostsample", PublishOutputDir.Combine(CliHostSampleAppProjectName)) + { + Format = DistPackageFormat.TarGz + }, + new DistPackage("clihostsample", PublishOutputDir.Combine(CliHostSampleAppProjectName)) + { + Format = DistPackageFormat.Zip + } + ]; + + public DirectoryPath DistOutputPath => ArtifactsDir.Combine("dist"); } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs index 0a9905ab..a6cd81c4 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs @@ -1,6 +1,8 @@ +using Cake.Core; using Cake.Frosting; using CreativeCoders.Core; using CreativeCoders.Core.IO; +using Microsoft.Extensions.DependencyInjection; namespace CreativeCoders.CakeBuild; diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs index e078cc93..e9070c37 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs @@ -1,6 +1,7 @@ using System.Reflection; using Cake.Frosting; using CreativeCoders.CakeBuild.BuildServer; +using CreativeCoders.CakeBuild.GitHub; using CreativeCoders.CakeBuild.Tasks.Defaults; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; using CreativeCoders.IO.Archives; @@ -59,13 +60,21 @@ public static CakeHost AddTasks(this CakeHost host, params Type[] taskTypes) public static CakeHost AddDefaultTasks(this CakeHost host) { return host.AddTasks( - typeof(CleanTask), - typeof(RestoreTask), - typeof(BuildTask), - typeof(TestTask), - typeof(CodeCoverageTask), - typeof(PackTask), - typeof(NuGetPushTask)); + typeof(CleanTask), + typeof(RestoreTask), + typeof(BuildTask), + typeof(TestTask), + typeof(CodeCoverageTask), + typeof(PackTask), + typeof(NuGetPushTask), + typeof(PublishTask), + typeof(CreateDistPackagesTask), + typeof(CreateGitHubReleaseTask)) + .ConfigureServices(x => + { + x.AddArchives(); + x.TryAddSingleton(); + }); } public static CakeHost AddBuildServerIntegration(this CakeHost host) @@ -75,15 +84,6 @@ public static CakeHost AddBuildServerIntegration(this CakeHost host) .UseTaskTeardown(); } - public static CakeHost SetupHost(this CakeHost host) - where TBuildSetup : class - where TBuildContext : CakeBuildContext - { - return host - .UseContext() - .UseBuildSetup(); - } - public static CakeHost UseBuildSetup(this CakeHost host) where TBuildSetup : class { diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs index f687d745..e7e29bf9 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs @@ -2,9 +2,9 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; -public interface ICreateDistPackagesTaskSettings +public interface ICreateDistPackagesTaskSettings : IBuildContextAccessor { - IEnumerable DistPackages { get; } + IEnumerable DistPackages => []; - DirectoryPath DistOutputPath { get; } + DirectoryPath DistOutputPath => Context.ArtifactsDir.Combine("dist"); } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs index 32234e68..6a3c95b7 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs @@ -19,6 +19,13 @@ public ITarArchiveWriter CreateWriter(Stream outputStream, bool useGZipCompressi public ITarArchiveWriter CreateWriter(string archiveFileName, bool? useGZipCompression = null, CompressionLevel compressionLevel = CompressionLevel.Optimal) { + var dirName = _fileSystem.Path.GetDirectoryName(archiveFileName); + + if (!string.IsNullOrWhiteSpace(dirName)) + { + _fileSystem.Directory.CreateDirectory(dirName); + } + return CreateWriter( _fileSystem.File.Create(archiveFileName), useGZipCompression ?? TarFileHelper.IsGZipFile(archiveFileName), diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs index 1fbabde1..f95aa2fa 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs @@ -1,9 +1,13 @@ +using System.IO.Abstractions; using System.IO.Compression; +using CreativeCoders.Core; namespace CreativeCoders.IO.Archives.Zip; -public class ZipArchiveWriterFactory : IZipArchiveWriterFactory +public class ZipArchiveWriterFactory(IFileSystem fileSystem) : IZipArchiveWriterFactory { + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + public IZipArchiveWriter CreateWriter(Stream outputStream, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool leaveOpen = false) { @@ -13,6 +17,13 @@ public IZipArchiveWriter CreateWriter(Stream outputStream, public IZipArchiveWriter CreateWriter(string archiveFileName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool overwriteExisting = true) { + var dirName = _fileSystem.Path.GetDirectoryName(archiveFileName); + + if (!string.IsNullOrWhiteSpace(dirName)) + { + _fileSystem.Directory.CreateDirectory(dirName); + } + return CreateWriter( overwriteExisting ? File.Create(archiveFileName) : File.OpenWrite(archiveFileName), compressionLevel); From 0ccaaddf98d8c9af316ce8794dc63d4c9b8dff2c Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:33:08 +0100 Subject: [PATCH 25/42] Use constant `CliHostSampleAppProjectName` for improved maintainability in path construction. --- samples/CakeBuildSample/SampleBuildContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples/CakeBuildSample/SampleBuildContext.cs b/samples/CakeBuildSample/SampleBuildContext.cs index 3c372c2f..db3f96ae 100644 --- a/samples/CakeBuildSample/SampleBuildContext.cs +++ b/samples/CakeBuildSample/SampleBuildContext.cs @@ -39,7 +39,7 @@ public class SampleBuildContext(ICakeContext context) public IEnumerable PublishingItems => [ new PublishingItem( - RootDir.Combine("samples").Combine("CliHostSampleApp") + RootDir.Combine("samples").Combine(CliHostSampleAppProjectName) .CombineWithFilePath("CliHostSampleApp.csproj"), PublishOutputDir.Combine(CliHostSampleAppProjectName)) ]; From 1cb7ac95427ec397e7c93b4b2ee14a42e2b4666e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:59:48 +0100 Subject: [PATCH 26/42] Use `_fileSystem` abstraction in `ZipArchiveWriterFactory` to improve testability and consistency --- .../CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs index f95aa2fa..8fea0b64 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs @@ -25,7 +25,9 @@ public IZipArchiveWriter CreateWriter(string archiveFileName, } return CreateWriter( - overwriteExisting ? File.Create(archiveFileName) : File.OpenWrite(archiveFileName), + overwriteExisting + ? _fileSystem.File.Create(archiveFileName) + : _fileSystem.File.OpenWrite(archiveFileName), compressionLevel); } } From 1c615a0bd1be3d3b9aa9d3589588a8974022d7a6 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:16:35 +0100 Subject: [PATCH 27/42] Add unit tests for `ArchiveWriterFactory` to validate writer creation logic - Introduced tests for creating tar, tar.gz, and zip writers with both streams and filenames. - Validated integration of `ITarArchiveWriterFactory` and `IZipArchiveWriterFactory` in writer creation methods. --- .../Archives/ArchiveWriterFactoryTests.cs | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/ArchiveWriterFactoryTests.cs diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/ArchiveWriterFactoryTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/ArchiveWriterFactoryTests.cs new file mode 100644 index 00000000..1db369d4 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/ArchiveWriterFactoryTests.cs @@ -0,0 +1,138 @@ +using System.IO.Compression; +using AwesomeAssertions; +using CreativeCoders.IO.Archives; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.IO.Archives.Zip; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives; + +public class ArchiveWriterFactoryTests +{ + [Fact] + public void CreateTarWriter_WithStream_ReturnsWriterFromTarFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + using var stream = new MemoryStream(); + var expectedWriter = A.Fake(); + + A.CallTo(() => tarFactory.CreateWriter(stream, true, CompressionLevel.Optimal, false)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateTarWriter(stream); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } + + [Fact] + public void CreateTarWriter_WithFileName_ReturnsWriterFromTarFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + const string fileName = "test.tar"; + var expectedWriter = A.Fake(); + + A.CallTo(() => tarFactory.CreateWriter(fileName, null, CompressionLevel.Optimal)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateTarWriter(fileName); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } + + [Fact] + public void CreateTarGzWriter_WithStream_ReturnsWriterFromTarFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + using var stream = new MemoryStream(); + var expectedWriter = A.Fake(); + + A.CallTo(() => tarFactory.CreateWriter(stream, true, CompressionLevel.Optimal, false)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateTarGzWriter(stream); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } + + [Fact] + public void CreateTarGzWriter_WithFileName_ReturnsWriterFromTarFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + const string fileName = "test.tar.gz"; + var expectedWriter = A.Fake(); + + A.CallTo(() => tarFactory.CreateWriter(fileName, true, CompressionLevel.Optimal)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateTarGzWriter(fileName); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } + + [Fact] + public void CreateZipWriter_WithStream_ReturnsWriterFromZipFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + using var stream = new MemoryStream(); + var expectedWriter = A.Fake(); + + A.CallTo(() => zipFactory.CreateWriter(stream, CompressionLevel.Optimal, false)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateZipWriter(stream); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } + + [Fact] + public void CreateZipWriter_WithFileName_ReturnsWriterFromZipFactory() + { + // Arrange + var zipFactory = A.Fake(); + var tarFactory = A.Fake(); + var factory = new ArchiveWriterFactory(zipFactory, tarFactory); + const string fileName = "test.zip"; + var expectedWriter = A.Fake(); + + A.CallTo(() => zipFactory.CreateWriter(fileName, CompressionLevel.Optimal, true)) + .Returns(expectedWriter); + + // Act + var result = factory.CreateZipWriter(fileName); + + // Assert + result + .Should().BeSameAs(expectedWriter); + } +} From 50ef0925366c5453e2a1360134af347809ac27ea Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:18:51 +0100 Subject: [PATCH 28/42] Add project for `CreativeCoders.CakeBuild.Tests` and integrate it into the solution --- Core.sln | 7 +++++++ .../CreativeCoders.CakeBuild.Tests.csproj | 9 +++++++++ 2 files changed, 16 insertions(+) create mode 100644 tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj diff --git a/Core.sln b/Core.sln index a82e3677..4370305c 100644 --- a/Core.sln +++ b/Core.sln @@ -260,6 +260,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.IO.Archives" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.IO.Ports", "source\IO\CreativeCoders.IO.Ports\CreativeCoders.IO.Ports.csproj", "{C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.CakeBuild.Tests", "tests\CreativeCoders.CakeBuild.Tests\CreativeCoders.CakeBuild.Tests.csproj", "{A081DDB3-A9DB-498F-824B-C605ACFDB43A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -582,6 +584,10 @@ Global {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Debug|Any CPU.Build.0 = Debug|Any CPU {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Release|Any CPU.ActiveCfg = Release|Any CPU {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4}.Release|Any CPU.Build.0 = Release|Any CPU + {A081DDB3-A9DB-498F-824B-C605ACFDB43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A081DDB3-A9DB-498F-824B-C605ACFDB43A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A081DDB3-A9DB-498F-824B-C605ACFDB43A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A081DDB3-A9DB-498F-824B-C605ACFDB43A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -691,6 +697,7 @@ Global {4A397E49-CB4F-4348-9798-A7F2B5F276DA} = {DB7A40EE-F469-4B87-A629-250BBB9ECC92} {EC120660-306E-4E48-8FBB-43D9F3962CEE} = {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} {C34426F1-E40C-498D-8FDE-D4ECEB3E49D4} = {F31389F0-613B-48B1-AA99-6BFC3D8D7D1A} + {A081DDB3-A9DB-498F-824B-C605ACFDB43A} = {5A3FFD14-2258-4007-AC77-BAFB3B3EB26F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} diff --git a/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj new file mode 100644 index 00000000..237d6616 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + From fb7f65a4f4fac33aa5f5cca13cfd6c39bff7999d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:19:48 +0100 Subject: [PATCH 29/42] Remove `` property from test project files for cleanup and consistency --- .../CreativeCoders.CakeBuild.Tests.csproj | 20 ++++++++++++++++++- .../CreativeCoders.Cli.Tests.csproj | 7 +++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj index 237d6616..a44c7de2 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj +++ b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj @@ -1,9 +1,27 @@  - net10.0 enable enable + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + diff --git a/tests/CreativeCoders.Cli.Tests/CreativeCoders.Cli.Tests.csproj b/tests/CreativeCoders.Cli.Tests/CreativeCoders.Cli.Tests.csproj index 41e54f16..81b25691 100644 --- a/tests/CreativeCoders.Cli.Tests/CreativeCoders.Cli.Tests.csproj +++ b/tests/CreativeCoders.Cli.Tests/CreativeCoders.Cli.Tests.csproj @@ -1,7 +1,6 @@  - net10.0 enable enable @@ -10,8 +9,8 @@ - - + + @@ -22,7 +21,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + From ce919e0968a639c5b1e7df9880fb4df263b3973f Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:26:55 +0100 Subject: [PATCH 30/42] Add unit tests for Tar and Zip archive reader and writer factories - Introduced `TarArchiveReaderFactoryTests` and `TarArchiveWriterFactoryTests` to validate creating readers and writers for `.tar` and `.tar.gz` formats using streams and filenames. - Added `ZipArchiveReaderFactoryTests` and `ZipArchiveWriterFactoryTests` to ensure proper behavior of `.zip` archive reader and writer creation methods. --- .../Tar/TarArchiveReaderFactoryTests.cs | 75 +++++++++++++++ .../Tar/TarArchiveWriterFactoryTests.cs | 80 ++++++++++++++++ .../Zip/ZipArchiveReaderFactoryTests.cs | 96 +++++++++++++++++++ .../Zip/ZipArchiveWriterFactoryTests.cs | 63 ++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderFactoryTests.cs create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterFactoryTests.cs create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderFactoryTests.cs create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterFactoryTests.cs diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderFactoryTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderFactoryTests.cs new file mode 100644 index 00000000..ef0ee446 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveReaderFactoryTests.cs @@ -0,0 +1,75 @@ +using System.IO.Abstractions.TestingHelpers; +using CreativeCoders.IO.Archives.Tar; +using AwesomeAssertions; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives.Tar; + +public class TarArchiveReaderFactoryTests +{ + [Fact] + public void CreateReader_WithStreamAndNoGZip_ReturnsTarArchiveReader() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveReaderFactory(fileSystem); + using var stream = new MemoryStream(); + + // Act + var reader = factory.CreateReader(stream, false); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + public void CreateReader_WithStreamAndGZip_ReturnsTarGzArchiveReader() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveReaderFactory(fileSystem); + using var stream = new MemoryStream(); + + // Act + var reader = factory.CreateReader(stream, true); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + public void CreateReader_WithTarFile_ReturnsTarArchiveReader() + { + // Arrange + const string fileName = "test.tar"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(fileName, new MockFileData("test content")); + var factory = new TarArchiveReaderFactory(fileSystem); + + // Act + var reader = factory.CreateReader(fileName); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + public void CreateReader_WithTarGzFile_ReturnsTarGzArchiveReader() + { + // Arrange + const string fileName = "test.tar.gz"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(fileName, new MockFileData("test content")); + var factory = new TarArchiveReaderFactory(fileSystem); + + // Act + var reader = factory.CreateReader(fileName); + + // Assert + reader + .Should().BeOfType(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterFactoryTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterFactoryTests.cs new file mode 100644 index 00000000..84cab1f9 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterFactoryTests.cs @@ -0,0 +1,80 @@ +using System.IO.Abstractions.TestingHelpers; +using System.IO.Compression; +using CreativeCoders.IO.Archives.Tar; +using AwesomeAssertions; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives.Tar; + +public class TarArchiveWriterFactoryTests +{ + [Fact] + public void CreateWriter_WithStreamAndNoGZip_ReturnsTarArchiveWriter() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveWriterFactory(fileSystem); + using var stream = new MemoryStream(); + + // Act + var writer = factory.CreateWriter(stream, false); + + // Assert + writer + .Should().BeOfType(); + } + + [Fact] + public void CreateWriter_WithStreamAndGZip_ReturnsTarArchiveWriter() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveWriterFactory(fileSystem); + using var stream = new MemoryStream(); + + // Act + var writer = factory.CreateWriter(stream, true); + + // Assert + writer + .Should().BeOfType(); + } + + [Fact] + public void CreateWriter_WithTarFile_ReturnsTarArchiveWriterAndCreatesFile() + { + // Arrange + const string fileName = "/test/test.tar"; + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveWriterFactory(fileSystem); + + // Act + var writer = factory.CreateWriter(fileName); + + // Assert + writer + .Should().BeOfType(); + + fileSystem.File.Exists(fileName) + .Should().BeTrue(); + } + + [Fact] + public void CreateWriter_WithTarGzFile_ReturnsTarArchiveWriterAndCreatesFile() + { + // Arrange + const string fileName = "/test/test.tar.gz"; + var fileSystem = new MockFileSystem(); + var factory = new TarArchiveWriterFactory(fileSystem); + + // Act + var writer = factory.CreateWriter(fileName); + + // Assert + writer + .Should().BeOfType(); + + fileSystem.File.Exists(fileName) + .Should().BeTrue(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderFactoryTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderFactoryTests.cs new file mode 100644 index 00000000..9679401c --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderFactoryTests.cs @@ -0,0 +1,96 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.IO.Abstractions.TestingHelpers; +using CreativeCoders.IO.Archives.Zip; +using AwesomeAssertions; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives.Zip; + +public class ZipArchiveReaderFactoryTests +{ + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + private static async Task CreateTestZipArchiveAsync() + { + using var stream = new MemoryStream(); + await using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true)) + { + var entry = archive.CreateEntry("test.txt"); + await using var entryStream = entry.Open(); + await entryStream.WriteAsync("test content"u8.ToArray()); + } + + return stream.ToArray(); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task CreateReader_WithStream_ReturnsZipArchiveReader() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new ZipArchiveReaderFactory(fileSystem); + using var stream = new MemoryStream(await CreateTestZipArchiveAsync()); + + // Act + var reader = factory.CreateReader(stream); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task CreateReader_WithFileName_ReturnsZipArchiveReader() + { + // Arrange + const string fileName = "test.zip"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(fileName, new MockFileData(await CreateTestZipArchiveAsync())); + var factory = new ZipArchiveReaderFactory(fileSystem); + + // Act + var reader = factory.CreateReader(fileName); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + public async Task CreateReaderAsync_WithStream_ReturnsZipArchiveReader() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new ZipArchiveReaderFactory(fileSystem); + using var stream = new MemoryStream(await CreateTestZipArchiveAsync()); + + // Act + var reader = await factory.CreateReaderAsync(stream); + + // Assert + reader + .Should().BeOfType(); + } + + [Fact] + public async Task CreateReaderAsync_WithFileName_ReturnsZipArchiveReader() + { + // Arrange + const string fileName = "test.zip"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(fileName, new MockFileData(await CreateTestZipArchiveAsync())); + var factory = new ZipArchiveReaderFactory(fileSystem); + + // Act + var reader = await factory.CreateReaderAsync(fileName); + + // Assert + reader + .Should().BeOfType(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterFactoryTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterFactoryTests.cs new file mode 100644 index 00000000..d23ec7eb --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterFactoryTests.cs @@ -0,0 +1,63 @@ +using System.IO.Abstractions.TestingHelpers; +using CreativeCoders.IO.Archives.Zip; +using AwesomeAssertions; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives.Zip; + +public class ZipArchiveWriterFactoryTests +{ + [Fact] + public void CreateWriter_WithStream_ReturnsZipArchiveWriter() + { + // Arrange + var fileSystem = new MockFileSystem(); + var factory = new ZipArchiveWriterFactory(fileSystem); + using var stream = new MemoryStream(); + + // Act + var writer = factory.CreateWriter(stream); + + // Assert + writer.Should().BeOfType(); + } + + [Fact] + public void CreateWriter_WithFileName_ReturnsZipArchiveWriterAndCreatesFile() + { + // Arrange + const string fileName = "/test/test.zip"; + var fileSystem = new MockFileSystem(); + var factory = new ZipArchiveWriterFactory(fileSystem); + + // Act + var writer = factory.CreateWriter(fileName); + + // Assert + writer + .Should().BeOfType(); + + fileSystem.File.Exists(fileName) + .Should().BeTrue(); + } + + [Fact] + public void CreateWriter_WithFileNameAndOverwriteFalse_ReturnsZipArchiveWriterAndOpensFile() + { + // Arrange + const string fileName = "/test/test.zip"; + var fileSystem = new MockFileSystem(); + fileSystem.AddFile(fileName, new MockFileData("existing content")); + var factory = new ZipArchiveWriterFactory(fileSystem); + + // Act + var writer = factory.CreateWriter(fileName, overwriteExisting: false); + + // Assert + writer + .Should().BeOfType(); + + fileSystem.File.Exists(fileName) + .Should().BeTrue(); + } +} From d36ed080dd5f9bde7d78fe453a9b32ebbe62c273 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:29:37 +0100 Subject: [PATCH 31/42] Make `ExtendedAttributes` property immutable by changing it to `init` accessor --- source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs index 787a45b7..fdab8767 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs @@ -12,6 +12,6 @@ public class TarEntryInfo public UnixFileMode FileMode { get; set; } - public IReadOnlyDictionary ExtendedAttributes { get; set; } = + public IReadOnlyDictionary ExtendedAttributes { get; init; } = new Dictionary(); } From 36a5a0bca57a3c88cc3b0d3c26841f3a781cf633 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 14:33:48 +0100 Subject: [PATCH 32/42] Make `ExtendedAttributes` property mutable by changing accessor from `init` to `set` --- source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs index fdab8767..787a45b7 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarEntryInfo.cs @@ -12,6 +12,6 @@ public class TarEntryInfo public UnixFileMode FileMode { get; set; } - public IReadOnlyDictionary ExtendedAttributes { get; init; } = + public IReadOnlyDictionary ExtendedAttributes { get; set; } = new Dictionary(); } From 2e7d084da364f0e37c372e9f367c5c0ab1159b85 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 15:09:35 +0100 Subject: [PATCH 33/42] Add unit tests for Cake build task templates - Introduced tests for various task templates, including `BuildTask`, `CleanTask`, `CodeCoverageTask`, `CreateDistPackagesTask`, `CreateGitHubReleaseTask`, `NuGetPushTask`, `PackTask`, `PublishTask`, `RestoreTask`, and `TestTask`. - Added `CakeTestHelper` for mock context and filesystem setup. - Updated test project with new dependencies and project references. --- .../CakeTestHelper.cs | 63 +++++++++++++++++++ .../CreativeCoders.CakeBuild.Tests.csproj | 5 ++ .../Tasks/Templates/BuildTaskTests.cs | 38 +++++++++++ .../Tasks/Templates/CleanTaskTests.cs | 28 +++++++++ .../Tasks/Templates/CodeCoverageTaskTests.cs | 27 ++++++++ .../Templates/CreateDistPackagesTaskTests.cs | 60 ++++++++++++++++++ .../Templates/CreateGitHubReleaseTaskTests.cs | 58 +++++++++++++++++ .../Tasks/Templates/NuGetPushTaskTests.cs | 48 ++++++++++++++ .../Tasks/Templates/PackTaskTests.cs | 27 ++++++++ .../Tasks/Templates/PublishTaskTests.cs | 27 ++++++++ .../Tasks/Templates/RestoreTaskTests.cs | 25 ++++++++ .../Tasks/Templates/TestTaskTests.cs | 31 +++++++++ 12 files changed, 437 insertions(+) create mode 100644 tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs create mode 100644 tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs diff --git a/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs b/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs new file mode 100644 index 00000000..7dae81b8 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs @@ -0,0 +1,63 @@ +using Cake.Core; +using Cake.Core.Diagnostics; +using Cake.Core.IO; +using Cake.Core.Tooling; +using FakeItEasy; + +namespace CreativeCoders.CakeBuild.Tests; + +public static class CakeTestHelper +{ + public static ICakeContext CreateCakeContext(string toolName = "/bin/tool/") + { + var context = A.Fake(); + var environment = A.Fake(); + var fileSystem = A.Fake(); + var log = A.Fake(); + var arguments = A.Fake(); + var fakeProcessRunner = A.Fake(); + var globber = A.Fake(); + var toolLocator = A.Fake(); + + A.CallTo(() => context.Environment).Returns(environment); + A.CallTo(() => context.FileSystem).Returns(fileSystem); + A.CallTo(() => context.Log).Returns(log); + A.CallTo(() => context.Arguments).Returns(arguments); + A.CallTo(() => context.ProcessRunner).Returns(fakeProcessRunner); + A.CallTo(() => context.Globber).Returns(globber); + A.CallTo(() => context.Tools).Returns(toolLocator); + + A.CallTo(() => toolLocator.Resolve(A._)) + .Returns(new FilePath(toolName)); + + A.CallTo(() => toolLocator.Resolve(A>._)) + .Returns(new FilePath(toolName)); + + A.CallTo(() => globber.Match(A._, A._)).Returns([ + new FilePath("/repo/coverage.xml") + ]); + + var toolFile = A.Fake(); + A.CallTo(() => toolFile.Exists).Returns(true); + A.CallTo(() => fileSystem.GetFile(A.That.Matches(x => x.FullPath == toolName))) + .Returns(toolFile); + + return context; + } + + public static void SetupFileSystem(ICakeContext context, string workingDir, string solutionFile) + { + var repoDir = new DirectoryPath(workingDir); + A.CallTo(() => context.Environment.WorkingDirectory).Returns(repoDir); + + var fileSystem = context.FileSystem; + var gitDir = A.Fake(); + A.CallTo(() => gitDir.Exists).Returns(true); + A.CallTo(() => + fileSystem.GetDirectory(A.That.Matches(x => x.FullPath.EndsWith(".git")))) + .Returns(gitDir); + + A.CallTo(() => context.Arguments.HasArgument("solution")).Returns(true); + A.CallTo(() => context.Arguments.GetArguments("solution")).Returns(new[] { solutionFile }); + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj index a44c7de2..4609ffff 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj +++ b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj @@ -5,8 +5,13 @@ enable + + + + + diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs new file mode 100644 index 00000000..bddb6a5c --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs @@ -0,0 +1,38 @@ +using AwesomeAssertions; +using Cake.Core.IO; +using CreativeCoders.CakeBuild.Tasks.Templates; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class BuildTaskTests +{ + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-build"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new CakeBuildContext(cakeContext); + var task = new BuildTask(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-build", A._)) + .Invokes(call => Console.WriteLine(call.Arguments)); + + // Act + var act = () => task.RunAsync(context); + + // Assert + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-build", + A.That.Matches(x => + x.Arguments.Select(x => x.Render()).FirstOrDefault() == "build" && + x.Arguments.Select(x => x.Render()).Skip(1).FirstOrDefault() == "\"/repo/test.sln\""))) + .MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs new file mode 100644 index 00000000..940abe65 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs @@ -0,0 +1,28 @@ +using Cake.Core.IO; +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class CleanTaskTests +{ + private class TestCleanBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICleanTaskSettings; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestCleanBuildContext(cakeContext); + var task = new CleanTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs new file mode 100644 index 00000000..7b9eabe4 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs @@ -0,0 +1,27 @@ +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class CodeCoverageTaskTests +{ + private class TestCodeCoverageBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICodeCoverageTaskSettings; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestCodeCoverageBuildContext(cakeContext); + var task = new CodeCoverageTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs new file mode 100644 index 00000000..4cd69579 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs @@ -0,0 +1,60 @@ +using Cake.Core.IO; +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.IO.Archives; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.IO.Archives.Zip; +using FakeItEasy; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class CreateDistPackagesTaskTests +{ + private class TestCreateDistPackagesBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ICreateDistPackagesTaskSettings + { + public IEnumerable DistPackages => new[] + { + new DistPackage("pkg1", new DirectoryPath("/repo/dist/pkg1")) { Format = DistPackageFormat.Zip }, + new DistPackage("pkg2", new DirectoryPath("/repo/dist/pkg2")) { Format = DistPackageFormat.TarGz } + }; + } + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var archiveWriterFactory = A.Fake(); + var zipWriter = A.Fake(); + var tarWriter = A.Fake(); + + A.CallTo(() => + archiveWriterFactory.CreateZipWriter(A._, A._, + A._)) + .Returns(zipWriter); + A.CallTo(() => + archiveWriterFactory.CreateTarGzWriter(A._, + A._)) + .Returns(tarWriter); + + var context = new TestCreateDistPackagesBuildContext(cakeContext); + var task = new CreateDistPackagesTask(archiveWriterFactory); + + // Act + await task.RunAsync(context); + + // Assert + A.CallTo(() => + archiveWriterFactory.CreateZipWriter(A._, A._, + A._)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => + archiveWriterFactory.CreateTarGzWriter(A._, + A._)) + .MustHaveHappenedOnceExactly(); + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs new file mode 100644 index 00000000..e69072e5 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs @@ -0,0 +1,58 @@ +using CreativeCoders.CakeBuild.GitHub; +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; +using Octokit; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class CreateGitHubReleaseTaskTests +{ + private class TestGitHubReleaseBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ICreateGitHubReleaseTaskSettings + { + public string ReleaseName => "Release 1.0.0"; + public string ReleaseVersion => "1.0.0"; + } + + [Fact] + public async Task RunAsync_ValidContext_CallsGitHubClient() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var gitHubClientFactory = A.Fake(); + var gitHubClient = A.Fake(); + var repositoryClient = A.Fake(); + var releasesClient = A.Fake(); + + A.CallTo(() => gitHubClientFactory.Create(A._)).Returns(gitHubClient); + A.CallTo(() => gitHubClient.Repository).Returns(repositoryClient); + A.CallTo(() => repositoryClient.Release).Returns(releasesClient); + + // Mock GitHubActions environment variables + A.CallTo(() => cakeContext.Environment.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns("true"); + A.CallTo(() => cakeContext.Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")) + .Returns("owner/repo"); + + var context = new TestGitHubReleaseBuildContext(cakeContext); + var task = new CreateGitHubReleaseTask(gitHubClientFactory); + + // Act + // Wir rufen den Task auf. Da wir viele Mocks haben, könnte es sein, + // dass es ohne Exception durchläuft oder fehlschlägt. + try + { + await task.RunAsync(context); + } + catch (Exception) + { + // ignored + } + + // Assert + A.CallTo(() => gitHubClientFactory.Create(A._)).MustHaveHappened(); + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs new file mode 100644 index 00000000..69fa0af8 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs @@ -0,0 +1,48 @@ +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class NuGetPushTaskTests +{ + private class TestNuGetPushBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), INuGetPushTaskSettings, IPackTaskSettings + { + public bool SkipPush { get; set; } + } + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestNuGetPushBuildContext(cakeContext); + var task = new NuGetPushTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } + + [Fact] + public async Task RunAsync_SkipPush_DoesNotPush() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestNuGetPushBuildContext(cakeContext) { SkipPush = true }; + var task = new NuGetPushTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs (Information was logged) + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs new file mode 100644 index 00000000..e6d99f56 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs @@ -0,0 +1,27 @@ +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class PackTaskTests +{ + private class TestPackBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), IPackTaskSettings; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestPackBuildContext(cakeContext); + var task = new PackTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs new file mode 100644 index 00000000..e7eca26a --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs @@ -0,0 +1,27 @@ +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class PublishTaskTests +{ + private class TestPublishBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), IPublishTaskSettings; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestPublishBuildContext(cakeContext); + var task = new PublishTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs new file mode 100644 index 00000000..b3618504 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs @@ -0,0 +1,25 @@ +using Cake.Core.IO; +using CreativeCoders.CakeBuild.Tasks.Templates; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class RestoreTaskTests +{ + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new CakeBuildContext(cakeContext); + var task = new RestoreTask(); + + // Act + await task.RunAsync(context); + + // Assert + // If no exception occurs, it's already a good sign for this simple task + } +} diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs new file mode 100644 index 00000000..5e54e450 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs @@ -0,0 +1,31 @@ +using Cake.Core.IO; +using CreativeCoders.CakeBuild.Tasks.Templates; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class TestTaskTests +{ + private class TestTestBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ITestTaskSettings + { + public IEnumerable TestProjects => new[] { new FilePath("/repo/tests/Project.Tests.csproj") }; + } + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestTestBuildContext(cakeContext); + var task = new TestTask(); + + // Act + await task.RunAsync(context); + + // Assert + // Success if no exception occurs + } +} From 4a0336ef126d0f7252ea7ef3a33fe7130afc8b4e Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:29:08 +0100 Subject: [PATCH 34/42] Refactor and enhance Cake build tasks and unit tests - Updated and enhanced existing unit tests for all main Cake build tasks (`BuildTask`, `CleanTask`, `CodeCoverageTask`, `CreateDistPackagesTask`, `CreateGitHubReleaseTask`, `NuGetPushTask`, `PackTask`, `PublishTask`, `RestoreTask`, and `TestTask`) to ensure stricter validation and better assertions. - Replaced test assertions with `AwesomeAssertions` for improved readability and accuracy. - Renamed property `DistFolder` to `SourceFolder` and updated related logic in `DistPackage` class. - Adjusted mock setups across test cases to align with intended execution flows for all tasks. - Fixed and streamlined test invocation logic to better capture process interaction and directory handling. --- .../Tasks/Templates/CreateDistPackagesTask.cs | 2 +- .../Tasks/Templates/Settings/DistPackage.cs | 4 ++-- .../CakeTestHelper.cs | 2 +- .../Tasks/Templates/BuildTaskTests.cs | 8 +++---- .../Tasks/Templates/CleanTaskTests.cs | 18 ++++++++++---- .../Tasks/Templates/CodeCoverageTaskTests.cs | 17 +++++++++---- .../Templates/CreateDistPackagesTaskTests.cs | 20 ++++++++++++---- .../Templates/CreateGitHubReleaseTaskTests.cs | 12 ++-------- .../Tasks/Templates/NuGetPushTaskTests.cs | 24 +++++++++++++++---- .../Tasks/Templates/PackTaskTests.cs | 20 ++++++++++++---- .../Tasks/Templates/PublishTaskTests.cs | 20 ++++++++++++---- .../Tasks/Templates/RestoreTaskTests.cs | 17 ++++++++++--- .../Tasks/Templates/TestTaskTests.cs | 23 ++++++++++++++---- 13 files changed, 134 insertions(+), 53 deletions(-) diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs index 1be463ef..adb8aa86 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs @@ -43,7 +43,7 @@ private static async Task CreateArchive(Func archiveWriterFactor await using var writer = archiveWriter.ConfigureAwait(false); await archiveWriter - .AddFromDirectoryAsync(distPackage.DistFolder.FullPath, distPackage.DistFolder.FullPath) + .AddFromDirectoryAsync(distPackage.SourceFolder.FullPath, distPackage.SourceFolder.FullPath) .ConfigureAwait(false); } diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs index 679730f0..8fff71bb 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/DistPackage.cs @@ -4,11 +4,11 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; [PublicAPI] -public class DistPackage(string name, DirectoryPath distFolder) +public class DistPackage(string name, DirectoryPath sourceFolder) { public DistPackageFormat Format { get; set; } public string Name { get; } = name; - public DirectoryPath DistFolder { get; } = distFolder; + public DirectoryPath SourceFolder { get; } = sourceFolder; } diff --git a/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs b/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs index 7dae81b8..a00a2cef 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs @@ -58,6 +58,6 @@ public static void SetupFileSystem(ICakeContext context, string workingDir, stri .Returns(gitDir); A.CallTo(() => context.Arguments.HasArgument("solution")).Returns(true); - A.CallTo(() => context.Arguments.GetArguments("solution")).Returns(new[] { solutionFile }); + A.CallTo(() => context.Arguments.GetArguments("solution")).Returns([solutionFile]); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs index bddb6a5c..b0f65155 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs @@ -18,9 +18,6 @@ public async Task RunAsync_ValidContext_RunsWithoutError() var context = new CakeBuildContext(cakeContext); var task = new BuildTask(); - A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-build", A._)) - .Invokes(call => Console.WriteLine(call.Arguments)); - // Act var act = () => task.RunAsync(context); @@ -31,8 +28,9 @@ await act A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-build", A.That.Matches(x => - x.Arguments.Select(x => x.Render()).FirstOrDefault() == "build" && - x.Arguments.Select(x => x.Render()).Skip(1).FirstOrDefault() == "\"/repo/test.sln\""))) + x.Arguments.Select(arg => arg.Render()).FirstOrDefault() == "build" && + x.Arguments.Select(arg => arg.Render()).Skip(1).FirstOrDefault() == + "\"/repo/test.sln\""))) .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs index 940abe65..3b513224 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs @@ -1,28 +1,38 @@ +using AwesomeAssertions; using Cake.Core.IO; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class CleanTaskTests { - private class TestCleanBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICleanTaskSettings; + private class TestCleanBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ICleanTaskSettings; [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestCleanBuildContext(cakeContext); var task = new CleanTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).FirstOrDefault() == "clean"))) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs index 7b9eabe4..874a88b7 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs @@ -1,27 +1,36 @@ +using AwesomeAssertions; +using Cake.Core.IO; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class CodeCoverageTaskTests { - private class TestCodeCoverageBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICodeCoverageTaskSettings; + private class TestCodeCoverageBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ICodeCoverageTaskSettings; [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("reportgenerator"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestCodeCoverageBuildContext(cakeContext); var task = new CodeCoverageTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("reportgenerator", A._)) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs index 4cd69579..786e63fc 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs @@ -5,6 +5,7 @@ using CreativeCoders.IO.Archives.Tar; using CreativeCoders.IO.Archives.Zip; using FakeItEasy; +using JetBrains.Annotations; using Xunit; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; @@ -14,11 +15,13 @@ public class CreateDistPackagesTaskTests private class TestCreateDistPackagesBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICreateDistPackagesTaskSettings { - public IEnumerable DistPackages => new[] - { + [UsedImplicitly] DirectoryPath ICreateDistPackagesTaskSettings.DistOutputPath => "/repo/dist"; + + public IEnumerable DistPackages => + [ new DistPackage("pkg1", new DirectoryPath("/repo/dist/pkg1")) { Format = DistPackageFormat.Zip }, new DistPackage("pkg2", new DirectoryPath("/repo/dist/pkg2")) { Format = DistPackageFormat.TarGz } - }; + ]; } [Fact] @@ -49,12 +52,19 @@ public async Task RunAsync_ValidContext_RunsWithoutError() // Assert A.CallTo(() => - archiveWriterFactory.CreateZipWriter(A._, A._, + archiveWriterFactory.CreateZipWriter("/repo/dist/pkg1.zip", + A._, A._)) .MustHaveHappenedOnceExactly(); A.CallTo(() => - archiveWriterFactory.CreateTarGzWriter(A._, + archiveWriterFactory.CreateTarGzWriter("/repo/dist/pkg2.tar.gz", A._)) .MustHaveHappenedOnceExactly(); + + A.CallTo(() => zipWriter.AddFromDirectoryAsync("/repo/dist/pkg1", "/repo/dist/pkg1")) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => tarWriter.AddFromDirectoryAsync("/repo/dist/pkg2", "/repo/dist/pkg2")) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs index e69072e5..99563510 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs @@ -13,6 +13,7 @@ private class TestGitHubReleaseBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ICreateGitHubReleaseTaskSettings { public string ReleaseName => "Release 1.0.0"; + public string ReleaseVersion => "1.0.0"; } @@ -41,16 +42,7 @@ public async Task RunAsync_ValidContext_CallsGitHubClient() var task = new CreateGitHubReleaseTask(gitHubClientFactory); // Act - // Wir rufen den Task auf. Da wir viele Mocks haben, könnte es sein, - // dass es ohne Exception durchläuft oder fehlschlägt. - try - { - await task.RunAsync(context); - } - catch (Exception) - { - // ignored - } + await task.RunAsync(context); // Assert A.CallTo(() => gitHubClientFactory.Create(A._)).MustHaveHappened(); diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs index 69fa0af8..001432df 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs @@ -1,6 +1,9 @@ +using AwesomeAssertions; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; +using Cake.Core.IO; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; @@ -10,30 +13,40 @@ private class TestNuGetPushBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), INuGetPushTaskSettings, IPackTaskSettings { public bool SkipPush { get; set; } + + public DirectoryPath OutputDirectory => new("/repo/dist"); } [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-nuget-push"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestNuGetPushBuildContext(cakeContext); var task = new NuGetPushTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-nuget-push", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).Any(arg => arg.Contains("nuget")) && + x.Arguments.Select(arg => arg.Render()).Any(arg => arg.Contains("push"))))) + .MustHaveHappenedOnceExactly(); } [Fact] public async Task RunAsync_SkipPush_DoesNotPush() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-nuget-push"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestNuGetPushBuildContext(cakeContext) { SkipPush = true }; @@ -43,6 +56,7 @@ public async Task RunAsync_SkipPush_DoesNotPush() await task.RunAsync(context); // Assert - // Success if no exception occurs (Information was logged) + A.CallTo(() => cakeContext.ProcessRunner.Start(A._, A._)) + .MustNotHaveHappened(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs index e6d99f56..95da12fc 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs @@ -1,27 +1,39 @@ +using AwesomeAssertions; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; +using Cake.Core.IO; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class PackTaskTests { - private class TestPackBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), IPackTaskSettings; + private class TestPackBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), IPackTaskSettings; [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-pack"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestPackBuildContext(cakeContext); var task = new PackTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-pack", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).FirstOrDefault() == "pack" && + x.Arguments.Select(arg => arg.Render()).Contains("\"/repo/test.sln\"")))) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs index e7eca26a..efbcb598 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs @@ -1,27 +1,39 @@ +using AwesomeAssertions; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; +using Cake.Core.IO; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class PublishTaskTests { - private class TestPublishBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), IPublishTaskSettings; + private class TestPublishBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), IPublishTaskSettings; [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-publish"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestPublishBuildContext(cakeContext); var task = new PublishTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-publish", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).FirstOrDefault() == "publish" && + x.Arguments.Select(arg => arg.Render()).Contains("\"/repo/test.sln\"")))) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs index b3618504..c9852794 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs @@ -1,25 +1,36 @@ +using AwesomeAssertions; using Cake.Core.IO; using CreativeCoders.CakeBuild.Tasks.Templates; +using FakeItEasy; using Xunit; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; +[Collection("FileSys")] public class RestoreTaskTests { [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-restore"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new CakeBuildContext(cakeContext); var task = new RestoreTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // If no exception occurs, it's already a good sign for this simple task + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-restore", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).Contains("restore") && + x.Arguments.Select(arg => arg.Render()).Any(arg => arg.Contains("/repo"))))) + .MustHaveHappenedOnceExactly(); } } diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs index 5e54e450..1f42be55 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs @@ -1,31 +1,44 @@ +using AwesomeAssertions; using Cake.Core.IO; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using FakeItEasy; using Xunit; namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class TestTaskTests { - private class TestTestBuildContext(Cake.Core.ICakeContext context) : CakeBuildContext(context), ITestTaskSettings + private class TestTestBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ITestTaskSettings { - public IEnumerable TestProjects => new[] { new FilePath("/repo/tests/Project.Tests.csproj") }; + public IEnumerable TestProjects => + [new FilePath("/repo/tests/Project.Tests.csproj")]; } [Fact] public async Task RunAsync_ValidContext_RunsWithoutError() { // Arrange - var cakeContext = CakeTestHelper.CreateCakeContext(); + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-test"); CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); var context = new TestTestBuildContext(cakeContext); var task = new TestTask(); // Act - await task.RunAsync(context); + var act = () => task.RunAsync(context); // Assert - // Success if no exception occurs + await act + .Should() + .NotThrowAsync(); + + A.CallTo(() => cakeContext.ProcessRunner.Start("dotnet-test", + A.That.Matches(x => + x.Arguments.Select(arg => arg.Render()).FirstOrDefault() == "test" && + x.Arguments.Select(arg => arg.Render()) + .Contains("\"/repo/tests/Project.Tests.csproj\"")))) + .MustHaveHappenedOnceExactly(); } } From aeac4751abd0a67dd3bbddb42cb33e0dcf9062f5 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:37:16 +0100 Subject: [PATCH 35/42] Make `OpenEntryStreamAsync` in `ZipArchiveReader` support copying data to a memory stream - Refactored `OpenEntryStreamAsync` to allow copying entry data into a `MemoryStream` when `copyData` is set to `true`. - Improved error handling and asynchronous stream operations for better reliability. --- .../Zip/ZipArchiveReader.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index 6431d42a..168645f7 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -38,13 +38,27 @@ public IEnumerable GetEntries() new ArchiveEntry(x.FullName)); } - public Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) + public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) { var zipEntry = _zipArchive.GetEntry(entry.FullName); - return zipEntry == null - ? throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the zip archive.") - : zipEntry.OpenAsync(); + if (zipEntry == null) + { + throw new FileNotFoundException($"Entry '{entry.FullName}' not found in the zip archive."); + } + + if (!copyData) + { + return await zipEntry.OpenAsync().ConfigureAwait(false); + } + + var memoryStream = new MemoryStream(); + var zipEntryStream = await zipEntry.OpenAsync().ConfigureAwait(false); + await using var zipEntryStreamDisposable = zipEntryStream.ConfigureAwait(false); + + await zipEntryStream.CopyToAsync(memoryStream).ConfigureAwait(false); + + return memoryStream; } public Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, bool overwriteExisting = true) From 22af7507555ce2329dce5f60084af139c2abda18 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:47:14 +0100 Subject: [PATCH 36/42] Add unit tests for `TarGzArchiveReader` and enhance `OpenEntryStreamAsync` behavior in archive readers - Introduced comprehensive unit tests for `TarGzArchiveReader` to validate archive entry listing, stream opening, and file extraction behavior. - Updated `OpenEntryStreamAsync` in both `TarGzArchiveReader` and `ZipArchiveReader` to reset `MemoryStream` position after data copy for reliable usage. - Refactored `ZipArchiveWriterTests` to simplify stream handling in `AddFileAsync`. --- .../Tar/TarGzArchiveReader.cs | 1 + .../Zip/ZipArchiveReader.cs | 1 + .../Archives/Tar/TarGzArchiveReaderTests.cs | 208 ++++++++++++++++++ .../Archives/Zip/ZipArchiveReaderTests.cs | 23 ++ .../Archives/Zip/ZipArchiveWriterTests.cs | 4 +- 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs index 5b53bb4c..6e7426ec 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -129,6 +129,7 @@ public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData var memoryStream = new MemoryStream(); await entryDataStream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; return memoryStream; } diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index 168645f7..6bc4ce77 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -58,6 +58,7 @@ public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData await zipEntryStream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; return memoryStream; } diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs new file mode 100644 index 00000000..fe0119a7 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs @@ -0,0 +1,208 @@ +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; +using System.IO.Compression; +using AwesomeAssertions; +using CreativeCoders.Core.IO; +using CreativeCoders.IO.Archives; +using CreativeCoders.IO.Archives.Tar; +using CreativeCoders.UnitTests; +using Xunit; + +namespace CreativeCoders.IO.UnitTests.Archives.Tar; + +[SuppressMessage("ReSharper", "NullableWarningSuppressionIsUsed")] +public class TarGzArchiveReaderTests +{ + private static async Task CreateTestTarGzArchiveAsync() + { + var tarStream = new MemoryStream(); + await using (var writer = new TarWriter(tarStream, TarEntryFormat.Pax, true)) + { + var entry1 = new PaxTarEntry(TarEntryType.RegularFile, "file1.txt") + { + DataStream = new MemoryStream("Content 1"u8.ToArray()) + }; + await writer.WriteEntryAsync(entry1); + + var entry2 = new PaxTarEntry(TarEntryType.RegularFile, "dir/file2.txt") + { + DataStream = new MemoryStream("Content 2"u8.ToArray()) + }; + await writer.WriteEntryAsync(entry2); + } + + tarStream.Position = 0; + var gzStream = new MemoryStream(); + await using (var compressionStream = new GZipStream(gzStream, CompressionMode.Compress, true)) + { + await tarStream.CopyToAsync(compressionStream); + } + + gzStream.Position = 0; + return gzStream; + } + + [Fact] + public async Task GetEntriesAsync_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + + // Act + var entries = await reader.GetEntriesAsync().ToListAsync(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + public async Task GetEntries_ArchiveWithEntries_ReturnsAllEntries() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + + // Act + var entries = reader.GetEntries().ToList(); + + // Assert + entries + .Should().HaveCount(2); + + entries.Select(x => x.FullName) + .Should().BeEquivalentTo("file1.txt", "dir/file2.txt"); + } + + [Fact] + public async Task OpenEntryStreamAsync_ExistingEntry_ReturnsCorrectStream() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var entry = new ArchiveEntry("file1.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + content + .Should().Be("Content 1"); + } + + [Fact] + public async Task OpenEntryStreamAsync_WithCopyData_ReturnsCopyOfStream() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var entry = new ArchiveEntry("dir/file2.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry, true); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + content + .Should().Be("Content 2"); + + entryStream + .Should().BeOfType(); + } + + [Fact] + public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var entry = new ArchiveEntry("file1.txt"); + var tempFile = Path.GetTempFileName(); + using var fileCleanup = new FileCleanUp(tempFile); + + // Act + await reader.ExtractFileAsync(entry, tempFile); + + // Assert + File.Exists(tempFile) + .Should().BeTrue(); + + (await File.ReadAllTextAsync(tempFile)) + .Should().Be("Content 1"); + } + + [Fact] + public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativePath() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var entry = new ArchiveEntry("dir/file2.txt"); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + using var dirCleanup = new DirectoryCleanUp(tempDir); + + // Act + var resultPath = await reader.ExtractFileWithPathAsync(entry, tempDir); + + // Assert + File.Exists(resultPath) + .Should().BeTrue(); + + resultPath + .Should().Be(Path.Combine(tempDir, "dir/file2.txt")); + + (await File.ReadAllTextAsync(resultPath)) + .Should().Be("Content 2"); + } + + [Fact] + public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + using var dirCleanup = new DirectoryCleanUp(tempDir); + + // Act + await reader.ExtractAllAsync(tempDir); + + // Assert + File.Exists(Path.Combine(tempDir, "file1.txt")) + .Should().BeTrue(); + File.Exists(Path.Combine(tempDir, "dir/file2.txt")) + .Should().BeTrue(); + + (await File.ReadAllTextAsync(Path.Combine(tempDir, "file1.txt"))) + .Should().Be("Content 1"); + + (await File.ReadAllTextAsync(Path.Combine(tempDir, "dir/file2.txt"))) + .Should().Be("Content 2"); + } + + [Fact] + [SuppressMessage("ReSharper", "AccessToDisposedClosure")] + public async Task OpenEntryStreamAsync_NonExistingEntry_ThrowsFileNotFoundException() + { + // Arrange + using var archiveStream = await CreateTestTarGzArchiveAsync(); + await using var reader = new TarGzArchiveReader(archiveStream); + var entry = new ArchiveEntry("non_existing.txt"); + + // Act + var act = () => reader.OpenEntryStreamAsync(entry); + + // Assert + await act + .Should().ThrowAsync(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs index 0a98bd40..64e04ecf 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs @@ -92,6 +92,29 @@ public async Task OpenEntryStreamAsync_ExistingEntry_ReturnsCorrectStream() .Should().Be("Content 1"); } + [Fact] + [SuppressMessage("ReSharper", "MethodHasAsyncOverload")] + [SuppressMessage("csharpsquid", "S6966")] + public async Task OpenEntryStreamAsync_CopyDataTrueAndExistingEntry_ReturnsCorrectStream() + { + // Arrange + using var archiveStream = await CreateTestZipArchiveAsync(); + await using var reader = ZipArchiveReader.Create(archiveStream); + var entry = new ArchiveEntry("file1.txt"); + + // Act + await using var entryStream = await reader.OpenEntryStreamAsync(entry, true); + using var streamReader = new StreamReader(entryStream); + var content = await streamReader.ReadToEndAsync(); + + // Assert + entryStream + .Should().BeOfType(); + + content + .Should().Be("Content 1"); + } + [Fact] public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() { diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs index 152ac17a..8158bee3 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveWriterTests.cs @@ -164,8 +164,8 @@ public async Task DisposeAsync_ArchiveIsCorrectlyFinalized() // Act await using (var writer = new ZipArchiveWriter(outputStream, CompressionLevel.Optimal, true)) { - await writer.AddFileAsync(new MemoryStream(Encoding.UTF8.GetBytes("Finalized content")), - fileNameInArchive); + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes("Finalized content")); + await writer.AddFileAsync(inputStream, fileNameInArchive); } // Assert From 22525a6343426435bc3681f0e8740977fbd32b44 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:52:51 +0100 Subject: [PATCH 37/42] Refactor `CreateGitHubReleaseTask` to simplify stream handling and improve `UploadAsset` usage --- .../Tasks/Templates/CreateGitHubReleaseTask.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs index 5f1af93f..cfe1c723 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -47,7 +47,7 @@ private async Task UploadReleaseAssets(IGitHubClient gitHubClient, Release relea { var dataStream = releaseAsset.GetAssetStream(); - await using var dataStreamDisposable = dataStream.ConfigureAwait(false); + await using var _ = dataStream.ConfigureAwait(false); var releaseAssetUpload = new ReleaseAssetUpload { @@ -56,7 +56,7 @@ private async Task UploadReleaseAssets(IGitHubClient gitHubClient, Release relea RawData = dataStream }; - _ = await gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) + await gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) .ConfigureAwait(false); } } From c57f1f8d30acfd2a7860be8dbbc0eb19bb3ab21d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:18:26 +0100 Subject: [PATCH 38/42] Add `PathSafety` utility for path validation and integrate it into archive readers - Introduced `PathSafety` class to validate paths and prevent path traversal attacks. - Added `PathSafetyTests` for comprehensive unit testing of path validation methods. - Updated archive readers (`ZipArchiveReader`, `TarArchiveReader`, and `TarGzArchiveReader`) to enforce path safety checks during file extraction. - Enhanced `PathExtensions` with `IsSafe` and `EnsureSafe` extension methods for convenient usage. --- .../Templates/CreateGitHubReleaseTask.cs | 3 +- .../CreativeCoders.Core/IO/PathExtensions.cs | 10 +++ .../Core/CreativeCoders.Core/IO/PathSafety.cs | 83 +++++++++++++++++ .../Tar/TarArchiveReader.cs | 5 ++ .../Tar/TarArchiveReaderFactory.cs | 2 +- .../Tar/TarGzArchiveReader.cs | 21 +++-- .../Zip/ZipArchiveReader.cs | 3 + .../IO/PathSafetyTests.cs | 90 +++++++++++++++++++ .../Archives/Tar/TarGzArchiveReaderTests.cs | 34 +++---- 9 files changed, 228 insertions(+), 23 deletions(-) create mode 100644 source/Core/CreativeCoders.Core/IO/PathSafety.cs create mode 100644 tests/CreativeCoders.Core.UnitTests/IO/PathSafetyTests.cs diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs index cfe1c723..60029616 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -61,7 +61,8 @@ await gitHubClient.Repository.Release.UploadAsset(release, releaseAssetUpload) } } - private async Task CreateReleaseDraftAsync(IGitHubClient gitHubClient, TBuildContext context, + private static async Task CreateReleaseDraftAsync(IGitHubClient gitHubClient, + TBuildContext context, string releaseVersion, string name, string body, bool isPreRelease) diff --git a/source/Core/CreativeCoders.Core/IO/PathExtensions.cs b/source/Core/CreativeCoders.Core/IO/PathExtensions.cs index 1ef9bccb..f622dfaf 100644 --- a/source/Core/CreativeCoders.Core/IO/PathExtensions.cs +++ b/source/Core/CreativeCoders.Core/IO/PathExtensions.cs @@ -10,4 +10,14 @@ public static string ReplaceInvalidFileNameChars(this IPath path, string fileNam return path.GetInvalidFileNameChars() .Aggregate(fileName, (current, c) => current.Replace(c.ToString(), replacement)); } + + public static bool IsSafe(this IPath path, string pathToCheck, string basePath) + { + return PathSafety.IsSafe(path, pathToCheck, basePath); + } + + public static void EnsureSafe(this IPath path, string pathToCheck, string basePath) + { + PathSafety.EnsureSafe(path, pathToCheck, basePath); + } } diff --git a/source/Core/CreativeCoders.Core/IO/PathSafety.cs b/source/Core/CreativeCoders.Core/IO/PathSafety.cs new file mode 100644 index 00000000..ab1bea1a --- /dev/null +++ b/source/Core/CreativeCoders.Core/IO/PathSafety.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using CreativeCoders.Core.IO; + +namespace CreativeCoders.Core.IO; + +/// +/// Provides methods for path safety checks, especially to prevent path traversal attacks. +/// +public static class PathSafety +{ + /// + /// Checks if the specified is safe to use within the given . + /// + /// The path to check. + /// The base path that the must be contained in. + /// true if the path is safe; otherwise, false. + /// + /// + /// var isSafe = PathSafety.IsSafe("data/config.json", "/var/www"); + /// + /// + public static bool IsSafe(string path, string basePath) + { + return IsSafe(FileSys.Path, path, basePath); + } + + /// + /// Checks if the specified is safe to use within the given . + /// + /// The helper to use for path operations. + /// The path to check. + /// The base path that the must be contained in. + /// true if the path is safe; otherwise, false. + public static bool IsSafe(IPath pathHelper, string path, string basePath) + { + Ensure.NotNull(pathHelper); + Ensure.IsNotNullOrWhitespace(path); + Ensure.IsNotNullOrWhitespace(basePath); + + var fullBasePath = pathHelper.GetFullPath(basePath); + if (!fullBasePath.EndsWith(pathHelper.DirectorySeparatorChar) && + !fullBasePath.EndsWith(pathHelper.AltDirectorySeparatorChar)) + { + fullBasePath += pathHelper.DirectorySeparatorChar; + } + + var combinedPath = pathHelper.Combine(fullBasePath, path); + var fullPath = pathHelper.GetFullPath(combinedPath); + + return fullPath.StartsWith(fullBasePath, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Ensures that the specified is safe to use within the given . + /// Throws an if the path is not safe. + /// + /// The path to check. + /// The base path that the must be contained in. + /// Thrown when the path is not safe. + public static void EnsureSafe(string path, string basePath) + { + EnsureSafe(FileSys.Path, path, basePath); + } + + /// + /// Ensures that the specified is safe to use within the given . + /// Throws an if the path is not safe. + /// + /// The helper to use for path operations. + /// The path to check. + /// The base path that the must be contained in. + /// Thrown when the path is not safe. + public static void EnsureSafe(IPath pathHelper, string path, string basePath) + { + if (!IsSafe(pathHelper, path, basePath)) + { + throw new UnauthorizedAccessException( + $"The path '{path}' is not safe and attempts to leave the base path '{basePath}'."); + } + } +} diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs index 2e8f379c..2ad90ad9 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -2,6 +2,7 @@ using System.Formats.Tar; using System.IO.Abstractions; using CreativeCoders.Core; +using CreativeCoders.Core.IO; namespace CreativeCoders.IO.Archives.Tar; @@ -180,6 +181,8 @@ private async Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { + _fileSystem.Path.EnsureSafe(entry.FullName, outputBaseDirectory); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); @@ -195,6 +198,8 @@ public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExis while (tarEntry is not null) { + _fileSystem.Path.EnsureSafe(tarEntry.Name, outputBaseDirectory); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, tarEntry.Name); await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs index 0a75a1c8..0129193f 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs @@ -10,7 +10,7 @@ public class TarArchiveReaderFactory(IFileSystem fileSystem) : ITarArchiveReader public ITarArchiveReader CreateReader(Stream inputStream, bool useGZipCompression = true) { return useGZipCompression - ? new TarGzArchiveReader(inputStream) + ? new TarGzArchiveReader(inputStream, _fileSystem) : new TarArchiveReader(inputStream, _fileSystem); } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs index 6e7426ec..721518ad 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -1,11 +1,15 @@ using System.Formats.Tar; +using System.IO.Abstractions; using System.IO.Compression; using CreativeCoders.Core; +using CreativeCoders.Core.IO; namespace CreativeCoders.IO.Archives.Tar; public sealed class TarGzArchiveReader : ITarArchiveReader { + private readonly IFileSystem _fileSystem; + private TarReader _tarReader; private bool _needsReset; @@ -14,8 +18,9 @@ public sealed class TarGzArchiveReader : ITarArchiveReader private readonly Stream _sourceStream; - public TarGzArchiveReader(Stream inputStream) + public TarGzArchiveReader(Stream inputStream, IFileSystem fileSystem) { + _fileSystem = Ensure.NotNull(fileSystem); _sourceStream = Ensure.NotNull(inputStream); _gzInputStream = new GZipStream(_sourceStream, CompressionMode.Decompress, true); _tarReader = new TarReader(_gzInputStream, true); @@ -147,12 +152,12 @@ public async Task ExtractFileAsync(ArchiveEntry entry, string outputFilePath, await ExtractFileCoreAsync(tarEntry, outputFilePath, overwriteExisting).ConfigureAwait(false); } - private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + private Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) { - var outputDirectory = Path.GetDirectoryName(outputFilePath); + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); if (outputDirectory != null) { - Directory.CreateDirectory(outputDirectory); + _fileSystem.Directory.CreateDirectory(outputDirectory); } return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); @@ -161,7 +166,9 @@ private static Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePat public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { - var outputFileName = Path.Combine(outputBaseDirectory, entry.FullName); + _fileSystem.Path.EnsureSafe(entry.FullName, outputBaseDirectory); + + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); @@ -176,7 +183,9 @@ public async Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExis while (tarEntry is not null) { - var outputFileName = Path.Combine(outputBaseDirectory, tarEntry.Name); + _fileSystem.Path.EnsureSafe(tarEntry.Name, outputBaseDirectory); + + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, tarEntry.Name); await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index 6bc4ce77..a3b4f7aa 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -2,6 +2,7 @@ using System.IO.Abstractions; using System.IO.Compression; using CreativeCoders.Core; +using CreativeCoders.Core.IO; namespace CreativeCoders.IO.Archives.Zip; @@ -86,6 +87,8 @@ private Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePat public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { + _fileSystem.Path.EnsureSafe(outputBaseDirectory, entry.FullName); + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); await ExtractFileAsync(entry, outputFileName, overwriteExisting).ConfigureAwait(false); diff --git a/tests/CreativeCoders.Core.UnitTests/IO/PathSafetyTests.cs b/tests/CreativeCoders.Core.UnitTests/IO/PathSafetyTests.cs new file mode 100644 index 00000000..5ad0c307 --- /dev/null +++ b/tests/CreativeCoders.Core.UnitTests/IO/PathSafetyTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO.Abstractions.TestingHelpers; +using CreativeCoders.Core.IO; +using AwesomeAssertions; +using Xunit; + +namespace CreativeCoders.Core.UnitTests.IO; + +public class PathSafetyTests +{ + [Theory] + [InlineData("file.txt", "/base")] + [InlineData("sub/file.txt", "/base")] + [InlineData("./file.txt", "/base")] + [InlineData("sub/../file.txt", "/base")] + public void IsSafe_SafePaths_ReturnsTrue(string path, string basePath) + { + // Arrange + var fileSystem = new MockFileSystem(); + + // Act + var result = PathSafety.IsSafe(fileSystem.Path, path, basePath); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData("../file.txt", "/base")] + [InlineData("/etc/passwd", "/base")] + [InlineData("sub/../../file.txt", "/base")] + [InlineData("..", "/base")] + [InlineData("../base_extra/file.txt", "/base")] + public void IsSafe_UnsafePaths_ReturnsFalse(string path, string basePath) + { + // Arrange + var fileSystem = new MockFileSystem(); + + // Act + var result = PathSafety.IsSafe(fileSystem.Path, path, basePath); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void EnsureSafe_SafePath_DoesNotThrow() + { + // Arrange + var fileSystem = new MockFileSystem(); + const string path = "file.txt"; + const string basePath = "/base"; + + // Act + var act = () => PathSafety.EnsureSafe(fileSystem.Path, path, basePath); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void EnsureSafe_UnsafePath_ThrowsUnauthorizedAccessException() + { + // Arrange + var fileSystem = new MockFileSystem(); + const string path = "../file.txt"; + const string basePath = "/base"; + + // Act + var act = () => PathSafety.EnsureSafe(fileSystem.Path, path, basePath); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void IsSafe_PathHelperExtension_ReturnsTrue() + { + // Arrange + var fileSystem = new MockFileSystem(); + const string path = "file.txt"; + const string basePath = "/base"; + + // Act + var result = fileSystem.Path.IsSafe(path, basePath); + + // Assert + result.Should().BeTrue(); + } +} diff --git a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs index fe0119a7..93210a8f 100644 --- a/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Formats.Tar; +using System.IO.Abstractions; +using System.IO.Abstractions.TestingHelpers; using System.IO.Compression; using AwesomeAssertions; using CreativeCoders.Core.IO; @@ -47,7 +49,7 @@ public async Task GetEntriesAsync_ArchiveWithEntries_ReturnsAllEntries() { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); // Act var entries = await reader.GetEntriesAsync().ToListAsync(); @@ -65,7 +67,7 @@ public async Task GetEntries_ArchiveWithEntries_ReturnsAllEntries() { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); // Act var entries = reader.GetEntries().ToList(); @@ -83,7 +85,7 @@ public async Task OpenEntryStreamAsync_ExistingEntry_ReturnsCorrectStream() { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); var entry = new ArchiveEntry("file1.txt"); // Act @@ -101,7 +103,7 @@ public async Task OpenEntryStreamAsync_WithCopyData_ReturnsCopyOfStream() { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); var entry = new ArchiveEntry("dir/file2.txt"); // Act @@ -122,7 +124,7 @@ public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); var entry = new ArchiveEntry("file1.txt"); var tempFile = Path.GetTempFileName(); using var fileCleanup = new FileCleanUp(tempFile); @@ -142,8 +144,9 @@ public async Task ExtractFileAsync_ExistingEntry_ExtractsFileToFileSystem() public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativePath() { // Arrange + var fakeFileSystem = new FileSystem(); using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, fakeFileSystem); var entry = new ArchiveEntry("dir/file2.txt"); var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); @@ -153,13 +156,13 @@ public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativ var resultPath = await reader.ExtractFileWithPathAsync(entry, tempDir); // Assert - File.Exists(resultPath) + fakeFileSystem.File.Exists(resultPath) .Should().BeTrue(); resultPath - .Should().Be(Path.Combine(tempDir, "dir/file2.txt")); + .Should().Be(fakeFileSystem.Path.Combine(tempDir, "dir/file2.txt")); - (await File.ReadAllTextAsync(resultPath)) + (await fakeFileSystem.File.ReadAllTextAsync(resultPath)) .Should().Be("Content 2"); } @@ -167,8 +170,9 @@ public async Task ExtractFileWithPathAsync_ExistingEntry_ExtractsFileWithRelativ public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() { // Arrange + var fakeFileSystem = new FileSystem(); using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, fakeFileSystem); var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); using var dirCleanup = new DirectoryCleanUp(tempDir); @@ -177,15 +181,15 @@ public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() await reader.ExtractAllAsync(tempDir); // Assert - File.Exists(Path.Combine(tempDir, "file1.txt")) + fakeFileSystem.File.Exists(fakeFileSystem.Path.Combine(tempDir, "file1.txt")) .Should().BeTrue(); - File.Exists(Path.Combine(tempDir, "dir/file2.txt")) + fakeFileSystem.File.Exists(fakeFileSystem.Path.Combine(tempDir, "dir/file2.txt")) .Should().BeTrue(); - (await File.ReadAllTextAsync(Path.Combine(tempDir, "file1.txt"))) + (await fakeFileSystem.File.ReadAllTextAsync(fakeFileSystem.Path.Combine(tempDir, "file1.txt"))) .Should().Be("Content 1"); - (await File.ReadAllTextAsync(Path.Combine(tempDir, "dir/file2.txt"))) + (await fakeFileSystem.File.ReadAllTextAsync(fakeFileSystem.Path.Combine(tempDir, "dir/file2.txt"))) .Should().Be("Content 2"); } @@ -195,7 +199,7 @@ public async Task OpenEntryStreamAsync_NonExistingEntry_ThrowsFileNotFoundExcept { // Arrange using var archiveStream = await CreateTestTarGzArchiveAsync(); - await using var reader = new TarGzArchiveReader(archiveStream); + await using var reader = new TarGzArchiveReader(archiveStream, new MockFileSystem()); var entry = new ArchiveEntry("non_existing.txt"); // Act From 4981dd9fa65a9fc359f1150de333875eef4e2d2d Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:23:44 +0100 Subject: [PATCH 39/42] Improve path handling in archive readers and enhance GZip file extension checks - Corrected parameter order in `EnsureSafe` usage within `ZipArchiveReader`. - Updated `ArchiveEntry` to use `FileSys.Path` for path operations, improving consistency. - Enhanced `TarFileHelper.IsGZipFile` to fix incorrect extension logic and handle `.tar.gz` files. --- source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs | 5 +++-- source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs | 6 +++--- .../IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs index 378a5f22..d92351ff 100644 --- a/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs @@ -1,12 +1,13 @@ using CreativeCoders.Core; +using CreativeCoders.Core.IO; namespace CreativeCoders.IO.Archives; public class ArchiveEntry(string fullName) { - public string FileName { get; } = Path.GetFileName(fullName); + public string FileName { get; } = FileSys.Path.GetFileName(fullName); public string FullName { get; } = Ensure.IsNotNullOrEmpty(fullName); - public string DirectoryName { get; } = Path.GetDirectoryName(fullName) ?? string.Empty; + public string DirectoryName { get; } = FileSys.Path.GetDirectoryName(fullName) ?? string.Empty; } diff --git a/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs index 05c8d48e..34ad95f6 100644 --- a/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs @@ -5,9 +5,9 @@ public static class TarFileHelper public static bool IsGZipFile(string archiveFileName) { var fileExt = Path.GetExtension(archiveFileName); - return fileExt.Equals("gz", StringComparison.CurrentCultureIgnoreCase) || - fileExt.Equals("tgz", StringComparison.CurrentCultureIgnoreCase) || - fileExt.EndsWith(".gz", StringComparison.CurrentCultureIgnoreCase) || + return fileExt.Equals(".gz", StringComparison.CurrentCultureIgnoreCase) || + fileExt.Equals(".tgz", StringComparison.CurrentCultureIgnoreCase) || + fileExt.EndsWith(".tar.gz", StringComparison.CurrentCultureIgnoreCase) || fileExt.EndsWith(".tgz", StringComparison.CurrentCultureIgnoreCase); } } diff --git a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs index a3b4f7aa..2628b159 100644 --- a/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -87,7 +87,7 @@ private Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePat public async Task ExtractFileWithPathAsync(ArchiveEntry entry, string outputBaseDirectory, bool overwriteExisting = true) { - _fileSystem.Path.EnsureSafe(outputBaseDirectory, entry.FullName); + _fileSystem.Path.EnsureSafe(entry.FullName, outputBaseDirectory); var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, entry.FullName); From 7cd44156b837f70bf27579e9f427f0aa766d4d83 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:25:03 +0100 Subject: [PATCH 40/42] Update source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Settings/ICreateGitHubReleaseTaskSettings.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs index f0452b74..f56cd77a 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs @@ -2,6 +2,14 @@ namespace CreativeCoders.CakeBuild.Tasks.Templates.Settings; public interface ICreateGitHubReleaseTaskSettings : IBuildContextAccessor { + /// + /// Gets the GitHub access token used for creating the release. + /// + /// + /// This value is read from the GITHUB_TOKEN environment variable and is highly sensitive. + /// Implementers and consumers must ensure that this token is kept secure and is never written + /// to console output, logs, or other persistent storage in plain text. + /// public string GitHubToken => Context.Environment.GetEnvironmentVariable("GITHUB_TOKEN"); string ReleaseName { get; } From 198bd4846ff2fd49f57b9f70311311c69b2cfc67 Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:27:34 +0100 Subject: [PATCH 41/42] Update source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Tasks/Templates/CreateGitHubReleaseTask.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs index 60029616..4a8b8aae 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -17,6 +17,11 @@ public class protected override async Task RunAsyncCore(TBuildContext context, ICreateGitHubReleaseTaskSettings taskSettings) { + if (string.IsNullOrWhiteSpace(taskSettings.GitHubToken)) + { + context.Error("GitHub token is missing or empty. Please ensure the GitHub token is provided (for example via the appropriate environment variable) before running this task."); + throw new InvalidOperationException("GitHub token is missing or empty. Cannot create GitHub client."); + } var gitHubClient = _gitHubClientFactory.Create(taskSettings.GitHubToken); await CreateReleaseAsync(gitHubClient, context, taskSettings).ConfigureAwait(false); From ba051c00e2168da8a08871a4061f6e45fc2abe8c Mon Sep 17 00:00:00 2001 From: darthsharp <48331467+darthsharp@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:36:04 +0100 Subject: [PATCH 42/42] Add nullability annotations and enhance `CreateGitHubReleaseTask` tests - Enabled nullable annotations in `PathSafety`. - Added a test for `CreateGitHubReleaseTask` to validate behavior when no GitHub token is provided. --- .../Core/CreativeCoders.Core/IO/PathSafety.cs | 2 + .../Templates/CreateGitHubReleaseTaskTests.cs | 39 ++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/source/Core/CreativeCoders.Core/IO/PathSafety.cs b/source/Core/CreativeCoders.Core/IO/PathSafety.cs index ab1bea1a..2d89d218 100644 --- a/source/Core/CreativeCoders.Core/IO/PathSafety.cs +++ b/source/Core/CreativeCoders.Core/IO/PathSafety.cs @@ -3,6 +3,8 @@ using System.IO.Abstractions; using CreativeCoders.Core.IO; +#nullable enable + namespace CreativeCoders.Core.IO; /// diff --git a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs index 99563510..701309b2 100644 --- a/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs @@ -1,3 +1,4 @@ +using AwesomeAssertions; using CreativeCoders.CakeBuild.GitHub; using CreativeCoders.CakeBuild.Tasks.Templates; using CreativeCoders.CakeBuild.Tasks.Templates.Settings; @@ -9,12 +10,16 @@ namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; public class CreateGitHubReleaseTaskTests { - private class TestGitHubReleaseBuildContext(Cake.Core.ICakeContext context) + private class TestGitHubReleaseBuildContext( + Cake.Core.ICakeContext context, + string gitHubToken = "test-token") : CakeBuildContext(context), ICreateGitHubReleaseTaskSettings { public string ReleaseName => "Release 1.0.0"; public string ReleaseVersion => "1.0.0"; + + public string GitHubToken => gitHubToken; } [Fact] @@ -47,4 +52,36 @@ public async Task RunAsync_ValidContext_CallsGitHubClient() // Assert A.CallTo(() => gitHubClientFactory.Create(A._)).MustHaveHappened(); } + + [Fact] + public async Task RunAsync_ValidContextNoGitHubToken_ThrowsException() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext(); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var gitHubClientFactory = A.Fake(); + var gitHubClient = A.Fake(); + var repositoryClient = A.Fake(); + var releasesClient = A.Fake(); + + A.CallTo(() => gitHubClientFactory.Create(A._)).Returns(gitHubClient); + A.CallTo(() => gitHubClient.Repository).Returns(repositoryClient); + A.CallTo(() => repositoryClient.Release).Returns(releasesClient); + + // Mock GitHubActions environment variables + A.CallTo(() => cakeContext.Environment.GetEnvironmentVariable("GITHUB_ACTIONS")).Returns("true"); + A.CallTo(() => cakeContext.Environment.GetEnvironmentVariable("GITHUB_REPOSITORY")) + .Returns("owner/repo"); + + var context = new TestGitHubReleaseBuildContext(cakeContext, string.Empty); + var task = new CreateGitHubReleaseTask(gitHubClientFactory); + + // Act + var act = () => task.RunAsync(context); + + // Assert + await act + .Should().ThrowAsync(); + } }