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;
+ }
+}