diff --git a/.gitignore b/.gitignore index 5e99b44..c9a1a21 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,4 @@ ASALocalRun/ .nuke .DS_Store +.tokensave diff --git a/macSynkker.sln b/macSynkker.sln index 795fadb..d1448f9 100644 --- a/macSynkker.sln +++ b/macSynkker.sln @@ -75,53 +75,131 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{1A70000D build.cmd = build.cmd EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CreativeCoders.MacOS.HomeBrew.Tests", "tests\CreativeCoders.MacOS.HomeBrew.Tests\CreativeCoders.MacOS.HomeBrew.Tests.csproj", "{9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {C033DF64-285B-4C44-AFFC-400878C92C73} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} - {1C050676-278A-4DD4-8396-A27BA85A89E6} = {C033DF64-285B-4C44-AFFC-400878C92C73} - {4B29C32B-9BAF-471E-B83A-D7F5735F27B8} = {1C050676-278A-4DD4-8396-A27BA85A89E6} - {083C21F2-8C37-4E30-9CA8-82AAD4F77DD9} = {3CA6C44F-1F49-4D5D-A26F-92046B51B4F5} - {16659358-8B04-4FAD-80A0-467982B00A9D} = {3CA6C44F-1F49-4D5D-A26F-92046B51B4F5} - {BFADD6C0-DB57-476C-A168-A1AE62DF4668} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} - {16476670-C168-4DDD-B6A5-069F402E6960} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} - {6C7AB20D-847C-48A0-92EF-C45C621E11F0} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} - {75784FD0-11AA-4DB2-95AE-92DA3C3B793D} = {215614E0-F85F-460E-BCAA-13368AE1E3BD} - {BF53FB30-8068-426D-BC06-B692E45CE94D} = {D3F213BB-A6EC-4DDA-9C43-59094DF2FD09} - {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78} = {9A2D709F-395D-4373-9B73-FDAE4E606B64} - {076341D1-E2B7-47A8-AB79-78BFD7DBA646} = {BF2B8E40-4D03-41A1-8ABD-7AD1A33D5B53} - {3CAC9757-F7D3-4A37-9820-B8755EF7FF3F} = {083C21F2-8C37-4E30-9CA8-82AAD4F77DD9} - {1A70000D-DAC9-4D34-9306-7914CC837B05} = {BF2B8E40-4D03-41A1-8ABD-7AD1A33D5B53} + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|x64.ActiveCfg = Debug|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|x64.Build.0 = Debug|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Debug|x86.Build.0 = Debug|Any CPU {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|Any CPU.Build.0 = Release|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|x64.ActiveCfg = Release|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|x64.Build.0 = Release|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|x86.ActiveCfg = Release|Any CPU + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8}.Release|x86.Build.0 = Release|Any CPU {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|x64.ActiveCfg = Debug|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|x64.Build.0 = Debug|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|x86.ActiveCfg = Debug|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Debug|x86.Build.0 = Debug|Any CPU {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|Any CPU.Build.0 = Release|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|x64.ActiveCfg = Release|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|x64.Build.0 = Release|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|x86.ActiveCfg = Release|Any CPU + {BFADD6C0-DB57-476C-A168-A1AE62DF4668}.Release|x86.Build.0 = Release|Any CPU {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|x64.ActiveCfg = Debug|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|x64.Build.0 = Debug|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|x86.ActiveCfg = Debug|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Debug|x86.Build.0 = Debug|Any CPU {16476670-C168-4DDD-B6A5-069F402E6960}.Release|Any CPU.ActiveCfg = Release|Any CPU {16476670-C168-4DDD-B6A5-069F402E6960}.Release|Any CPU.Build.0 = Release|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Release|x64.ActiveCfg = Release|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Release|x64.Build.0 = Release|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Release|x86.ActiveCfg = Release|Any CPU + {16476670-C168-4DDD-B6A5-069F402E6960}.Release|x86.Build.0 = Release|Any CPU {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|x64.Build.0 = Debug|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Debug|x86.Build.0 = Debug|Any CPU {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|Any CPU.ActiveCfg = Release|Any CPU {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|Any CPU.Build.0 = Release|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|x64.ActiveCfg = Release|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|x64.Build.0 = Release|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|x86.ActiveCfg = Release|Any CPU + {6C7AB20D-847C-48A0-92EF-C45C621E11F0}.Release|x86.Build.0 = Release|Any CPU {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|x64.ActiveCfg = Debug|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|x64.Build.0 = Debug|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|x86.ActiveCfg = Debug|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Debug|x86.Build.0 = Debug|Any CPU {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|Any CPU.ActiveCfg = Release|Any CPU {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|Any CPU.Build.0 = Release|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|x64.ActiveCfg = Release|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|x64.Build.0 = Release|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|x86.ActiveCfg = Release|Any CPU + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D}.Release|x86.Build.0 = Release|Any CPU {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|x64.Build.0 = Debug|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Debug|x86.Build.0 = Debug|Any CPU {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|Any CPU.ActiveCfg = Release|Any CPU {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|Any CPU.Build.0 = Release|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|x64.ActiveCfg = Release|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|x64.Build.0 = Release|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|x86.ActiveCfg = Release|Any CPU + {BF53FB30-8068-426D-BC06-B692E45CE94D}.Release|x86.Build.0 = Release|Any CPU {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Debug|x64.ActiveCfg = Debug|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Debug|x64.Build.0 = Debug|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Debug|x86.ActiveCfg = Debug|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Debug|x86.Build.0 = Debug|Any CPU {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Release|x64.ActiveCfg = Release|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Release|x64.Build.0 = Release|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Release|x86.ActiveCfg = Release|Any CPU + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78}.Release|x86.Build.0 = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|x64.Build.0 = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Debug|x86.Build.0 = Debug|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|Any CPU.Build.0 = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|x64.ActiveCfg = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|x64.Build.0 = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|x86.ActiveCfg = Release|Any CPU + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C033DF64-285B-4C44-AFFC-400878C92C73} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} + {1C050676-278A-4DD4-8396-A27BA85A89E6} = {C033DF64-285B-4C44-AFFC-400878C92C73} + {4B29C32B-9BAF-471E-B83A-D7F5735F27B8} = {1C050676-278A-4DD4-8396-A27BA85A89E6} + {083C21F2-8C37-4E30-9CA8-82AAD4F77DD9} = {3CA6C44F-1F49-4D5D-A26F-92046B51B4F5} + {16659358-8B04-4FAD-80A0-467982B00A9D} = {3CA6C44F-1F49-4D5D-A26F-92046B51B4F5} + {BFADD6C0-DB57-476C-A168-A1AE62DF4668} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} + {16476670-C168-4DDD-B6A5-069F402E6960} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} + {6C7AB20D-847C-48A0-92EF-C45C621E11F0} = {A9CE4FEF-F010-408C-9B5E-F00DC960A792} + {75784FD0-11AA-4DB2-95AE-92DA3C3B793D} = {215614E0-F85F-460E-BCAA-13368AE1E3BD} + {BF53FB30-8068-426D-BC06-B692E45CE94D} = {D3F213BB-A6EC-4DDA-9C43-59094DF2FD09} + {5D00B0B9-E9B4-423C-AFCF-0F7CB21B2A78} = {9A2D709F-395D-4373-9B73-FDAE4E606B64} + {076341D1-E2B7-47A8-AB79-78BFD7DBA646} = {BF2B8E40-4D03-41A1-8ABD-7AD1A33D5B53} + {3CAC9757-F7D3-4A37-9820-B8755EF7FF3F} = {083C21F2-8C37-4E30-9CA8-82AAD4F77DD9} + {1A70000D-DAC9-4D34-9306-7914CC837B05} = {BF2B8E40-4D03-41A1-8ABD-7AD1A33D5B53} + {9D2DCA2F-3A9B-4258-92FB-91FDCEB714A0} = {D3F213BB-A6EC-4DDA-9C43-59094DF2FD09} EndGlobalSection EndGlobal diff --git a/source/CreativeCoders.MacOS.HomeBrew/BrewInstaller.cs b/source/CreativeCoders.MacOS.HomeBrew/BrewInstaller.cs new file mode 100644 index 0000000..e19e00e --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/BrewInstaller.cs @@ -0,0 +1,83 @@ +using CreativeCoders.Core; +using CreativeCoders.MacOS.HomeBrew.Import; +using CreativeCoders.ProcessUtils.Execution; + +namespace CreativeCoders.MacOS.HomeBrew; + +/// +/// Default implementation. Uses +/// instances (analogous to BrewUpgrader) to invoke the brew CLI. +/// +public class BrewInstaller : IBrewInstaller +{ + private readonly IProcessExecutor _tapExecutor; + + private readonly IProcessExecutor _installFormulaExecutor; + + private readonly IProcessExecutor _installCaskExecutor; + + public BrewInstaller(IProcessExecutorBuilder processExecutorBuilder) + { + Ensure.NotNull(processExecutorBuilder); + + _tapExecutor = processExecutorBuilder + .SetFileName("brew") + .SetArguments(["tap", "{{tap}}"]) + .ShouldThrowOnError() + .Build(); + + _installFormulaExecutor = processExecutorBuilder + .SetFileName("brew") + .SetArguments(["install", "{{name}}"]) + .ShouldThrowOnError() + .Build(); + + _installCaskExecutor = processExecutorBuilder + .SetFileName("brew") + .SetArguments(["install", "--cask", "{{token}}"]) + .ShouldThrowOnError() + .Build(); + } + + public async Task TapAsync(string tap) + { + Ensure.IsNotNullOrWhitespace(tap); + + try + { + await _tapExecutor.ExecuteAsync(new { tap }).ConfigureAwait(false); + } + catch (ProcessExecutionFailedException e) + { + throw new BrewInstallFailedException(BrewInstallTargetKind.Tap, tap, e.ErrorOutput, e.ExitCode); + } + } + + public async Task InstallFormulaAsync(string name) + { + Ensure.IsNotNullOrWhitespace(name); + + try + { + await _installFormulaExecutor.ExecuteAsync(new { name }).ConfigureAwait(false); + } + catch (ProcessExecutionFailedException e) + { + throw new BrewInstallFailedException(BrewInstallTargetKind.Formula, name, e.ErrorOutput, e.ExitCode); + } + } + + public async Task InstallCaskAsync(string token) + { + Ensure.IsNotNullOrWhitespace(token); + + try + { + await _installCaskExecutor.ExecuteAsync(new { token }).ConfigureAwait(false); + } + catch (ProcessExecutionFailedException e) + { + throw new BrewInstallFailedException(BrewInstallTargetKind.Cask, token, e.ErrorOutput, e.ExitCode); + } + } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Export/BrewExporter.cs b/source/CreativeCoders.MacOS.HomeBrew/Export/BrewExporter.cs new file mode 100644 index 0000000..28ec1d0 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Export/BrewExporter.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using CreativeCoders.Core; +using CreativeCoders.MacOS.HomeBrew.Models.Casks; +using CreativeCoders.MacOS.HomeBrew.Models.Export; +using CreativeCoders.MacOS.HomeBrew.Models.Formulae; + +namespace CreativeCoders.MacOS.HomeBrew.Export; + +/// +/// Default implementation. Reads the installed software via +/// and projects it onto the slim +/// used for the JSON export. +/// +public class BrewExporter(IBrewInstalledSoftware installedSoftware) : IBrewExporter +{ + private const string DefaultFormulaTap = "homebrew/core"; + + private const string DefaultCaskTap = "homebrew/cask"; + + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + private readonly IBrewInstalledSoftware _installedSoftware = Ensure.NotNull(installedSoftware); + + /// + public async Task CreateExportModelAsync(bool includeDependencies) + { + var installed = await _installedSoftware.GetInstalledSoftwareAsync().ConfigureAwait(false); + + return new BrewExportModel + { + Formulae = installed.Formulae + .Where(x => !x.IsInstalledAsDependency() || includeDependencies) + .Select(MapFormula) + .Where(x => !string.IsNullOrWhiteSpace(x.Name)) + .ToArray(), + Casks = installed.Casks + .Select(MapCask) + .Where(x => !string.IsNullOrWhiteSpace(x.Token)) + .ToArray() + }; + } + + /// + public async Task ExportToFileAsync(string filePath, bool includeDependencies) + { + Ensure.IsNotNullOrWhitespace(filePath); + + var exportModel = await CreateExportModelAsync(includeDependencies).ConfigureAwait(false); + + var directory = Path.GetDirectoryName(filePath); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + await using var stream = File.Create(filePath); + + await JsonSerializer.SerializeAsync(stream, exportModel, JsonOptions).ConfigureAwait(false); + } + + private static BrewExportFormulaModel MapFormula(BrewFormulaModel formula) + { + return new BrewExportFormulaModel + { + Name = formula.FullName ?? formula.Name ?? string.Empty, + Tap = NormalizeTap(formula.Tap, DefaultFormulaTap) + }; + } + + private static BrewExportCaskModel MapCask(BrewCaskModel cask) + { + return new BrewExportCaskModel + { + Token = cask.FullToken ?? cask.Token ?? string.Empty, + Tap = NormalizeTap(cask.Tap, DefaultCaskTap) + }; + } + + // Returns null when the tap matches the default tap so that the exported JSON stays slim. + private static string? NormalizeTap(string? tap, string defaultTap) + { + if (string.IsNullOrWhiteSpace(tap)) + { + return null; + } + + return string.Equals(tap, defaultTap, StringComparison.OrdinalIgnoreCase) + ? null + : tap; + } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Export/IBrewExporter.cs b/source/CreativeCoders.MacOS.HomeBrew/Export/IBrewExporter.cs new file mode 100644 index 0000000..e9eabe5 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Export/IBrewExporter.cs @@ -0,0 +1,29 @@ +using CreativeCoders.MacOS.HomeBrew.Models.Export; + +namespace CreativeCoders.MacOS.HomeBrew.Export; + +/// +/// Exports the locally installed Homebrew software to a serializable model or to a JSON file. +/// +public interface IBrewExporter +{ + /// + /// Builds a based on the currently installed Homebrew software. + /// + /// + /// to include formulae installed as dependencies; + /// otherwise, . + /// + /// A representing the installed software. + Task CreateExportModelAsync(bool includeDependencies); + + /// + /// Builds the export model and writes it as JSON to the file at . + /// + /// The absolute or relative path of the target file. + /// + /// to include formulae installed as dependencies; + /// otherwise, . + /// + Task ExportToFileAsync(string filePath, bool includeDependencies); +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs b/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs index 9543876..fe4908a 100644 --- a/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs +++ b/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs @@ -1,3 +1,5 @@ +using CreativeCoders.MacOS.HomeBrew.Export; +using CreativeCoders.MacOS.HomeBrew.Import; using CreativeCoders.ProcessUtils; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -13,6 +15,9 @@ public static IServiceCollection AddHomeBrew(this IServiceCollection services) services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/source/CreativeCoders.MacOS.HomeBrew/IBrewInstaller.cs b/source/CreativeCoders.MacOS.HomeBrew/IBrewInstaller.cs new file mode 100644 index 0000000..44e949e --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/IBrewInstaller.cs @@ -0,0 +1,16 @@ +namespace CreativeCoders.MacOS.HomeBrew; + +/// +/// Wraps the brew CLI calls needed to add taps and install formulae or casks. +/// +public interface IBrewInstaller +{ + /// Adds a Homebrew tap (brew tap <tap>). + Task TapAsync(string tap); + + /// Installs a formula (brew install <name>). + Task InstallFormulaAsync(string name); + + /// Installs a cask (brew install --cask <token>). + Task InstallCaskAsync(string token); +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportFailedException.cs b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportFailedException.cs new file mode 100644 index 0000000..9cf2b1a --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportFailedException.cs @@ -0,0 +1,18 @@ +namespace CreativeCoders.MacOS.HomeBrew.Import; + +/// +/// Aggregates all single-package failures that occurred during a Homebrew import. The import +/// itself runs through to completion; this exception is thrown at the very end when at least +/// one package failed to install. +/// +public class BrewImportFailedException : Exception +{ + public BrewImportFailedException(IReadOnlyCollection failures) + : base($"Brew import failed for {failures.Count} package(s)") + { + Failures = failures; + } + + /// Gets the individual install failures collected during the import. + public IReadOnlyCollection Failures { get; } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportProgress.cs b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportProgress.cs new file mode 100644 index 0000000..42ee8bb --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImportProgress.cs @@ -0,0 +1,36 @@ +namespace CreativeCoders.MacOS.HomeBrew.Import; + +/// Identifies the kind of operation being performed during an import step. +public enum BrewImportStep +{ + Tap, + InstallFormula, + InstallCask +} + +/// Identifies whether an import step is about to start, has succeeded, or has failed. +public enum BrewImportStepState +{ + Starting, + Succeeded, + Failed +} + +/// +/// Reports the current progress of a Homebrew import operation. Passed to +/// callbacks so callers can display live feedback. +/// +public class BrewImportProgress +{ + /// Gets the kind of operation being performed. + public required BrewImportStep Step { get; init; } + + /// Gets whether the step is starting, succeeded, or failed. + public required BrewImportStepState State { get; init; } + + /// Gets the tap, formula name, or cask token of the current step. + public required string Target { get; init; } + + /// Gets the failure details when is . + public BrewInstallFailedException? Error { get; init; } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImporter.cs b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImporter.cs new file mode 100644 index 0000000..3b4b07d --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewImporter.cs @@ -0,0 +1,132 @@ +using System.Text.Json; +using CreativeCoders.Core; +using CreativeCoders.MacOS.HomeBrew.Models.Export; + +namespace CreativeCoders.MacOS.HomeBrew.Import; + +/// +/// Default implementation. Delegates the actual brew calls to +/// an injected and orchestrates taps, formulae and casks. +/// +public class BrewImporter : IBrewImporter +{ + private readonly IBrewInstaller _installer; + + public BrewImporter(IBrewInstaller installer) + { + _installer = Ensure.NotNull(installer); + } + + public async Task ReadFileAsync(string filePath) + { + Ensure.IsNotNullOrWhitespace(filePath); + + await using var stream = File.OpenRead(filePath); + + var model = await JsonSerializer.DeserializeAsync(stream).ConfigureAwait(false); + + return model ?? new BrewExportModel(); + } + + public async Task ImportAsync(BrewExportModel exportModel, IProgress? progress = null) + { + Ensure.NotNull(exportModel); + + var failures = new List(); + + foreach (var tap in CollectDistinctTaps(exportModel)) + { + await TryRunAsync(BrewImportStep.Tap, tap, () => _installer.TapAsync(tap), failures, progress) + .ConfigureAwait(false); + } + + foreach (var formula in exportModel.Formulae) + { + if (string.IsNullOrWhiteSpace(formula.Name)) + { + continue; + } + + await TryRunAsync(BrewImportStep.InstallFormula, formula.Name, + () => _installer.InstallFormulaAsync(formula.Name), failures, progress).ConfigureAwait(false); + } + + foreach (var cask in exportModel.Casks) + { + if (string.IsNullOrWhiteSpace(cask.Token)) + { + continue; + } + + await TryRunAsync(BrewImportStep.InstallCask, cask.Token, + () => _installer.InstallCaskAsync(cask.Token), failures, progress).ConfigureAwait(false); + } + + if (failures.Count > 0) + { + throw new BrewImportFailedException(failures); + } + } + + public async Task ImportFromFileAsync(string filePath, IProgress? progress = null) + { + var exportModel = await ReadFileAsync(filePath).ConfigureAwait(false); + + await ImportAsync(exportModel, progress).ConfigureAwait(false); + } + + private static IEnumerable CollectDistinctTaps(BrewExportModel exportModel) + { + var taps = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var formula in exportModel.Formulae) + { + if (!string.IsNullOrWhiteSpace(formula.Tap)) + { + taps.Add(formula.Tap); + } + } + + foreach (var cask in exportModel.Casks) + { + if (!string.IsNullOrWhiteSpace(cask.Tap)) + { + taps.Add(cask.Tap); + } + } + + return taps; + } + + private static async Task TryRunAsync( + BrewImportStep step, + string target, + Func action, + List failures, + IProgress? progress) + { + progress?.Report(new BrewImportProgress + { + Step = step, State = BrewImportStepState.Starting, Target = target + }); + + try + { + await action().ConfigureAwait(false); + + progress?.Report(new BrewImportProgress + { + Step = step, State = BrewImportStepState.Succeeded, Target = target + }); + } + catch (BrewInstallFailedException e) + { + failures.Add(e); + + progress?.Report(new BrewImportProgress + { + Step = step, State = BrewImportStepState.Failed, Target = target, Error = e + }); + } + } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Import/BrewInstallFailedException.cs b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewInstallFailedException.cs new file mode 100644 index 0000000..6d38170 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Import/BrewInstallFailedException.cs @@ -0,0 +1,35 @@ +namespace CreativeCoders.MacOS.HomeBrew.Import; + +/// +/// Identifies which kind of brew operation failed. +/// +public enum BrewInstallTargetKind +{ + Tap, + Formula, + Cask +} + +/// +/// Thrown when a single brew tap or brew install call performed by the +/// fails. +/// +public class BrewInstallFailedException( + BrewInstallTargetKind kind, + string target, + string errorOutput, + int exitCode) + : Exception($"Brew {kind.ToString().ToLowerInvariant()} of '{target}' failed") +{ + /// Gets the kind of operation that failed. + public BrewInstallTargetKind Kind { get; } = kind; + + /// Gets the tap, formula name or cask token of the failed operation. + public string Target { get; } = target; + + /// Gets the standard-error output of the failed brew call. + public string ErrorOutput { get; } = errorOutput; + + /// Gets the exit code reported by the failed brew call. + public int ExitCode { get; } = exitCode; +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Import/IBrewImporter.cs b/source/CreativeCoders.MacOS.HomeBrew/Import/IBrewImporter.cs new file mode 100644 index 0000000..310aca6 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Import/IBrewImporter.cs @@ -0,0 +1,23 @@ +using CreativeCoders.MacOS.HomeBrew.Models.Export; + +namespace CreativeCoders.MacOS.HomeBrew.Import; + +/// +/// Reads a previously written export file and re-installs the contained Homebrew software via +/// . +/// +public interface IBrewImporter +{ + /// Deserializes the JSON export file at . + Task ReadFileAsync(string filePath); + + /// Installs every formula and cask listed in . + /// The model containing the software to install. + /// Optional progress callback that is notified before and after each step. + Task ImportAsync(BrewExportModel exportModel, IProgress? progress = null); + + /// Convenience: followed by . + /// Path to the JSON export file. + /// Optional progress callback that is notified before and after each step. + Task ImportFromFileAsync(string filePath, IProgress? progress = null); +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewUninstallModel.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewUninstallModel.cs index 9056243..6b9341a 100644 --- a/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewUninstallModel.cs +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewUninstallModel.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace CreativeCoders.MacOS.HomeBrew.Models.Casks; @@ -9,5 +10,30 @@ public class BrewUninstallModel { /// Gets or sets the bundle identifier(s) to quit before uninstall. [JsonPropertyName("quit")] - public string? Quit { get; set; } + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Quit { get; set; } + + /// Gets or sets the package identifier(s) to forget via pkgutil. + [JsonPropertyName("pkgutil")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Pkgutil { get; set; } + + /// Gets or sets the launchctl service(s) to remove. + [JsonPropertyName("launchctl")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Launchctl { get; set; } + + /// Gets or sets the file(s) to delete. + [JsonPropertyName("delete")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Delete { get; set; } + + /// Gets or sets the directories to remove. + [JsonPropertyName("rmdir")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Rmdir { get; set; } + + /// Gets or sets a script to execute during uninstall. + [JsonPropertyName("script")] + public JsonNode? Script { get; set; } } diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewZapModel.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewZapModel.cs index 87014d1..193128d 100644 --- a/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewZapModel.cs +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/BrewZapModel.cs @@ -9,5 +9,26 @@ public class BrewZapModel { /// Gets or sets items that should be moved to trash when zapping. [JsonPropertyName("trash")] + [JsonConverter(typeof(SingleOrArrayConverter))] public string[]? Trash { get; set; } + + /// Gets or sets directories to remove when zapping. + [JsonPropertyName("rmdir")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Rmdir { get; set; } + + /// Gets or sets package identifiers to forget via pkgutil when zapping. + [JsonPropertyName("pkgutil")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Pkgutil { get; set; } + + /// Gets or sets files to delete when zapping. + [JsonPropertyName("delete")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Delete { get; set; } + + /// Gets or sets launchctl services to remove when zapping. + [JsonPropertyName("launchctl")] + [JsonConverter(typeof(SingleOrArrayConverter))] + public string[]? Launchctl { get; set; } } diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/SingleOrArrayConverter.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/SingleOrArrayConverter.cs new file mode 100644 index 0000000..dcfd3d5 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Casks/SingleOrArrayConverter.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CreativeCoders.MacOS.HomeBrew.Models.Casks; + +/// +/// Deserializes a JSON value that may be either a single string or an array of strings into string[]. +/// Homebrew's JSON output uses both forms interchangeably for fields like trash, quit, etc. +/// +public class SingleOrArrayConverter : JsonConverter +{ + /// + public override string[]? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + + case JsonTokenType.String: + return [reader.GetString()!]; + + case JsonTokenType.StartArray: + var items = new List(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType == JsonTokenType.String) + { + items.Add(reader.GetString()!); + } + else + { + reader.Skip(); + } + } + + return items.ToArray(); + + default: + reader.Skip(); + return null; + } + } + + /// + public override void Write(Utf8JsonWriter writer, string[]? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartArray(); + + foreach (var item in value) + { + writer.WriteStringValue(item); + } + + writer.WriteEndArray(); + } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportCaskModel.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportCaskModel.cs new file mode 100644 index 0000000..e00a463 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportCaskModel.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace CreativeCoders.MacOS.HomeBrew.Models.Export; + +/// +/// Represents a single Homebrew cask entry inside an export file. +/// +[UsedImplicitly] +public class BrewExportCaskModel +{ + /// Gets or sets the cask token (preferably the full token including the tap). + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + /// + /// Gets or sets the tap the cask belongs to. null when the cask resides in the + /// default cask tap (homebrew/cask). + /// + [JsonPropertyName("tap")] + public string? Tap { get; set; } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportFormulaModel.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportFormulaModel.cs new file mode 100644 index 0000000..452c7c2 --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportFormulaModel.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace CreativeCoders.MacOS.HomeBrew.Models.Export; + +/// +/// Represents a single Homebrew formula entry inside an export file. +/// +[UsedImplicitly] +public class BrewExportFormulaModel +{ + /// Gets or sets the formula name (preferably the full name including the tap). + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the tap the formula belongs to. null when the formula resides in the + /// default core tap (homebrew/core). + /// + [JsonPropertyName("tap")] + public string? Tap { get; set; } +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportModel.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportModel.cs new file mode 100644 index 0000000..ac3f07a --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Export/BrewExportModel.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace CreativeCoders.MacOS.HomeBrew.Models.Export; + +/// +/// Root model that represents the contents of a Homebrew export file. Holds the list of +/// installed formulae and casks that should be re-installed on import. +/// +[UsedImplicitly] +public class BrewExportModel +{ + /// Gets or sets the formulae to be exported / installed. + [JsonPropertyName("formulae")] + public BrewExportFormulaModel[] Formulae { get; set; } = []; + + /// Gets or sets the casks to be exported / installed. + [JsonPropertyName("casks")] + public BrewExportCaskModel[] Casks { get; set; } = []; +} diff --git a/source/CreativeCoders.MacOS.HomeBrew/Models/Formulae/BrewFormulaModelExtensions.cs b/source/CreativeCoders.MacOS.HomeBrew/Models/Formulae/BrewFormulaModelExtensions.cs new file mode 100644 index 0000000..e8e2d5a --- /dev/null +++ b/source/CreativeCoders.MacOS.HomeBrew/Models/Formulae/BrewFormulaModelExtensions.cs @@ -0,0 +1,20 @@ +namespace CreativeCoders.MacOS.HomeBrew.Models.Formulae; + +/// +/// Provides extension methods for . +/// +public static class BrewFormulaModelExtensions +{ + /// + /// Determines whether the formula is installed as a dependency. + /// + /// The formula to check. + /// + /// if the formula is installed as a dependency; + /// otherwise, . + /// + public static bool IsInstalledAsDependency(this BrewFormulaModel formula) + { + return formula.Installed?.Any(x => x.InstalledAsDependency == true) == true; + } +} diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportCommand.cs new file mode 100644 index 0000000..c127724 --- /dev/null +++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportCommand.cs @@ -0,0 +1,38 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.MacOS.HomeBrew.Export; +using JetBrains.Annotations; +using Spectre.Console; + +namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.Export; + +[UsedImplicitly] +[CliCommand([HomebrewCommandGroup.Name, "export"], + Description = "Exports installed Homebrew software to a JSON file")] + +/// +/// Exports the installed Homebrew software to a JSON file. +/// +public class BrewExportCommand(IAnsiConsole ansiConsole, IBrewExporter brewExporter) + : ICliCommand +{ + private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole); + + private readonly IBrewExporter _brewExporter = Ensure.NotNull(brewExporter); + + /// + /// Exports the installed Homebrew formulae and casks to the file path specified in . + /// + /// The export options containing the output path and dependency filter. + /// A indicating success or failure. + public async Task ExecuteAsync(BrewExportOptions options) + { + _ansiConsole.Write($"Exporting installed Homebrew software to '{options.OutputPath}' ... "); + + await _brewExporter.ExportToFileAsync(options.OutputPath, options.IncludeDependencies).ConfigureAwait(false); + + _ansiConsole.MarkupLine("[green]Done[/]"); + + return CommandResult.Success; + } +} diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportOptions.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportOptions.cs new file mode 100644 index 0000000..7997468 --- /dev/null +++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Export/BrewExportOptions.cs @@ -0,0 +1,27 @@ +using CreativeCoders.SysConsole.Cli.Parsing; +using JetBrains.Annotations; + +namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.Export; + +[UsedImplicitly] + +/// +/// Represents the command-line options for the Homebrew export command. +/// +public class BrewExportOptions +{ + /// Gets or sets the file path to write the export JSON to. + [OptionParameter('o', "output", HelpText = "The file path to export the installed software to", + IsRequired = true)] + public string OutputPath { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether formulae installed as dependencies are included in the export. + /// + /// + /// to include dependencies; otherwise, . + /// The default is . + /// + [OptionParameter('d', "dependency", HelpText = "Include dependencies in the export")] + public bool IncludeDependencies { get; set; } +} diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportCommand.cs new file mode 100644 index 0000000..c39d83d --- /dev/null +++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportCommand.cs @@ -0,0 +1,81 @@ +using CreativeCoders.Cli.Core; +using CreativeCoders.Core; +using CreativeCoders.MacOS.HomeBrew.Import; +using CreativeCoders.SysConsole.Core; +using JetBrains.Annotations; +using Spectre.Console; + +namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.Import; + +[UsedImplicitly] +[CliCommand([HomebrewCommandGroup.Name, "import"], + Description = "Imports and installs Homebrew software from a JSON file")] +public class BrewImportCommand(IAnsiConsole ansiConsole, IBrewImporter brewImporter) + : ICliCommand +{ + private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole); + + private readonly IBrewImporter _brewImporter = Ensure.NotNull(brewImporter); + + public async Task ExecuteAsync(BrewImportOptions options) + { + if (!File.Exists(options.InputPath)) + { + _ansiConsole.MarkupLine($"[red]File not found: {options.InputPath}[/]"); + + return MacSynkkerCliExitCodes.FileNotFound; + } + + _ansiConsole.MarkupLine($"Importing Homebrew software from '{options.InputPath}'"); + _ansiConsole.WriteLine(); + + var progress = new Progress(OnProgress); + + try + { + await _brewImporter.ImportFromFileAsync(options.InputPath, progress).ConfigureAwait(false); + + _ansiConsole.WriteLine(); + _ansiConsole.MarkupLine("[green]Import completed successfully[/]"); + } + catch (BrewImportFailedException e) + { + _ansiConsole.WriteLine(); + _ansiConsole.MarkupLine($"[red]Import completed with {e.Failures.Count} error(s)[/]"); + } + + return CommandResult.Success; + } + + private void OnProgress(BrewImportProgress p) + { + switch (p.State) + { + case BrewImportStepState.Starting: + _ansiConsole.Write($"{GetStepLabel(p.Step)} '{p.Target}' ... "); + break; + + case BrewImportStepState.Succeeded: + _ansiConsole.MarkupLine("[green]Done[/]"); + break; + + case BrewImportStepState.Failed: + _ansiConsole.MarkupLine("[red]Failed[/]"); + + if (p.Error is not null) + { + _ansiConsole.WriteLine(p.Error.ErrorOutput); + } + + break; + } + } + + private static string GetStepLabel(BrewImportStep step) => step switch + { + BrewImportStep.Tap => "Tapping", + BrewImportStep.InstallFormula => "Installing formula", + BrewImportStep.InstallCask => "Installing cask", + _ => "Processing" + }; +} diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportOptions.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportOptions.cs new file mode 100644 index 0000000..28094c7 --- /dev/null +++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/Import/BrewImportOptions.cs @@ -0,0 +1,12 @@ +using CreativeCoders.SysConsole.Cli.Parsing; +using JetBrains.Annotations; + +namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.Import; + +[UsedImplicitly] +public class BrewImportOptions +{ + [OptionParameter('i', "input", HelpText = "The file path to import the Homebrew software from", + IsRequired = true)] + public string InputPath { get; set; } = string.Empty; +} diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs index 9620654..20fbf56 100644 --- a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs +++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs @@ -11,6 +11,10 @@ namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.List; [UsedImplicitly] [CliCommand([HomebrewCommandGroup.Name, "list"], Description = "Shows Homebrew installed software")] + +/// +/// Lists the installed Homebrew formulae and casks on the console. +/// public class BrewListInstalledSoftwareCommand(IAnsiConsole ansiConsole, IBrewInstalledSoftware brewInstalledSoftware) : ICliCommand { @@ -18,6 +22,11 @@ public class BrewListInstalledSoftwareCommand(IAnsiConsole ansiConsole, IBrewIns private readonly IBrewInstalledSoftware _brewInstalledSoftware = Ensure.NotNull(brewInstalledSoftware); + /// + /// Retrieves and displays the installed Homebrew software based on the specified . + /// + /// The listing options controlling output format and filters. + /// A indicating success or failure. public async Task ExecuteAsync(BrewListInstalledSoftwareOptions options) { _ansiConsole.WriteLine("List installed HomeBrew software"); @@ -44,6 +53,13 @@ public async Task ExecuteAsync(BrewListInstalledSoftwareOptions o return CommandResult.Success; } + /// + /// Prints the installed formulae to the console. + /// + /// The formulae to display. + /// + /// to render a table; otherwise, for a simple list. + /// private void PrintFormulae(BrewFormulaModel[] installedSoftwareFormulae, bool optionsShowAsListView) { _ansiConsole.WriteLines("Installed HomeBrew formulae:", string.Empty); @@ -54,7 +70,10 @@ private void PrintFormulae(BrewFormulaModel[] installedSoftwareFormulae, bool op new TableColumnDef(x => x.FullName, "FullName"), new TableColumnDef(x => string.Join(",", x.Installed?.Select(y => y.Version) ?? []), "Installed"), - new TableColumnDef(x => x.Versions?.Stable, "Available") + new TableColumnDef(x => x.Versions?.Stable, "Available"), + new TableColumnDef( + x => x.IsInstalledAsDependency(), + "Installed as dependency") ]); } else @@ -67,6 +86,13 @@ private void PrintFormulae(BrewFormulaModel[] installedSoftwareFormulae, bool op } } + /// + /// Prints the installed casks to the console. + /// + /// The casks to display. + /// + /// to render a table; otherwise, for a simple list. + /// private void PrintCasks(BrewCaskModel[] installedSoftwareCasks, bool optionsShowAsListView) { _ansiConsole.WriteLines("Installed HomeBrew casks:", string.Empty); @@ -92,6 +118,11 @@ private void PrintCasks(BrewCaskModel[] installedSoftwareCasks, bool optionsShow } } + /// + /// Extracts the primary version number from a cask version string that may contain multiple comma-separated parts. + /// + /// The raw version string from the cask model. + /// The extracted version, or an empty string if is empty. private static string ExtractCaskVersion(string? versionString) { if (string.IsNullOrWhiteSpace(versionString)) diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..c1fdbb9 --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,2 @@ +[*.{cs,vb}] +configure_await_analysis_mode = disabled diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInfoTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInfoTests.cs new file mode 100644 index 0000000..06a4b00 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInfoTests.cs @@ -0,0 +1,125 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class BrewInfoTests +{ + [Fact] + public async Task IsInstalledAsync_WhenOutputStartsWithHomebrew_ReturnsTrue() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns("Homebrew 4.3.1\nHomebrew/homebrew-core (git revision abc)"); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.IsInstalledAsync(); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData("not brew")] + [InlineData("")] + public async Task IsInstalledAsync_WhenOutputDoesNotStartWithHomebrew_ReturnsFalse(string output) + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(output); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.IsInstalledAsync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task IsInstalledAsync_WhenOutputIsNull_ReturnsFalse() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(Task.FromResult(null)); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.IsInstalledAsync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task GetVersionAsync_WhenOutputIsStandardFormat_ReturnsVersionToken() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns("Homebrew 4.3.1"); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.GetVersionAsync(); + + // Assert + result.Should().Be("4.3.1"); + } + + [Fact] + public async Task GetVersionAsync_WhenOutputIsNull_ReturnsEmpty() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(Task.FromResult(null)); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.GetVersionAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetVersionAsync_WhenOutputHasNoSecondToken_ReturnsEmpty() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns("Homebrew"); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.GetVersionAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetVersionAsync_WhenOutputIsEmptyString_ReturnsEmpty() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(string.Empty); + var sut = new BrewInfo(builder); + + // Act + var result = await sut.GetVersionAsync(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void Ctor_WhenBuilderIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewInfo(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledModelExtensionsTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledModelExtensionsTests.cs new file mode 100644 index 0000000..ff54d3b --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledModelExtensionsTests.cs @@ -0,0 +1,303 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Models; +using CreativeCoders.MacOS.HomeBrew.Models.Casks; +using CreativeCoders.MacOS.HomeBrew.Models.Formulae; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class BrewInstalledModelExtensionsTests +{ + [Fact] + public void GetOutdatedCasks_OnlyReturnsCasksWhereInstalledDiffersFromVersion() + { + // Arrange + var model = new BrewInstalledModel + { + Casks = + [ + new BrewCaskModel { Token = "a", Installed = "1.0", Version = "1.0" }, + new BrewCaskModel { Token = "b", Installed = "1.0", Version = "2.0" }, + new BrewCaskModel { Token = "c", Installed = null, Version = "1.0" } + ] + }; + + // Act + var result = model.GetOutdatedCasks(); + + // Assert + result.Select(x => x.Token).Should().BeEquivalentTo("b", "c"); + } + + [Fact] + public void GetCasks_WhenOnlyOutdatedTrue_ReturnsOutdatedOnly() + { + // Arrange + var model = new BrewInstalledModel + { + Casks = + [ + new BrewCaskModel { Token = "a", Installed = "1.0", Version = "1.0" }, + new BrewCaskModel { Token = "b", Installed = "1.0", Version = "2.0" } + ] + }; + + // Act + var result = model.GetCasks(onlyOutdated: true); + + // Assert + result.Should().HaveCount(1); + result[0].Token.Should().Be("b"); + } + + [Fact] + public void GetCasks_WhenOnlyOutdatedFalse_ReturnsAll() + { + // Arrange + var model = new BrewInstalledModel + { + Casks = + [ + new BrewCaskModel { Token = "a", Installed = "1.0", Version = "1.0" }, + new BrewCaskModel { Token = "b", Installed = "1.0", Version = "2.0" } + ] + }; + + // Act + var result = model.GetCasks(onlyOutdated: false); + + // Assert + result.Should().HaveCount(2); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledVersionMatchesStable_IsNotOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "1.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0" }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledVersionIsRevisionOfStable_IsNotOutdated() + { + // Arrange - Homebrew formats rebuilds as "_" (e.g., "1.0_1") + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "1.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0_1" }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledVersionDiffers_IsOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0" }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().ContainSingle().Which.Name.Should().Be("f1"); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledIsNull_IsNotOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = null + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledHasMultipleAndOneDiffers_IsOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = + [ + new BrewInstalledFormulaModel { Version = "2.0" }, + new BrewInstalledFormulaModel { Version = "1.0" } + ] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().ContainSingle(); + } + + [Fact] + public void GetOutdatedFormulae_WhenBothVersionsNull_IsNotOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = null }, + Installed = [new BrewInstalledFormulaModel { Version = null }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetOutdatedFormulae_WhenOnlyInstalledVersionIsNull_IsOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = [new BrewInstalledFormulaModel { Version = null }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().ContainSingle(); + } + + [Fact] + public void GetFormulae_WhenOnlyOutdatedTrue_ReturnsOutdatedOnly() + { + // Arrange + var outdated = new BrewFormulaModel + { + Name = "outdated", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0" }] + }; + var current = new BrewFormulaModel + { + Name = "current", + Versions = new BrewVersionsModel { Stable = "1.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0" }] + }; + var model = new BrewInstalledModel { Formulae = [outdated, current] }; + + // Act + var result = model.GetFormulae(onlyOutdated: true); + + // Assert + result.Should().ContainSingle().Which.Name.Should().Be("outdated"); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledArrayIsEmpty_IsNotOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "2.0" }, + Installed = [] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetOutdatedFormulae_WhenInstalledIsPrefixButWithoutUnderscore_IsOutdated() + { + // Arrange - Only "_..." should be treated as equivalent; "1.01" must not match "1.0" + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = "1.0" }, + Installed = [new BrewInstalledFormulaModel { Version = "1.01" }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().ContainSingle(); + } + + [Fact] + public void GetOutdatedFormulae_WhenOnlyStableVersionIsNull_IsOutdated() + { + // Arrange + var formula = new BrewFormulaModel + { + Name = "f1", + Versions = new BrewVersionsModel { Stable = null }, + Installed = [new BrewInstalledFormulaModel { Version = "1.0" }] + }; + var model = new BrewInstalledModel { Formulae = [formula] }; + + // Act + var result = model.GetOutdatedFormulae(); + + // Assert + result.Should().ContainSingle(); + } + + [Fact] + public void GetOutdatedCasks_WhenCasksAreEmpty_ReturnsEmptyArray() + { + // Arrange + var model = new BrewInstalledModel(); + + // Act + var result = model.GetOutdatedCasks(); + + // Assert + result.Should().BeEmpty(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledSoftwareTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledSoftwareTests.cs new file mode 100644 index 0000000..6833ae9 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstalledSoftwareTests.cs @@ -0,0 +1,58 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Models; +using CreativeCoders.MacOS.HomeBrew.Models.Casks; +using CreativeCoders.MacOS.HomeBrew.Models.Formulae; +using CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class BrewInstalledSoftwareTests +{ + [Fact] + public async Task GetInstalledSoftwareAsync_WhenExecutorReturnsModel_ReturnsSameModel() + { + // Arrange + var model = new BrewInstalledModel + { + Casks = [new BrewCaskModel { Token = "firefox" }], + Formulae = [new BrewFormulaModel { Name = "wget" }] + }; + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(model); + var sut = new BrewInstalledSoftware(builder); + + // Act + var result = await sut.GetInstalledSoftwareAsync(); + + // Assert + result.Should().BeSameAs(model); + } + + [Fact] + public async Task GetInstalledSoftwareAsync_WhenExecutorReturnsNull_ReturnsEmptyModel() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync()).Returns(Task.FromResult(null)); + var sut = new BrewInstalledSoftware(builder); + + // Act + var result = await sut.GetInstalledSoftwareAsync(); + + // Assert + result.Should().NotBeNull(); + result.Casks.Should().BeEmpty(); + result.Formulae.Should().BeEmpty(); + } + + [Fact] + public void Ctor_WhenBuilderIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewInstalledSoftware(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstallerTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstallerTests.cs new file mode 100644 index 0000000..e731198 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewInstallerTests.cs @@ -0,0 +1,176 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Import; +using CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class BrewInstallerTests +{ + [Fact] + public async Task TapAsync_WhenExecutorSucceeds_InvokesBrewTap() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewInstaller(builder); + + // Act + await sut.TapAsync("homebrew/cask"); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["tap"] == "homebrew/cask"))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task TapAsync_WhenExecutionFails_ThrowsBrewInstallFailedExceptionWithTapKind() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync(A>._)) + .Throws(new ProcessExecutionFailedException(2, "err", "std")); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.TapAsync("bad/tap"); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Kind.Should().Be(BrewInstallTargetKind.Tap); + ex.Which.Target.Should().Be("bad/tap"); + ex.Which.ErrorOutput.Should().Be("err"); + ex.Which.ExitCode.Should().Be(2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task TapAsync_WhenTapIsNullOrWhitespace_Throws(string? tap) + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out _); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.TapAsync(tap!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task InstallFormulaAsync_WhenExecutorSucceeds_InvokesBrewInstall() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewInstaller(builder); + + // Act + await sut.InstallFormulaAsync("wget"); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["name"] == "wget"))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task InstallFormulaAsync_WhenExecutionFails_ThrowsWithFormulaKind() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync(A>._)) + .Throws(new ProcessExecutionFailedException(1, "boom", "std")); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.InstallFormulaAsync("wget"); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Kind.Should().Be(BrewInstallTargetKind.Formula); + ex.Which.Target.Should().Be("wget"); + } + + [Fact] + public async Task InstallCaskAsync_WhenExecutorSucceeds_InvokesBrewInstallCask() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewInstaller(builder); + + // Act + await sut.InstallCaskAsync("firefox"); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["token"] == "firefox"))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task InstallCaskAsync_WhenExecutionFails_ThrowsWithCaskKind() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync(A>._)) + .Throws(new ProcessExecutionFailedException(3, "err", "std")); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.InstallCaskAsync("firefox"); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Kind.Should().Be(BrewInstallTargetKind.Cask); + ex.Which.Target.Should().Be("firefox"); + ex.Which.ExitCode.Should().Be(3); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task InstallFormulaAsync_WhenNameIsNullOrWhitespace_Throws(string? name) + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out _); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.InstallFormulaAsync(name!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task InstallCaskAsync_WhenTokenIsNullOrWhitespace_Throws(string? token) + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out _); + var sut = new BrewInstaller(builder); + + // Act + var act = () => sut.InstallCaskAsync(token!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Ctor_WhenBuilderIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewInstaller(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewUpgraderTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewUpgraderTests.cs new file mode 100644 index 0000000..5a6c0b2 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/BrewUpgraderTests.cs @@ -0,0 +1,122 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers; +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class BrewUpgraderTests +{ + [Fact] + public async Task UpgradeAsync_WithoutForce_CallsExecutorWithEmptyArgs() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewUpgrader(builder); + + // Act + await sut.UpgradeAsync(); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["appName"] == "" && (string?)d["force"] == ""))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpgradeAsync_WithForce_SetsForceFlag() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewUpgrader(builder); + + // Act + await sut.UpgradeAsync(force: true); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["force"] == "-f"))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpgradeAsync_WhenExecutionFails_ThrowsBrewUpgradeException() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync(A>._)) + .Throws(new ProcessExecutionFailedException(5, "err", "std")); + var sut = new BrewUpgrader(builder); + + // Act + var act = () => sut.UpgradeAsync(); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Should().NotBeOfType(); + ex.Which.ErrorOutput.Should().Be("err"); + ex.Which.ExitCode.Should().Be(5); + } + + [Fact] + public async Task UpgradeSoftwareAsync_PassesAppNameToExecutor() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewUpgrader(builder); + + // Act + await sut.UpgradeSoftwareAsync("wget"); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["appName"] == "wget" && (string?)d["force"] == ""))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpgradeSoftwareAsync_WithForce_SetsForceFlag() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + var sut = new BrewUpgrader(builder); + + // Act + await sut.UpgradeSoftwareAsync("wget", force: true); + + // Assert + A.CallTo(() => executor.ExecuteAsync(A>.That + .Matches(d => (string?)d["appName"] == "wget" && (string?)d["force"] == "-f"))) + .MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task UpgradeSoftwareAsync_WhenExecutionFails_ThrowsBrewUpgradeFailedException() + { + // Arrange + var builder = FakeProcessExecutorBuilder.Create(out var executor); + A.CallTo(() => executor.ExecuteAsync(A>._)) + .Throws(new ProcessExecutionFailedException(7, "oops", "std")); + var sut = new BrewUpgrader(builder); + + // Act + var act = () => sut.UpgradeSoftwareAsync("wget"); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.AppName.Should().Be("wget"); + ex.Which.ErrorOutput.Should().Be("oops"); + ex.Which.ExitCode.Should().Be(7); + ex.Which.Message.Should().Contain("wget"); + } + + [Fact] + public void Ctor_WhenBuilderIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewUpgrader(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/CreativeCoders.MacOS.HomeBrew.Tests.csproj b/tests/CreativeCoders.MacOS.HomeBrew.Tests/CreativeCoders.MacOS.HomeBrew.Tests.csproj new file mode 100644 index 0000000..1b1925c --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/CreativeCoders.MacOS.HomeBrew.Tests.csproj @@ -0,0 +1,30 @@ + + + + enable + enable + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/ExceptionTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/ExceptionTests.cs new file mode 100644 index 0000000..b4a0c21 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/ExceptionTests.cs @@ -0,0 +1,70 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Import; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class ExceptionTests +{ + [Fact] + public void BrewUpgradeException_StoresMessageErrorOutputAndExitCode() + { + // Arrange + Act + var ex = new BrewUpgradeException("msg", "err", 42); + + // Assert + ex.Message.Should().Be("msg"); + ex.ErrorOutput.Should().Be("err"); + ex.ExitCode.Should().Be(42); + } + + [Fact] + public void BrewUpgradeFailedException_FormatsMessageWithAppName() + { + // Arrange + Act + var ex = new BrewUpgradeFailedException("wget", "err", 1); + + // Assert + ex.AppName.Should().Be("wget"); + ex.Message.Should().Contain("wget"); + ex.ErrorOutput.Should().Be("err"); + ex.ExitCode.Should().Be(1); + ex.Should().BeAssignableTo(); + } + + [Theory] + [InlineData(BrewInstallTargetKind.Tap, "tap")] + [InlineData(BrewInstallTargetKind.Formula, "formula")] + [InlineData(BrewInstallTargetKind.Cask, "cask")] + public void BrewInstallFailedException_MessageContainsLowercaseKindAndTarget( + BrewInstallTargetKind kind, string expectedKindText) + { + // Arrange + Act + var ex = new BrewInstallFailedException(kind, "my-target", "err", 3); + + // Assert + ex.Kind.Should().Be(kind); + ex.Target.Should().Be("my-target"); + ex.ErrorOutput.Should().Be("err"); + ex.ExitCode.Should().Be(3); + ex.Message.Should().Contain(expectedKindText); + ex.Message.Should().Contain("my-target"); + } + + [Fact] + public void BrewImportFailedException_AggregatesFailures() + { + // Arrange + var failures = new[] + { + new BrewInstallFailedException(BrewInstallTargetKind.Formula, "a", "e", 1), + new BrewInstallFailedException(BrewInstallTargetKind.Cask, "b", "e", 1) + }; + + // Act + var ex = new BrewImportFailedException(failures); + + // Assert + ex.Failures.Should().BeEquivalentTo(failures); + ex.Message.Should().Contain("2"); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Export/BrewExporterTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Export/BrewExporterTests.cs new file mode 100644 index 0000000..6fe3559 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Export/BrewExporterTests.cs @@ -0,0 +1,459 @@ +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Export; +using CreativeCoders.MacOS.HomeBrew.Models; +using CreativeCoders.MacOS.HomeBrew.Models.Casks; +using CreativeCoders.MacOS.HomeBrew.Models.Export; +using CreativeCoders.MacOS.HomeBrew.Models.Formulae; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests.Export; + +public class BrewExporterTests +{ + [Fact] + public async Task CreateExportModelAsync_MapsFormulaeUsingFullNameWhenAvailable() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel { Name = "wget", FullName = "homebrew/core/wget", Tap = "homebrew/core" } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae.Should().ContainSingle(); + model.Formulae[0].Name.Should().Be("homebrew/core/wget"); + // Default formula tap is stripped to keep JSON slim + model.Formulae[0].Tap.Should().BeNull(); + } + + [Fact] + public async Task CreateExportModelAsync_FallsBackToNameWhenFullNameMissing() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = [new BrewFormulaModel { Name = "wget", FullName = null, Tap = "custom/tap" }] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae[0].Name.Should().Be("wget"); + model.Formulae[0].Tap.Should().Be("custom/tap"); + } + + [Fact] + public async Task CreateExportModelAsync_SkipsFormulaeWithEmptyName() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel { Name = null, FullName = null }, + new BrewFormulaModel { Name = " ", FullName = null }, + new BrewFormulaModel { Name = "wget", FullName = null } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae.Should().ContainSingle().Which.Name.Should().Be("wget"); + } + + [Fact] + public async Task CreateExportModelAsync_MapsCasksUsingFullTokenWhenAvailable() + { + // Arrange + var installed = new BrewInstalledModel + { + Casks = + [ + new BrewCaskModel { Token = "firefox", FullToken = "homebrew/cask/firefox", Tap = "HOMEBREW/CASK" } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Casks.Should().ContainSingle(); + model.Casks[0].Token.Should().Be("homebrew/cask/firefox"); + // Default cask tap is stripped regardless of casing + model.Casks[0].Tap.Should().BeNull(); + } + + [Fact] + public async Task CreateExportModelAsync_SkipsCasksWithEmptyToken() + { + // Arrange + var installed = new BrewInstalledModel + { + Casks = + [ + new BrewCaskModel { Token = null, FullToken = null }, + new BrewCaskModel { Token = "firefox", FullToken = null, Tap = null } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Casks.Should().ContainSingle().Which.Token.Should().Be("firefox"); + } + + [Fact] + public async Task CreateExportModelAsync_WhenCaskTapIsWhitespace_SetsTapNull() + { + // Arrange + var installed = new BrewInstalledModel + { + Casks = [new BrewCaskModel { Token = "firefox", FullToken = "firefox", Tap = " " }] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Casks[0].Tap.Should().BeNull(); + } + + [Fact] + public async Task CreateExportModelAsync_WhenIncludeDependenciesFalse_ExcludesDependencyFormulae() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "wget", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = false }] + }, + new BrewFormulaModel + { + Name = "openssl", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae.Should().ContainSingle().Which.Name.Should().Be("wget"); + } + + [Fact] + public async Task CreateExportModelAsync_WhenIncludeDependenciesTrue_IncludesDependencyFormulae() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "wget", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = false }] + }, + new BrewFormulaModel + { + Name = "openssl", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(true); + + // Assert + model.Formulae.Should().HaveCount(2); + model.Formulae.Select(f => f.Name).Should().BeEquivalentTo("wget", "openssl"); + } + + [Fact] + public async Task CreateExportModelAsync_WhenFormulaHasNoInstalledInfo_IncludedRegardlessOfFlag() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel { Name = "wget", Installed = null }, + new BrewFormulaModel { Name = "curl", Installed = [] } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae.Should().HaveCount(2); + } + + [Fact] + public async Task CreateExportModelAsync_WhenFormulaHasMixedInstalledEntries_TreatedAsDependency() + { + // Arrange – one installed entry is a dependency, the other is not + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "openssl", + Installed = + [ + new BrewInstalledFormulaModel { InstalledAsDependency = false }, + new BrewInstalledFormulaModel { InstalledAsDependency = true } + ] + } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert – IsInstalledAsDependency returns true if ANY entry is a dependency + model.Formulae.Should().BeEmpty(); + } + + [Fact] + public async Task CreateExportModelAsync_WhenAllFormulaeAreDependencies_IncludeDependenciesTrue_ReturnsAll() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "openssl", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + }, + new BrewFormulaModel + { + Name = "zlib", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(true); + + // Assert + model.Formulae.Should().HaveCount(2); + } + + [Fact] + public async Task CreateExportModelAsync_WhenIncludeDependenciesFalse_DoesNotAffectCasks() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "openssl", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + } + ], + Casks = [new BrewCaskModel { Token = "firefox" }] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + + // Act + var model = await sut.CreateExportModelAsync(false); + + // Assert + model.Formulae.Should().BeEmpty(); + model.Casks.Should().ContainSingle().Which.Token.Should().Be("firefox"); + } + + [Fact] + public async Task ExportToFileAsync_PassesIncludeDependenciesToModel() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = + [ + new BrewFormulaModel + { + Name = "wget", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = false }] + }, + new BrewFormulaModel + { + Name = "openssl", + Installed = [new BrewInstalledFormulaModel { InstalledAsDependency = true }] + } + ] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + var filePath = Path.Combine(Path.GetTempPath(), $"brew-export-{Guid.NewGuid():N}.json"); + + try + { + // Act + await sut.ExportToFileAsync(filePath, true); + + // Assert + var content = await File.ReadAllTextAsync(filePath); + var roundtrip = JsonSerializer.Deserialize(content); + roundtrip.Should().NotBeNull(); + roundtrip!.Formulae.Should().HaveCount(2); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Fact] + public async Task ExportToFileAsync_WritesJsonWithFormulaeAndCasks() + { + // Arrange + var installed = new BrewInstalledModel + { + Formulae = [new BrewFormulaModel { Name = "wget", Tap = "homebrew/core" }], + Casks = [new BrewCaskModel { Token = "firefox", Tap = "homebrew/cask" }] + }; + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(installed); + var sut = new BrewExporter(software); + var filePath = Path.Combine(Path.GetTempPath(), $"brew-export-{Guid.NewGuid():N}.json"); + + try + { + // Act + await sut.ExportToFileAsync(filePath, false); + + // Assert + File.Exists(filePath).Should().BeTrue(); + var content = await File.ReadAllTextAsync(filePath); + var roundtrip = JsonSerializer.Deserialize(content); + roundtrip.Should().NotBeNull(); + roundtrip.Formulae.Should().ContainSingle().Which.Name.Should().Be("wget"); + roundtrip.Casks.Should().ContainSingle().Which.Token.Should().Be("firefox"); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Fact] + public async Task ExportToFileAsync_CreatesMissingDirectory() + { + // Arrange + var software = A.Fake(); + A.CallTo(() => software.GetInstalledSoftwareAsync()).Returns(new BrewInstalledModel()); + var sut = new BrewExporter(software); + var dir = Path.Combine(Path.GetTempPath(), $"brew-export-dir-{Guid.NewGuid():N}"); + var filePath = Path.Combine(dir, "nested", "export.json"); + + try + { + // Act + await sut.ExportToFileAsync(filePath, false); + + // Assert + File.Exists(filePath).Should().BeTrue(); + } + finally + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ExportToFileAsync_WhenFilePathInvalid_Throws(string? filePath) + { + // Arrange + var software = A.Fake(); + var sut = new BrewExporter(software); + + // Act + var act = () => sut.ExportToFileAsync(filePath!, false); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public void Ctor_WhenInstalledSoftwareIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewExporter(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/GlobalUsings.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/HomeBrewServiceCollectionExtensionsTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/HomeBrewServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..ad2ae11 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/HomeBrewServiceCollectionExtensionsTests.cs @@ -0,0 +1,80 @@ +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Export; +using CreativeCoders.MacOS.HomeBrew.Import; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; + +namespace CreativeCoders.MacOS.HomeBrew.Tests; + +public class HomeBrewServiceCollectionExtensionsTests +{ + [Fact] + public void AddHomeBrew_RegistersAllHomeBrewServicesAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddHomeBrew(); + + // Assert + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewInfo) + && x.ImplementationType == typeof(BrewInfo) && x.Lifetime == ServiceLifetime.Singleton); + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewInstalledSoftware) + && x.ImplementationType == typeof(BrewInstalledSoftware)); + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewUpgrader) + && x.ImplementationType == typeof(BrewUpgrader)); + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewExporter) + && x.ImplementationType == typeof(BrewExporter)); + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewInstaller) + && x.ImplementationType == typeof(BrewInstaller)); + services.Should().ContainSingle(x => x.ServiceType == typeof(IBrewImporter) + && x.ImplementationType == typeof(BrewImporter)); + } + + [Fact] + public void AddHomeBrew_ReturnsSameServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var result = services.AddHomeBrew(); + + // Assert + result.Should().BeSameAs(services); + } + + [Fact] + public void AddHomeBrew_CanResolveAllServicesFromBuiltProvider() + { + // Arrange + var services = new ServiceCollection(); + services.AddHomeBrew(); + using var provider = services.BuildServiceProvider(); + + // Act + Assert + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void AddHomeBrew_WhenServiceAlreadyRegistered_DoesNotOverride() + { + // Arrange - Uses TryAddSingleton so a pre-existing registration wins + var services = new ServiceCollection(); + var customInfo = A.Fake(); + services.AddSingleton(customInfo); + + // Act + services.AddHomeBrew(); + using var provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().BeSameAs(customInfo); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Import/BrewImporterTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Import/BrewImporterTests.cs new file mode 100644 index 0000000..d35c276 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Import/BrewImporterTests.cs @@ -0,0 +1,395 @@ +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Import; +using CreativeCoders.MacOS.HomeBrew.Models.Export; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests.Import; + +public class BrewImporterTests +{ + [Fact] + public async Task ImportAsync_WithFormulaeAndCasks_InstallsDistinctTapsFirst() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + + var exportModel = new BrewExportModel + { + Formulae = + [ + new BrewExportFormulaModel { Name = "wget", Tap = "custom/tap" }, + new BrewExportFormulaModel { Name = "curl", Tap = "custom/tap" } + ], + Casks = + [ + new BrewExportCaskModel { Token = "firefox", Tap = "other/tap" } + ] + }; + + // Act + await sut.ImportAsync(exportModel); + + // Assert + A.CallTo(() => installer.TapAsync("custom/tap")).MustHaveHappenedOnceExactly(); + A.CallTo(() => installer.TapAsync("other/tap")).MustHaveHappenedOnceExactly(); + A.CallTo(() => installer.InstallFormulaAsync("wget")).MustHaveHappenedOnceExactly(); + A.CallTo(() => installer.InstallFormulaAsync("curl")).MustHaveHappenedOnceExactly(); + A.CallTo(() => installer.InstallCaskAsync("firefox")).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ImportAsync_DeduplicatesTapsCaseInsensitively() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = + [ + new BrewExportFormulaModel { Name = "wget", Tap = "custom/tap" }, + new BrewExportFormulaModel { Name = "curl", Tap = "CUSTOM/TAP" } + ] + }; + + // Act + await sut.ImportAsync(exportModel); + + // Assert + A.CallTo(() => installer.TapAsync(A._)).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ImportAsync_SkipsFormulaeAndCasksWithEmptyNameOrToken() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = + [ + new BrewExportFormulaModel { Name = "" }, + new BrewExportFormulaModel { Name = " " } + ], + Casks = + [ + new BrewExportCaskModel { Token = "" } + ] + }; + + // Act + await sut.ImportAsync(exportModel); + + // Assert + A.CallTo(() => installer.InstallFormulaAsync(A._)).MustNotHaveHappened(); + A.CallTo(() => installer.InstallCaskAsync(A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task ImportAsync_WhenInstallFails_CollectsAndThrowsAggregate() + { + // Arrange + var installer = A.Fake(); + A.CallTo(() => installer.InstallFormulaAsync("bad")) + .Throws(new BrewInstallFailedException(BrewInstallTargetKind.Formula, "bad", "err", 1)); + A.CallTo(() => installer.InstallCaskAsync("badcask")) + .Throws(new BrewInstallFailedException(BrewInstallTargetKind.Cask, "badcask", "err", 1)); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = + [ + new BrewExportFormulaModel { Name = "good" }, + new BrewExportFormulaModel { Name = "bad" } + ], + Casks = [new BrewExportCaskModel { Token = "badcask" }] + }; + + // Act + var act = () => sut.ImportAsync(exportModel); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Failures.Should().HaveCount(2); + // Ensure the good formula was still installed despite failures + A.CallTo(() => installer.InstallFormulaAsync("good")).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ImportAsync_ReportsProgressForEachStep() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = [new BrewExportFormulaModel { Name = "wget", Tap = "some/tap" }], + Casks = [new BrewExportCaskModel { Token = "firefox" }] + }; + var reports = new List(); + var progress = new Progress(reports.Add); + + // Act + await sut.ImportAsync(exportModel, progress); + + // Allow the Progress SynchronizationContext to flush callbacks + await Task.Delay(50); + + // Assert + reports.Should().Contain(r => r.Step == BrewImportStep.Tap + && r.State == BrewImportStepState.Starting && r.Target == "some/tap"); + reports.Should().Contain(r => r.Step == BrewImportStep.Tap + && r.State == BrewImportStepState.Succeeded && r.Target == "some/tap"); + reports.Should().Contain(r => r.Step == BrewImportStep.InstallFormula + && r.State == BrewImportStepState.Succeeded && r.Target == "wget"); + reports.Should().Contain(r => r.Step == BrewImportStep.InstallCask + && r.State == BrewImportStepState.Succeeded && r.Target == "firefox"); + } + + [Fact] + public async Task ImportAsync_WhenStepFails_ReportsFailedProgressWithError() + { + // Arrange + var failure = new BrewInstallFailedException(BrewInstallTargetKind.Formula, "bad", "err", 1); + var installer = A.Fake(); + A.CallTo(() => installer.InstallFormulaAsync("bad")).Throws(failure); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = [new BrewExportFormulaModel { Name = "bad" }] + }; + var reports = new List(); + var progress = new Progress(reports.Add); + + // Act + var act = () => sut.ImportAsync(exportModel, progress); + + await act.Should().ThrowAsync(); + await Task.Delay(50); + + // Assert + reports.Should().Contain(r => r.State == BrewImportStepState.Failed + && r.Target == "bad" && ReferenceEquals(r.Error, failure)); + } + + [Fact] + public async Task ImportAsync_WhenExportModelIsNull_Throws() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + + // Act + var act = () => sut.ImportAsync(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadFileAsync_ReturnsDeserializedModel() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var model = new BrewExportModel + { + Formulae = [new BrewExportFormulaModel { Name = "wget", Tap = "homebrew/core" }], + Casks = [new BrewExportCaskModel { Token = "firefox" }] + }; + var filePath = Path.Combine(Path.GetTempPath(), $"brew-import-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(model)); + + try + { + // Act + var result = await sut.ReadFileAsync(filePath); + + // Assert + result.Formulae.Should().ContainSingle().Which.Name.Should().Be("wget"); + result.Casks.Should().ContainSingle().Which.Token.Should().Be("firefox"); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ReadFileAsync_WhenJsonIsNullLiteral_ReturnsEmptyModel() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var filePath = Path.Combine(Path.GetTempPath(), $"brew-import-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(filePath, "null"); + + try + { + // Act + var result = await sut.ReadFileAsync(filePath); + + // Assert + result.Should().NotBeNull(); + result.Formulae.Should().BeEmpty(); + result.Casks.Should().BeEmpty(); + } + finally + { + File.Delete(filePath); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task ReadFileAsync_WhenFilePathInvalid_Throws(string? filePath) + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + + // Act + var act = () => sut.ReadFileAsync(filePath!); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ImportFromFileAsync_ReadsFileThenImports() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + var model = new BrewExportModel + { + Formulae = [new BrewExportFormulaModel { Name = "wget" }] + }; + var filePath = Path.Combine(Path.GetTempPath(), $"brew-import-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(model)); + + try + { + // Act + await sut.ImportFromFileAsync(filePath); + + // Assert + A.CallTo(() => installer.InstallFormulaAsync("wget")).MustHaveHappenedOnceExactly(); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ImportAsync_WhenModelIsEmpty_DoesNotInvokeInstallerAndDoesNotThrow() + { + // Arrange + var installer = A.Fake(); + var sut = new BrewImporter(installer); + + // Act + await sut.ImportAsync(new BrewExportModel()); + + // Assert + A.CallTo(installer).MustNotHaveHappened(); + } + + [Fact] + public async Task ImportAsync_WhenTapFails_DoesNotPreventSubsequentInstalls() + { + // Arrange + var installer = A.Fake(); + A.CallTo(() => installer.TapAsync("custom/tap")) + .Throws(new BrewInstallFailedException(BrewInstallTargetKind.Tap, "custom/tap", "err", 1)); + var sut = new BrewImporter(installer); + var exportModel = new BrewExportModel + { + Formulae = [new BrewExportFormulaModel { Name = "wget", Tap = "custom/tap" }], + Casks = [new BrewExportCaskModel { Token = "firefox" }] + }; + + // Act + var act = () => sut.ImportAsync(exportModel); + + // Assert + await act.Should().ThrowAsync(); + A.CallTo(() => installer.InstallFormulaAsync("wget")).MustHaveHappenedOnceExactly(); + A.CallTo(() => installer.InstallCaskAsync("firefox")).MustHaveHappenedOnceExactly(); + } + + [Fact] + public async Task ReadFileAsync_WhenFileDoesNotExist_ThrowsFileNotFoundException() + { + // Arrange + var sut = new BrewImporter(A.Fake()); + var filePath = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.json"); + + // Act + var act = () => sut.ReadFileAsync(filePath); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadFileAsync_WhenJsonIsMalformed_Throws() + { + // Arrange + var sut = new BrewImporter(A.Fake()); + var filePath = Path.Combine(Path.GetTempPath(), $"malformed-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(filePath, "{ this is not valid json"); + + try + { + // Act + var act = () => sut.ReadFileAsync(filePath); + + // Assert + await act.Should().ThrowAsync(); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ReadFileAsync_WhenJsonIsEmptyObject_ReturnsModelWithEmptyCollections() + { + // Arrange + var sut = new BrewImporter(A.Fake()); + var filePath = Path.Combine(Path.GetTempPath(), $"empty-{Guid.NewGuid():N}.json"); + await File.WriteAllTextAsync(filePath, "{}"); + + try + { + // Act + var result = await sut.ReadFileAsync(filePath); + + // Assert + result.Formulae.Should().BeEmpty(); + result.Casks.Should().BeEmpty(); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public void Ctor_WhenInstallerIsNull_Throws() + { + // Arrange + Act + var act = () => new BrewImporter(null!); + + // Assert + act.Should().Throw(); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Models/Casks/SingleOrArrayConverterTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Models/Casks/SingleOrArrayConverterTests.cs new file mode 100644 index 0000000..4da5d66 --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Models/Casks/SingleOrArrayConverterTests.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using AwesomeAssertions; +using CreativeCoders.MacOS.HomeBrew.Models.Casks; + +namespace CreativeCoders.MacOS.HomeBrew.Tests.Models.Casks; + +public class SingleOrArrayConverterTests +{ + [Fact] + public void Deserialize_WhenValueIsString_ReturnsArrayWithSingleElement() + { + const string json = """{"trash": "~/.copilot"}"""; + + var result = JsonSerializer.Deserialize(json); + + result!.Trash.Should().BeEquivalentTo(["~/.copilot"]); + } + + [Fact] + public void Deserialize_WhenValueIsArray_ReturnsArray() + { + const string json = """{"trash": ["~/Library/Preferences/com.test.plist", "~/Library/Caches/com.test"]}"""; + + var result = JsonSerializer.Deserialize(json); + + result!.Trash.Should().BeEquivalentTo(["~/Library/Preferences/com.test.plist", "~/Library/Caches/com.test"]); + } + + [Fact] + public void Deserialize_WhenValueIsNull_ReturnsNull() + { + const string json = """{"trash": null}"""; + + var result = JsonSerializer.Deserialize(json); + + result!.Trash.Should().BeNull(); + } + + [Fact] + public void Deserialize_WhenPropertyIsMissing_ReturnsNull() + { + const string json = """{}"""; + + var result = JsonSerializer.Deserialize(json); + + result!.Trash.Should().BeNull(); + } + + [Fact] + public void Deserialize_WhenValueIsEmptyArray_ReturnsEmptyArray() + { + const string json = """{"trash": []}"""; + + var result = JsonSerializer.Deserialize(json); + + result!.Trash.Should().BeEmpty(); + } + + [Fact] + public void Serialize_WhenValueIsArray_WritesArray() + { + var model = new BrewZapModel { Trash = ["~/file1", "~/file2"] }; + + var json = JsonSerializer.Serialize(model); + + using var doc = JsonDocument.Parse(json); + var trash = doc.RootElement.GetProperty("trash"); + trash.ValueKind.Should().Be(JsonValueKind.Array); + trash.GetArrayLength().Should().Be(2); + } + + [Fact] + public void Serialize_WhenValueIsNull_WritesNull() + { + var model = new BrewZapModel { Trash = null }; + + var json = JsonSerializer.Serialize(model); + + using var doc = JsonDocument.Parse(json); + var trash = doc.RootElement.GetProperty("trash"); + trash.ValueKind.Should().Be(JsonValueKind.Null); + } +} diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/TestHelpers/FakeProcessExecutorBuilder.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/TestHelpers/FakeProcessExecutorBuilder.cs new file mode 100644 index 0000000..9fdabaf --- /dev/null +++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/TestHelpers/FakeProcessExecutorBuilder.cs @@ -0,0 +1,33 @@ +using CreativeCoders.ProcessUtils.Execution; +using FakeItEasy; + +namespace CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers; + +/// +/// Test helpers that produce fluent-builder fakes for . +/// Configures the builder so that every fluent setter returns the builder itself and +/// yields the provided executor fake. +/// +internal static class FakeProcessExecutorBuilder +{ + public static IProcessExecutorBuilder Create(out IProcessExecutor executor) + { + var builder = A.Fake>(); + executor = A.Fake>(); + + A.CallTo(() => builder.SetFileName(A._)).Returns(builder); + A.CallTo(() => builder.SetArguments(A._)).Returns(builder); + A.CallTo(() => builder.SetupStartInfo(A>._)).Returns(builder); + A.CallTo(() => builder.ShouldThrowOnError(A._)).Returns(builder); + A.CallTo(() => builder.SetOutputParser(A>._)).Returns(builder); + // Fluent generic SetOutputParser(Action) is matched via reflection-based predicate + A.CallTo(builder) + .Where(call => call.Method.Name == "SetOutputParser" && call.Method.IsGenericMethod) + .WithReturnType>() + .Returns(builder); + + A.CallTo(() => builder.Build()).Returns(executor); + + return builder; + } +}