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/.gitignore b/.gitignore index 5e99b447..ff766f9d 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,5 @@ ASALocalRun/ .nuke .DS_Store + +.ai-plans diff --git a/Core.sln b/Core.sln index f0ff9a93..4370305c 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,12 @@ 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 +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 @@ -428,10 +432,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 +576,18 @@ 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 + {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 @@ -637,7 +649,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 +695,9 @@ 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} + {A081DDB3-A9DB-498F-824B-C605ACFDB43A} = {5A3FFD14-2258-4007-AC77-BAFB3B3EB26F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {EE24476B-9A4C-4146-B982-3461FAF8B3B0} diff --git a/samples/CakeBuildSample/SampleBuildContext.cs b/samples/CakeBuildSample/SampleBuildContext.cs index 398b52bb..db3f96ae 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(CliHostSampleAppProjectName) + .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 54d777fc..a6cd81c4 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostBuilder.cs @@ -1,10 +1,8 @@ -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; +using Microsoft.Extensions.DependencyInjection; namespace CreativeCoders.CakeBuild; @@ -93,36 +91,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; - } - - var pushSettings = buildContext.GetSettings(); - - if (pushSettings != null) - { - context.Information($"Skip Push: {pushSettings.SkipPush}"); - } - - var version = buildContext.Version; - - context.Information("Version Info"); - - 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..e9070c37 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CakeHostExtensions.cs @@ -1,8 +1,10 @@ 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; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -58,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) @@ -74,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/CreativeCoders.CakeBuild.csproj b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj index 69c651f0..32004047 100644 --- a/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj +++ b/source/CakeBuild/CreativeCoders.CakeBuild/CreativeCoders.CakeBuild.csproj @@ -8,10 +8,13 @@ + + + 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/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/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/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/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..6a443c5e 100755 --- a/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/FrostingTaskBase.cs @@ -34,3 +34,31 @@ public override void Finally(T context) context.AddExecutedTask(this); } } + +[PublicAPI] +public abstract class FrostingTaskBase : FrostingTaskBase + where TBuildContext : CakeBuildContext + where TSettings : class +{ + protected sealed override Task RunAsyncCore(TBuildContext context) + { + var taskSettings = context.GetSettings(); + + if (taskSettings != null) + { + 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/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..adb8aa86 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateDistPackagesTask.cs @@ -0,0 +1,51 @@ +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.Core; +using CreativeCoders.IO.Archives; + +namespace CreativeCoders.CakeBuild.Tasks.Templates; + +public class + CreateDistPackagesTask(IArchiveWriterFactory archiveWriterFactory) + : FrostingTaskBase + where TBuildContext : CakeBuildContext +{ + 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: + await CreateArchive(() => _archiveWriterFactory.CreateTarGzWriter( + taskSettings.DistOutputPath.CombineWithFilePath($"{distPackage.Name}.tar.gz") + .FullPath), distPackage) + .ConfigureAwait(false); + break; + case DistPackageFormat.Zip: + await CreateArchive(() => _archiveWriterFactory.CreateZipWriter( + taskSettings.DistOutputPath.CombineWithFilePath($"{distPackage.Name}.zip") + .FullPath), distPackage) + .ConfigureAwait(false); + break; + default: + throw new InvalidOperationException("Package format not supported"); + } + } + } + + private static async Task CreateArchive(Func archiveWriterFactory, + DistPackage distPackage) + { + var archiveWriter = archiveWriterFactory(); + await using var writer = archiveWriter.ConfigureAwait(false); + + await archiveWriter + .AddFromDirectoryAsync(distPackage.SourceFolder.FullPath, distPackage.SourceFolder.FullPath) + .ConfigureAwait(false); + } + + protected override bool SkipIfNoSettings => true; +} 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..4a8b8aae --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/CreateGitHubReleaseTask.cs @@ -0,0 +1,107 @@ +using Cake.Common.Build; +using Cake.Common.Diagnostics; +using CreativeCoders.CakeBuild.GitHub; +using CreativeCoders.CakeBuild.Tasks.Templates.Settings; +using CreativeCoders.Core; +using Octokit; + +namespace CreativeCoders.CakeBuild.Tasks.Templates; + +public class + CreateGitHubReleaseTask(IGitHubClientFactory gitHubClientFactory) + : FrostingTaskBase + where TBuildContext : CakeBuildContext +{ + private readonly IGitHubClientFactory _gitHubClientFactory = Ensure.NotNull(gitHubClientFactory); + + 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); + } + + private async Task CreateReleaseAsync(IGitHubClient gitHubClient, TBuildContext context, + ICreateGitHubReleaseTaskSettings taskSettings) + { + var release = await CreateReleaseDraftAsync(gitHubClient, context, + taskSettings.ReleaseVersion, + taskSettings.ReleaseName, + taskSettings.ReleaseBody, + taskSettings.IsPreRelease) + .ConfigureAwait(false); + + await UploadReleaseAssets(gitHubClient, 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(IGitHubClient gitHubClient, Release release, + IEnumerable githubReleaseAssets) + { + foreach (var releaseAsset in githubReleaseAssets) + { + var dataStream = releaseAsset.GetAssetStream(); + + await using var _ = 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 static async Task CreateReleaseDraftAsync(IGitHubClient gitHubClient, + 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/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 8c418331..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; } @@ -26,7 +24,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..f3b3dc6c --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/PublishTask.cs @@ -0,0 +1,60 @@ +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 +{ + protected override Task RunAsyncCore(TBuildContext context, IPublishTaskSettings taskSettings) + { + if (taskSettings.PublishingItems.Any()) + { + foreach (var publishingItem in taskSettings.PublishingItems) + { + context.DotNetPublish(publishingItem.ProjectPath.FullPath, + CreateDotNetPublishSettings(publishingItem, context)); + } + } + else + { + context.DotNetPublish(context.SolutionFile.FullPath, + CreateDotNetPublishSettings(taskSettings, 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; + } + + protected override bool SkipIfNoSettings => true; +} 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..8fff71bb --- /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 sourceFolder) +{ + public DistPackageFormat Format { get; set; } + + public string Name { get; } = name; + + public DirectoryPath SourceFolder { get; } = sourceFolder; +} 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/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/ICreateDistPackagesTaskSettings.cs b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateDistPackagesTaskSettings.cs new file mode 100644 index 00000000..e7e29bf9 --- /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 : IBuildContextAccessor +{ + IEnumerable DistPackages => []; + + DirectoryPath DistOutputPath => Context.ArtifactsDir.Combine("dist"); +} 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..f56cd77a --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/ICreateGitHubReleaseTaskSettings.cs @@ -0,0 +1,24 @@ +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; } + + string ReleaseBody => string.Empty; + + bool IsPreRelease => false; + + string ReleaseVersion { get; } + + IEnumerable ReleaseAssets => []; +} 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..0223ec10 --- /dev/null +++ b/source/CakeBuild/CreativeCoders.CakeBuild/Tasks/Templates/Settings/PublishingItem.cs @@ -0,0 +1,16 @@ +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; + + public DirectoryPath OutputDir { get; } = outputDir; + + public string? Runtime { get; set; } + + public bool? SelfContained { 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; diff --git a/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs new file mode 100644 index 00000000..d807238c --- /dev/null +++ b/source/Core/CreativeCoders.Core/IO/DirectoryCleanUp.cs @@ -0,0 +1,27 @@ +using System; + +namespace CreativeCoders.Core.IO; + +public sealed class DirectoryCleanUp(string directoryName, bool recursive = true, bool throwException = false) + : IDisposable +{ + public void Dispose() + { + try + { + if (FileSys.Directory.Exists(DirectoryName)) + { + 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..5858de7e 100644 --- a/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs +++ b/source/Core/CreativeCoders.Core/IO/FileCleanUp.cs @@ -2,31 +2,25 @@ 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 { - FileSys.File.Delete(FileName); + if (FileSys.File.Exists(FileName)) + { + FileSys.File.Delete(FileName); + } } catch (Exception) { - if (_throwException) + if (throwException) { throw; } } - } - public string FileName { get; } + public string FileName { get; } = Ensure.IsNotNullOrWhitespace(fileName); } 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/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..2d89d218 --- /dev/null +++ b/source/Core/CreativeCoders.Core/IO/PathSafety.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.IO.Abstractions; +using CreativeCoders.Core.IO; + +#nullable enable + +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/ArchiveEntry.cs b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs new file mode 100644 index 00000000..d92351ff --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveEntry.cs @@ -0,0 +1,13 @@ +using CreativeCoders.Core; +using CreativeCoders.Core.IO; + +namespace CreativeCoders.IO.Archives; + +public class ArchiveEntry(string fullName) +{ + public string FileName { get; } = FileSys.Path.GetFileName(fullName); + + public string FullName { get; } = Ensure.IsNotNullOrEmpty(fullName); + + public string DirectoryName { get; } = FileSys.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..73ff97e5 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchiveWriterFactory.cs @@ -0,0 +1,56 @@ +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 +{ + 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, bool leaveOpen = false) + { + return _zipArchiveWriterFactory.CreateWriter(outputStream, compressionLevel, leaveOpen); + } + + 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..bb083007 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/ArchivesServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +using CreativeCoders.Core.IO; +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 + .AddFileSystem() + .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..23f9fc22 --- /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, bool copyData = false); + + 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..2c423e6c --- /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, bool leaveOpen = false); + + 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..3a6c4f7d --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/ITarArchiveWriterFactory.cs @@ -0,0 +1,12 @@ +using System.IO.Compression; + +namespace CreativeCoders.IO.Archives.Tar; + +public interface ITarArchiveWriterFactory +{ + 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); +} 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..2ad90ad9 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReader.cs @@ -0,0 +1,223 @@ +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; +using System.IO.Abstractions; +using CreativeCoders.Core; +using CreativeCoders.Core.IO; + +namespace CreativeCoders.IO.Archives.Tar; + +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; + + 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 async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) + { + 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 (entryDataStream.CanSeek) + { + entryDataStream.Position = 0; + } + + if (!copyData) + { + return entryDataStream; + } + + var memoryStream = new MemoryStream(); + await entryDataStream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + + return memoryStream; + } + + 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 async Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + { + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + _fileSystem.Directory.CreateDirectory(outputDirectory); + } + + 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, + bool overwriteExisting = true) + { + _fileSystem.Path.EnsureSafe(entry.FullName, outputBaseDirectory); + + var outputFileName = _fileSystem.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) + { + _fileSystem.Path.EnsureSafe(tarEntry.Name, outputBaseDirectory); + + var outputFileName = _fileSystem.Path.Combine(outputBaseDirectory, tarEntry.Name); + + await ExtractFileCoreAsync(tarEntry, outputFileName, overwriteExisting).ConfigureAwait(false); + + tarEntry = await _tarReader.GetNextEntryAsync().ConfigureAwait(false); + } + } + + [ExcludeFromCodeCoverage] + 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..0129193f --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveReaderFactory.cs @@ -0,0 +1,23 @@ +using System.IO.Abstractions; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Tar; + +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, _fileSystem) + : new TarArchiveReader(inputStream, _fileSystem); + } + + public ITarArchiveReader CreateReader(string archiveFileName, bool? useGZipCompression = null) + { + return CreateReader( + _fileSystem.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..5fe412d6 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriter.cs @@ -0,0 +1,104 @@ +using System.Diagnostics.CodeAnalysis; +using System.Formats.Tar; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Tar; + +public sealed class TarArchiveWriter(Stream outputStream, bool leaveOpen = false) : ITarArchiveWriter +{ + private readonly TarWriter _tarWriter = + new TarWriter(Ensure.NotNull(outputStream), TarEntryFormat.Pax, leaveOpen); + + 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; + } + + [ExcludeFromCodeCoverage] + 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; + }); + } +} 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..6a3c95b7 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarArchiveWriterFactory.cs @@ -0,0 +1,34 @@ +using System.IO.Abstractions; +using System.IO.Compression; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Tar; + +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, bool leaveOpen = false) + { + return new TarArchiveWriter(useGZipCompression + ? new GZipStream(outputStream, compressionLevel, leaveOpen) + : outputStream, leaveOpen); + } + + 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), + compressionLevel); + } +} 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/Tar/TarFileHelper.cs b/source/IO/CreativeCoders.IO.Archives/Tar/TarFileHelper.cs new file mode 100644 index 00000000..34ad95f6 --- /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(".tar.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..721518ad --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Tar/TarGzArchiveReader.cs @@ -0,0 +1,209 @@ +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; + + private Stream _gzInputStream; + + private readonly Stream _sourceStream; + + 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); + } + + 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 async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) + { + 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); + memoryStream.Position = 0; + + return memoryStream; + } + + 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 Task ExtractFileCoreAsync(TarEntry tarEntry, string outputFilePath, bool overwriteExisting) + { + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + _fileSystem.Directory.CreateDirectory(outputDirectory); + } + + return tarEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + } + + 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); + + 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) + { + _fileSystem.Path.EnsureSafe(tarEntry.Name, outputBaseDirectory); + + var outputFileName = _fileSystem.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..ee718fbc --- /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, bool leaveOpen = false); + + 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..2628b159 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReader.cs @@ -0,0 +1,114 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Abstractions; +using System.IO.Compression; +using CreativeCoders.Core; +using CreativeCoders.Core.IO; + +namespace CreativeCoders.IO.Archives.Zip; + +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, IFileSystem? fileSystem = null) + { + var zipArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, false); + + return new ZipArchiveReader(zipArchive, fileSystem ?? new FileSystem()); + } + + 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, fileSystem ?? new FileSystem()); + } + + public IAsyncEnumerable GetEntriesAsync() + { + return GetEntries().ToAsyncEnumerable(); + } + + public IEnumerable GetEntries() + { + return _zipArchive.Entries + .Select(x => + new ArchiveEntry(x.FullName)); + } + + public async Task OpenEntryStreamAsync(ArchiveEntry entry, bool copyData = false) + { + var zipEntry = _zipArchive.GetEntry(entry.FullName); + + 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); + + memoryStream.Position = 0; + return memoryStream; + } + + 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 Task ExtractFileCoreAsync(ZipArchiveEntry zipEntry, string outputFilePath, + bool overwriteExisting) + { + var outputDirectory = _fileSystem.Path.GetDirectoryName(outputFilePath); + if (outputDirectory != null) + { + _fileSystem.Directory.CreateDirectory(outputDirectory); + } + + return zipEntry.ExtractToFileAsync(outputFilePath, overwriteExisting); + } + + 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); + + return outputFileName; + } + + public Task ExtractAllAsync(string outputBaseDirectory, bool overwriteExisting = true) + { + return _zipArchive.ExtractToDirectoryAsync(outputBaseDirectory, overwriteExisting); + } + + [ExcludeFromCodeCoverage] + 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..05855276 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveReaderFactory.cs @@ -0,0 +1,29 @@ +using System.IO.Abstractions; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Zip; + +public class ZipArchiveReaderFactory(IFileSystem fileSystem) : IZipArchiveReaderFactory +{ + private readonly IFileSystem _fileSystem = Ensure.NotNull(fileSystem); + + public IZipArchiveReader CreateReader(Stream inputStream) + { + return ZipArchiveReader.Create(inputStream, _fileSystem); + } + + public IZipArchiveReader CreateReader(string archiveFileName) + { + return CreateReader(_fileSystem.File.OpenRead(archiveFileName)); + } + + public async Task CreateReaderAsync(Stream inputStream) + { + return await ZipArchiveReader.CreateAsync(inputStream, _fileSystem).ConfigureAwait(false); + } + + public Task CreateReaderAsync(string archiveFileName) + { + return CreateReaderAsync(_fileSystem.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..76a1e1b1 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriter.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Zip; + +public sealed class ZipArchiveWriter( + Stream outputStream, + CompressionLevel defaultCompressionLevel, + bool leaveOpen = false) : IZipArchiveWriter +{ + private readonly ZipArchive + _archive = new ZipArchive(Ensure.NotNull(outputStream), ZipArchiveMode.Create, leaveOpen); + + 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); + } + } + + [ExcludeFromCodeCoverage] + 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..8fea0b64 --- /dev/null +++ b/source/IO/CreativeCoders.IO.Archives/Zip/ZipArchiveWriterFactory.cs @@ -0,0 +1,33 @@ +using System.IO.Abstractions; +using System.IO.Compression; +using CreativeCoders.Core; + +namespace CreativeCoders.IO.Archives.Zip; + +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) + { + return new ZipArchiveWriter(outputStream, compressionLevel, leaveOpen); + } + + 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 + ? _fileSystem.File.Create(archiveFileName) + : _fileSystem.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.CakeBuild.Tests/CakeTestHelper.cs b/tests/CreativeCoders.CakeBuild.Tests/CakeTestHelper.cs new file mode 100644 index 00000000..a00a2cef --- /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([solutionFile]); + } +} 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..4609ffff --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/CreativeCoders.CakeBuild.Tests.csproj @@ -0,0 +1,32 @@ + + + + enable + enable + + + + + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + 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..b0f65155 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/BuildTaskTests.cs @@ -0,0 +1,36 @@ +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(); + + // 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(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 new file mode 100644 index 00000000..3b513224 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CleanTaskTests.cs @@ -0,0 +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; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestCleanBuildContext(cakeContext); + var task = new CleanTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..874a88b7 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CodeCoverageTaskTests.cs @@ -0,0 +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; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("reportgenerator"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestCodeCoverageBuildContext(cakeContext); + var task = new CodeCoverageTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..786e63fc --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateDistPackagesTaskTests.cs @@ -0,0 +1,70 @@ +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 JetBrains.Annotations; +using Xunit; + +namespace CreativeCoders.CakeBuild.Tests.Tasks.Templates; + +public class CreateDistPackagesTaskTests +{ + private class TestCreateDistPackagesBuildContext(Cake.Core.ICakeContext context) + : CakeBuildContext(context), ICreateDistPackagesTaskSettings + { + [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] + 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("/repo/dist/pkg1.zip", + A._, + A._)) + .MustHaveHappenedOnceExactly(); + A.CallTo(() => + 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 new file mode 100644 index 00000000..701309b2 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/CreateGitHubReleaseTaskTests.cs @@ -0,0 +1,87 @@ +using AwesomeAssertions; +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, + 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] + 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 + await task.RunAsync(context); + + // 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(); + } +} 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..001432df --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/NuGetPushTaskTests.cs @@ -0,0 +1,62 @@ +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 NuGetPushTaskTests +{ + 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("dotnet-nuget-push"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestNuGetPushBuildContext(cakeContext); + var task = new NuGetPushTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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("dotnet-nuget-push"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestNuGetPushBuildContext(cakeContext) { SkipPush = true }; + var task = new NuGetPushTask(); + + // Act + await task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..95da12fc --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PackTaskTests.cs @@ -0,0 +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; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-pack"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestPackBuildContext(cakeContext); + var task = new PackTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..efbcb598 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/PublishTaskTests.cs @@ -0,0 +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; + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-publish"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestPublishBuildContext(cakeContext); + var task = new PublishTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..c9852794 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/RestoreTaskTests.cs @@ -0,0 +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("dotnet-restore"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new CakeBuildContext(cakeContext); + var task = new RestoreTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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 new file mode 100644 index 00000000..1f42be55 --- /dev/null +++ b/tests/CreativeCoders.CakeBuild.Tests/Tasks/Templates/TestTaskTests.cs @@ -0,0 +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 + { + public IEnumerable TestProjects => + [new FilePath("/repo/tests/Project.Tests.csproj")]; + } + + [Fact] + public async Task RunAsync_ValidContext_RunsWithoutError() + { + // Arrange + var cakeContext = CakeTestHelper.CreateCakeContext("dotnet-test"); + CakeTestHelper.SetupFileSystem(cakeContext, "/repo", "/repo/test.sln"); + + var context = new TestTestBuildContext(cakeContext); + var task = new TestTask(); + + // Act + var act = () => task.RunAsync(context); + + // Assert + 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(); + } +} 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 - + 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/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); + } +} 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/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/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/Tar/TarArchiveWriterTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs new file mode 100644 index 00000000..d06fc0d8 --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarArchiveWriterTests.cs @@ -0,0 +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/Archives/Tar/TarGzArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs new file mode 100644 index 00000000..93210a8f --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Tar/TarGzArchiveReaderTests.cs @@ -0,0 +1,212 @@ +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; +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, new MockFileSystem()); + + // 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, new MockFileSystem()); + + // 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, new MockFileSystem()); + 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, new MockFileSystem()); + 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, new MockFileSystem()); + 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 + var fakeFileSystem = new FileSystem(); + using var archiveStream = await CreateTestTarGzArchiveAsync(); + 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); + using var dirCleanup = new DirectoryCleanUp(tempDir); + + // Act + var resultPath = await reader.ExtractFileWithPathAsync(entry, tempDir); + + // Assert + fakeFileSystem.File.Exists(resultPath) + .Should().BeTrue(); + + resultPath + .Should().Be(fakeFileSystem.Path.Combine(tempDir, "dir/file2.txt")); + + (await fakeFileSystem.File.ReadAllTextAsync(resultPath)) + .Should().Be("Content 2"); + } + + [Fact] + public async Task ExtractAllAsync_ArchiveWithEntries_ExtractsAllFiles() + { + // Arrange + var fakeFileSystem = new FileSystem(); + using var archiveStream = await CreateTestTarGzArchiveAsync(); + 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); + + // Act + await reader.ExtractAllAsync(tempDir); + + // Assert + fakeFileSystem.File.Exists(fakeFileSystem.Path.Combine(tempDir, "file1.txt")) + .Should().BeTrue(); + fakeFileSystem.File.Exists(fakeFileSystem.Path.Combine(tempDir, "dir/file2.txt")) + .Should().BeTrue(); + + (await fakeFileSystem.File.ReadAllTextAsync(fakeFileSystem.Path.Combine(tempDir, "file1.txt"))) + .Should().Be("Content 1"); + + (await fakeFileSystem.File.ReadAllTextAsync(fakeFileSystem.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, new MockFileSystem()); + 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/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/ZipArchiveReaderTests.cs b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs new file mode 100644 index 00000000..64e04ecf --- /dev/null +++ b/tests/CreativeCoders.IO.UnitTests/Archives/Zip/ZipArchiveReaderTests.cs @@ -0,0 +1,216 @@ +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(Path.Combine("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", Path.Combine("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", Path.Combine("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] + [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() + { + // 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(Path.Combine("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(); + } +} 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(); + } +} 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..8158bee3 --- /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)) + { + using var inputStream = new MemoryStream(Encoding.UTF8.GetBytes("Finalized content")); + await writer.AddFileAsync(inputStream, 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 33165a6f..bd4edd14 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,11 +20,18 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + + + + + + + +