diff --git a/.serena/.gitignore b/.serena/.gitignore
new file mode 100644
index 0000000..2e510af
--- /dev/null
+++ b/.serena/.gitignore
@@ -0,0 +1,2 @@
+/cache
+/project.local.yml
diff --git a/.serena/project.yml b/.serena/project.yml
new file mode 100644
index 0000000..35105f9
--- /dev/null
+++ b/.serena/project.yml
@@ -0,0 +1,154 @@
+# the name by which the project can be referenced within Serena
+project_name: "macsynkker"
+
+
+# list of languages for which language servers are started; choose from:
+# al bash clojure cpp csharp
+# csharp_omnisharp dart elixir elm erlang
+# fortran fsharp go groovy haskell
+# haxe java julia kotlin lua
+# markdown
+# matlab nix pascal perl php
+# php_phpactor powershell python python_jedi r
+# rego ruby ruby_solargraph rust scala
+# swift terraform toml typescript typescript_vts
+# vue yaml zig
+# (This list may be outdated. For the current list, see values of Language enum here:
+# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
+# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
+# Note:
+# - For C, use cpp
+# - For JavaScript, use typescript
+# - For Free Pascal/Lazarus, use pascal
+# Special requirements:
+# Some languages require additional setup/installations.
+# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
+# When using multiple languages, the first language server that supports a given file will be used for that file.
+# The first language is the default language and the respective language server will be used as a fallback.
+# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
+languages:
+- csharp
+
+# the encoding used by text files in the project
+# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
+encoding: "utf-8"
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# whether to use project's .gitignore files to ignore files
+ignore_all_files_in_gitignore: true
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
+
+# list of additional paths to ignore in this project.
+# Same syntax as gitignore, so you can use * and **.
+# Note: global ignored_paths from serena_config.yml are also applied additively.
+ignored_paths: []
+
+# whether the project is in read-only mode
+# If set to true, all editing tools will be disabled and attempts to use them will result in an error
+# Added on 2025-04-18
+read_only: false
+
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+#
+# Below is the complete list of tools for convenience.
+# To make sure you have the latest list of tools, and to view their descriptions,
+# execute `uv run scripts/print_tool_overview.py`.
+#
+# * `activate_project`: Activates a project based on the project name or path.
+# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
+# * `create_text_file`: Creates/overwrites a file in the project directory.
+# * `delete_memory`: Delete a memory file. Should only happen if a user asks for it explicitly,
+# for example by saying that the information retrieved from a memory file is no longer correct
+# or no longer relevant for the project.
+# * `edit_memory`: Replaces content matching a regular expression in a memory.
+# * `execute_shell_command`: Executes a shell command.
+# * `find_file`: Finds files in the given relative paths
+# * `find_referencing_symbols`: Finds symbols that reference the given symbol using the language server backend
+# * `find_symbol`: Performs a global (or local) search using the language server backend.
+# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
+# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
+# * `initial_instructions`: Provides instructions Serena usage (i.e. the 'Serena Instructions Manual')
+# for clients that do not read the initial instructions when the MCP server is connected.
+# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
+# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
+# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
+# * `list_memories`: List available memories. Any memory can be read using the `read_memory` tool.
+# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
+# * `read_file`: Reads a file within the project directory.
+# * `read_memory`: Read the content of a memory file. This tool should only be used if the information
+# is relevant to the current task. You can infer whether the information
+# is relevant from the memory file name.
+# You should not read the same memory file multiple times in the same conversation.
+# * `rename_memory`: Renames or moves a memory. Moving between project and global scope is supported
+# (e.g., renaming "global/foo" to "bar" moves it from global to project scope).
+# * `rename_symbol`: Renames a symbol throughout the codebase using language server refactoring capabilities.
+# For JB, we use a separate tool.
+# * `replace_content`: Replaces content in a file (optionally using regular expressions).
+# * `replace_symbol_body`: Replaces the full definition of a symbol using the language server backend.
+# * `safe_delete_symbol`:
+# * `search_for_pattern`: Performs a search for a pattern in the project.
+# * `write_memory`: Write some information (utf-8-encoded) about this project that can be useful for future tasks to a memory in md format.
+# The memory name should be meaningful.
+excluded_tools: []
+
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+included_optional_tools: []
+
+# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
+# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+fixed_tools: []
+
+# list of mode names to that are always to be included in the set of active modes
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this setting overrides the global configuration.
+# Set this to [] to disable base modes for this project.
+# Set this to a list of mode names to always include the respective modes for this project.
+base_modes:
+
+# list of mode names that are to be activated by default.
+# The full set of modes to be activated is base_modes + default_modes.
+# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# This setting can, in turn, be overridden by CLI parameters (--mode).
+default_modes:
+
+# initial prompt for the project. It will always be given to the LLM upon activating the project
+# (contrary to the memories, which are loaded on demand).
+initial_prompt: ""
+
+# time budget (seconds) per tool call for the retrieval of additional symbol information
+# such as docstrings or parameter information.
+# This overrides the corresponding setting in the global configuration; see the documentation there.
+# If null or missing, use the setting from the global configuration.
+symbol_info_budget:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanup.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanup.cs
new file mode 100644
index 0000000..fb16e72
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanup.cs
@@ -0,0 +1,63 @@
+using CreativeCoders.Core;
+using CreativeCoders.ProcessUtils.Execution;
+
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Default implementation. Uses
+/// to invoke brew cleanup, mirroring the structure of BrewUpgrader and
+/// BrewInstaller.
+///
+public class BrewCleanup : IBrewCleanup
+{
+ private readonly IProcessExecutor _cleanupExecutor;
+
+ public BrewCleanup(IProcessExecutorBuilder processExecutorBuilder)
+ {
+ Ensure.NotNull(processExecutorBuilder);
+
+ _cleanupExecutor = processExecutorBuilder
+ .SetFileName("brew")
+ .SetArguments(["cleanup", "{{prune}}", "{{dryRun}}"])
+ .ShouldThrowOnError()
+ .Build();
+ }
+
+ ///
+ public async Task CleanupAsync(BrewCleanupOptions? options = null)
+ {
+ await ExecuteAsync(options, dryRun: false).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task GetReclaimableSpaceAsync(BrewCleanupOptions? options = null)
+ {
+ var details = await GetReclaimableSpaceDetailsAsync(options).ConfigureAwait(false);
+
+ return details.TotalBytes;
+ }
+
+ ///
+ public async Task GetReclaimableSpaceDetailsAsync(BrewCleanupOptions? options = null)
+ {
+ var output = await ExecuteAsync(options, dryRun: true).ConfigureAwait(false);
+
+ return ReclaimableSpaceParser.Parse(output);
+ }
+
+ private async Task ExecuteAsync(BrewCleanupOptions? options, bool dryRun)
+ {
+ var prune = options?.Prune?.ToCommandLineArgument() ?? string.Empty;
+
+ try
+ {
+ return await _cleanupExecutor
+ .ExecuteAsync(new { prune, dryRun = dryRun ? "--dry-run" : string.Empty })
+ .ConfigureAwait(false);
+ }
+ catch (ProcessExecutionFailedException e)
+ {
+ throw new BrewCleanupFailedException("Brew cleanup failed", e.ErrorOutput, e.ExitCode, e);
+ }
+ }
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupFailedException.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupFailedException.cs
new file mode 100644
index 0000000..ca8fc4e
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupFailedException.cs
@@ -0,0 +1,18 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Thrown when brew cleanup exits with a non-zero exit code. Carries the captured error
+/// output and the exit code for diagnostics.
+///
+public class BrewCleanupFailedException(
+ string message,
+ string errorOutput,
+ int exitCode,
+ Exception? innerException = null) : Exception(message, innerException)
+{
+ /// Gets the standard error output captured from the brew process.
+ public string ErrorOutput { get; } = errorOutput;
+
+ /// Gets the exit code reported by the brew process.
+ public int ExitCode { get; } = exitCode;
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupOptions.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupOptions.cs
new file mode 100644
index 0000000..de75dfb
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewCleanupOptions.cs
@@ -0,0 +1,11 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+/// Options for a brew cleanup invocation.
+public class BrewCleanupOptions
+{
+ ///
+ /// Optional --prune setting. When null the option is omitted and Homebrew's
+ /// default behaviour applies.
+ ///
+ public BrewPruneOption? Prune { get; set; }
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewPruneOption.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewPruneOption.cs
new file mode 100644
index 0000000..d2ddd29
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/BrewPruneOption.cs
@@ -0,0 +1,41 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Represents the value passed to the --prune option of brew cleanup. A prune option
+/// either targets cache files older than a specified number of days or removes all cache files.
+///
+public sealed class BrewPruneOption
+{
+ private readonly int? _days;
+
+ private readonly bool _all;
+
+ private BrewPruneOption(int? days, bool all)
+ {
+ _days = days;
+ _all = all;
+ }
+
+ ///
+ /// Creates a prune option that removes cache files older than days.
+ ///
+ /// Age threshold in days. Must be zero or positive.
+ public static BrewPruneOption Days(int days)
+ {
+ if (days < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(days), days, "Days must be zero or positive.");
+ }
+
+ return new BrewPruneOption(days, all: false);
+ }
+
+ /// Creates a prune option that removes all cache files.
+ public static BrewPruneOption All { get; } = new BrewPruneOption(days: null, all: true);
+
+ /// Returns the command-line argument representation, e.g. --prune=7 or --prune=all.
+ public string ToCommandLineArgument()
+ {
+ return _all ? "--prune=all" : $"--prune={_days}";
+ }
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/IBrewCleanup.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/IBrewCleanup.cs
new file mode 100644
index 0000000..1c09cef
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/IBrewCleanup.cs
@@ -0,0 +1,36 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+/// Runs brew cleanup and inspects the disk space it would free.
+public interface IBrewCleanup
+{
+ ///
+ /// Executes brew cleanup using the given .
+ ///
+ /// Cleanup options or null for Homebrew defaults.
+ /// When the brew process fails.
+ Task CleanupAsync(BrewCleanupOptions? options = null);
+
+ ///
+ /// Executes brew cleanup --dry-run using the given and
+ /// returns the amount of disk space (in bytes) that the cleanup would reclaim. Returns
+ /// 0 when the brew output does not contain a parseable size hint.
+ ///
+ /// Cleanup options or null for Homebrew defaults.
+ /// When the brew process fails.
+ Task GetReclaimableSpaceAsync(BrewCleanupOptions? options = null);
+
+ ///
+ /// Executes brew cleanup --dry-run using the given and
+ /// returns both the total amount of disk space (in bytes) that the cleanup would reclaim
+ /// and a detailed list of individual entries that would be removed together with their
+ /// reported size.
+ ///
+ /// Cleanup options or null for Homebrew defaults.
+ ///
+ /// A containing the total reclaimable bytes and the list
+ /// of entries parsed from the brew output. The result is
+ /// empty when the output does not contain any parseable size information.
+ ///
+ /// When the brew process fails.
+ Task GetReclaimableSpaceDetailsAsync(BrewCleanupOptions? options = null);
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableItem.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableItem.cs
new file mode 100644
index 0000000..f957f99
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableItem.cs
@@ -0,0 +1,9 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Represents a single entry that brew cleanup --dry-run reported as removable,
+/// together with its on-disk size in bytes.
+///
+/// The file or directory path that would be removed.
+/// Size of the entry in bytes as reported by Homebrew.
+public record ReclaimableItem(string Path, long SizeInBytes);
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpace.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpace.cs
new file mode 100644
index 0000000..a36dc80
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpace.cs
@@ -0,0 +1,10 @@
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Aggregated result of a brew cleanup --dry-run invocation: the total amount
+/// of disk space that would be reclaimed and the individual entries that contribute
+/// to that total.
+///
+/// Total reclaimable disk space in bytes.
+/// Detailed list of entries that would be removed.
+public record ReclaimableSpace(long TotalBytes, IReadOnlyList Items);
diff --git a/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpaceParser.cs b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpaceParser.cs
new file mode 100644
index 0000000..5e41de3
--- /dev/null
+++ b/source/CreativeCoders.MacOS.HomeBrew/Cleanup/ReclaimableSpaceParser.cs
@@ -0,0 +1,118 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+///
+/// Parses the human-readable output emitted by brew cleanup --dry-run
+/// (e.g. "Would remove: /path/to/file (1.2MB)" and
+/// "This operation would free approximately 1.2GB of disk space.").
+///
+internal static partial class ReclaimableSpaceParser
+{
+ [GeneratedRegex(
+ @"^\s*(?:Would remove|Removing):\s+(?.+?)\s*\((?\d+(?:\.\d+)?)\s*(?B|KB|MB|GB|TB|PB)\)\s*$",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ItemRegex();
+
+ [GeneratedRegex(
+ @"would\s+free\s+approximately\s+(?\d+(?:\.\d+)?)\s*(?B|KB|MB|GB|TB|PB)",
+ RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex TotalRegex();
+
+ ///
+ /// Returns the total reclaimable size found in in bytes.
+ /// Returns 0 when no size hint can be detected.
+ ///
+ public static long ParseBytes(string? brewOutput)
+ {
+ return Parse(brewOutput).TotalBytes;
+ }
+
+ ///
+ /// Parses the full brew cleanup --dry-run output into a
+ /// containing the total reclaimable bytes and the list of individual entries.
+ ///
+ /// Raw stdout of brew cleanup --dry-run.
+ ///
+ /// A . When is null or
+ /// whitespace, an empty result with TotalBytes = 0 is returned. When the
+ /// output contains a "would free approximately"-line, its size is used as
+ /// ; otherwise the sum of the parsed
+ /// item sizes is used.
+ ///
+ public static ReclaimableSpace Parse(string? brewOutput)
+ {
+ if (string.IsNullOrWhiteSpace(brewOutput))
+ {
+ return new ReclaimableSpace(0, []);
+ }
+
+ var items = new List();
+ long itemsSum = 0;
+
+ foreach (var line in brewOutput.Split('\n'))
+ {
+ var match = ItemRegex().Match(line);
+
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ if (!TryGetBytes(match.Groups["value"].Value, match.Groups["unit"].Value, out var bytes))
+ {
+ continue;
+ }
+
+ items.Add(new ReclaimableItem(match.Groups["path"].Value.Trim(), bytes));
+ itemsSum += bytes;
+ }
+
+ var totalMatch = TotalRegex().Match(brewOutput);
+
+ long totalBytes;
+
+ if (totalMatch.Success
+ && TryGetBytes(totalMatch.Groups["value"].Value, totalMatch.Groups["unit"].Value, out var parsedTotal))
+ {
+ totalBytes = parsedTotal;
+ }
+ else
+ {
+ totalBytes = itemsSum;
+ }
+
+ return new ReclaimableSpace(totalBytes, items);
+ }
+
+ private static bool TryGetBytes(string value, string unit, out long bytes)
+ {
+ bytes = 0;
+
+ if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number))
+ {
+ return false;
+ }
+
+ var multiplier = unit.ToUpperInvariant() switch
+ {
+ "B" => 1L,
+ "KB" => 1024L,
+ "MB" => 1024L * 1024L,
+ "GB" => 1024L * 1024L * 1024L,
+ "TB" => 1024L * 1024L * 1024L * 1024L,
+ "PB" => 1024L * 1024L * 1024L * 1024L * 1024L,
+ _ => 0L
+ };
+
+ if (multiplier == 0L)
+ {
+ return false;
+ }
+
+ bytes = (long)(number * multiplier);
+
+ return true;
+ }
+}
diff --git a/source/CreativeCoders.MacOS.HomeBrew/CreativeCoders.MacOS.HomeBrew.csproj b/source/CreativeCoders.MacOS.HomeBrew/CreativeCoders.MacOS.HomeBrew.csproj
index 88ece31..5fc7362 100644
--- a/source/CreativeCoders.MacOS.HomeBrew/CreativeCoders.MacOS.HomeBrew.csproj
+++ b/source/CreativeCoders.MacOS.HomeBrew/CreativeCoders.MacOS.HomeBrew.csproj
@@ -9,6 +9,10 @@
+
+
+
+
diff --git a/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs b/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs
index fe4908a..31c6634 100644
--- a/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs
+++ b/source/CreativeCoders.MacOS.HomeBrew/HomeBrewServiceCollectionExtensions.cs
@@ -1,3 +1,4 @@
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
using CreativeCoders.MacOS.HomeBrew.Export;
using CreativeCoders.MacOS.HomeBrew.Import;
using CreativeCoders.ProcessUtils;
@@ -18,6 +19,7 @@ public static IServiceCollection AddHomeBrew(this IServiceCollection services)
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
return services;
}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpCommand.cs
new file mode 100644
index 0000000..91b842a
--- /dev/null
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpCommand.cs
@@ -0,0 +1,44 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.Core;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+using JetBrains.Annotations;
+using Spectre.Console;
+
+namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+/// Runs brew cleanup via .
+[UsedImplicitly]
+[CliCommand([HomebrewCommandGroup.Name, CleanUpCommandGroup.Name],
+ Description = "Run brew cleanup to free disk space")]
+public class BrewCleanUpCommand(IBrewCleanup brewCleanup, IAnsiConsole ansiConsole)
+ : ICliCommand
+{
+ private readonly IBrewCleanup _brewCleanup = Ensure.NotNull(brewCleanup);
+
+ private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole);
+
+ public async Task ExecuteAsync(BrewCleanUpOptions options)
+ {
+ Ensure.NotNull(options);
+
+ var brewOptions = options.ToBrewCleanupOptions();
+
+ _ansiConsole.MarkupLine("Running [bold]brew cleanup[/] ...");
+
+ try
+ {
+ await _brewCleanup.CleanupAsync(brewOptions).ConfigureAwait(false);
+
+ _ansiConsole.MarkupLine("[green]Cleanup completed successfully[/]");
+ }
+ catch (BrewCleanupFailedException e)
+ {
+ _ansiConsole.MarkupLine("[red]Cleanup failed[/]");
+ _ansiConsole.WriteLine(e.ErrorOutput);
+
+ return new CommandResult(MacSynkkerCliExitCodes.CleanupFailed);
+ }
+
+ return CommandResult.Success;
+ }
+}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpOptions.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpOptions.cs
new file mode 100644
index 0000000..cde6e47
--- /dev/null
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewCleanUpOptions.cs
@@ -0,0 +1,52 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+using CreativeCoders.SysConsole.Cli.Parsing;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+/// Options for the brew cleanup CLI commands.
+[UsedImplicitly]
+public class BrewCleanUpOptions : IOptionsValidation
+{
+ ///
+ /// Maps to brew cleanup --prune=<days>. Mutually exclusive with
+ /// . Must be zero or positive.
+ ///
+ [OptionParameter('p', "prune", HelpText = "Remove cache files older than the given number of days")]
+ public int? PruneDays { get; set; }
+
+ /// Maps to brew cleanup --prune=all. Mutually exclusive with .
+ [OptionParameter('a', "prune-all", HelpText = "Remove all cache files (--prune=all)")]
+ public bool PruneAll { get; set; }
+
+ /// Builds the instance to pass to IBrewCleanup.
+ public BrewCleanupOptions ToBrewCleanupOptions()
+ {
+ var options = new BrewCleanupOptions();
+
+ if (PruneAll)
+ {
+ options.Prune = BrewPruneOption.All;
+ }
+ else if (PruneDays.HasValue)
+ {
+ options.Prune = BrewPruneOption.Days(PruneDays.Value);
+ }
+
+ return options;
+ }
+
+ public Task ValidateAsync()
+ {
+ if (PruneAll && PruneDays.HasValue)
+ {
+ return Task.FromResult(
+ OptionsValidationResult.Invalid(["--prune and --prune-all are mutually exclusive"]));
+ }
+
+ return Task.FromResult(PruneDays is < 0
+ ? OptionsValidationResult.Invalid(["--prune must be zero or a positive number of days"])
+ : OptionsValidationResult.Valid());
+ }
+}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceCommand.cs
new file mode 100644
index 0000000..3e8ba09
--- /dev/null
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceCommand.cs
@@ -0,0 +1,107 @@
+using System.Globalization;
+using CreativeCoders.Cli.Core;
+using CreativeCoders.Core;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+using JetBrains.Annotations;
+using Spectre.Console;
+
+namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+/// Shows how much disk space brew cleanup would reclaim.
+[UsedImplicitly]
+[CliCommand([HomebrewCommandGroup.Name, CleanUpCommandGroup.Name + "-show"],
+ Description = "Show disk space brew cleanup would reclaim (dry-run)")]
+public class BrewReclaimableSpaceCommand(IBrewCleanup brewCleanup, IAnsiConsole ansiConsole)
+ : ICliCommand
+{
+ private static readonly string[] UnitSuffixes = ["B", "KB", "MB", "GB", "TB", "PB"];
+
+ private readonly IBrewCleanup _brewCleanup = Ensure.NotNull(brewCleanup);
+
+ private readonly IAnsiConsole _ansiConsole = Ensure.NotNull(ansiConsole);
+
+ public async Task ExecuteAsync(BrewReclaimableSpaceOptions options)
+ {
+ Ensure.NotNull(options);
+
+ var brewOptions = options.ToBrewCleanupOptions();
+
+ _ansiConsole.MarkupLine("Calculating reclaimable space ...");
+
+ try
+ {
+ if (options.Details)
+ {
+ var details = await _brewCleanup.GetReclaimableSpaceDetailsAsync(brewOptions)
+ .ConfigureAwait(false);
+
+ WriteDetails(details);
+ }
+ else
+ {
+ var bytes = await _brewCleanup.GetReclaimableSpaceAsync(brewOptions).ConfigureAwait(false);
+
+ WriteTotal(bytes);
+ }
+ }
+ catch (BrewCleanupFailedException e)
+ {
+ _ansiConsole.MarkupLine("[red]Failed to query reclaimable space[/]");
+ _ansiConsole.WriteLine(e.ErrorOutput);
+ }
+
+ return CommandResult.Success;
+ }
+
+ private void WriteTotal(long bytes)
+ {
+ _ansiConsole.MarkupLine(
+ $"Reclaimable space: [green]{FormatBytes(bytes)}[/] ({bytes:N0} bytes)");
+ }
+
+ private void WriteDetails(ReclaimableSpace details)
+ {
+ if (details.Items.Count == 0)
+ {
+ _ansiConsole.MarkupLine("[yellow]No reclaimable entries found.[/]");
+ }
+ else
+ {
+ var table = new Table()
+ .AddColumn("Path")
+ .AddColumn(new TableColumn("Size").RightAligned())
+ .AddColumn(new TableColumn("Bytes").RightAligned());
+
+ foreach (var item in details.Items)
+ {
+ table.AddRow(
+ Markup.Escape(item.Path),
+ FormatBytes(item.SizeInBytes),
+ item.SizeInBytes.ToString("N0", CultureInfo.InvariantCulture));
+ }
+
+ _ansiConsole.Write(table);
+ }
+
+ WriteTotal(details.TotalBytes);
+ }
+
+ private static string FormatBytes(long bytes)
+ {
+ if (bytes <= 0)
+ {
+ return "0 B";
+ }
+
+ double value = bytes;
+ var unit = 0;
+
+ while (value >= 1024 && unit < UnitSuffixes.Length - 1)
+ {
+ value /= 1024;
+ unit++;
+ }
+
+ return string.Create(CultureInfo.InvariantCulture, $"{value:0.##} {UnitSuffixes[unit]}");
+ }
+}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceOptions.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceOptions.cs
new file mode 100644
index 0000000..33ce3f6
--- /dev/null
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/BrewReclaimableSpaceOptions.cs
@@ -0,0 +1,17 @@
+using CreativeCoders.SysConsole.Cli.Parsing;
+using JetBrains.Annotations;
+
+namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+/// Options for the brew cleanup-show CLI command.
+[UsedImplicitly]
+public class BrewReclaimableSpaceOptions : BrewCleanUpOptions
+{
+ ///
+ /// When set, prints the individual entries that would be removed by
+ /// brew cleanup --dry-run together with their reported size in addition
+ /// to the total reclaimable disk space.
+ ///
+ [OptionParameter('d', "details", HelpText = "Show individual entries with their size in addition to the total")]
+ public bool Details { get; set; }
+}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/CleanUpCommandGroup.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/CleanUpCommandGroup.cs
new file mode 100644
index 0000000..f4bec93
--- /dev/null
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/CleanUp/CleanUpCommandGroup.cs
@@ -0,0 +1,13 @@
+using CreativeCoders.Cli.Core;
+using CreativeCoders.MacSynkker.Cli.Commands.HomeBrew;
+using CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+[assembly: CliCommandGroup([HomebrewCommandGroup.Name, CleanUpCommandGroup.Name],
+ "Commands for cleaning up Homebrew caches")]
+
+namespace CreativeCoders.MacSynkker.Cli.Commands.HomeBrew.CleanUp;
+
+public static class CleanUpCommandGroup
+{
+ public const string Name = "cleanup";
+}
diff --git a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs
index 20fbf56..06c3fd9 100644
--- a/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs
+++ b/source/CreativeCoders.MacSynkker.Cli/Commands/HomeBrew/List/BrewListInstalledSoftwareCommand.cs
@@ -9,12 +9,11 @@
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.
///
+[UsedImplicitly]
+[CliCommand([HomebrewCommandGroup.Name, "list"], Description = "Shows Homebrew installed software")]
public class BrewListInstalledSoftwareCommand(IAnsiConsole ansiConsole, IBrewInstalledSoftware brewInstalledSoftware)
: ICliCommand
{
diff --git a/source/CreativeCoders.MacSynkker.Cli/MacSynkkerCliExitCodes.cs b/source/CreativeCoders.MacSynkker.Cli/MacSynkkerCliExitCodes.cs
index 803f0e0..da7a01b 100644
--- a/source/CreativeCoders.MacSynkker.Cli/MacSynkkerCliExitCodes.cs
+++ b/source/CreativeCoders.MacSynkker.Cli/MacSynkkerCliExitCodes.cs
@@ -3,4 +3,6 @@ namespace CreativeCoders.MacSynkker.Cli;
public static class MacSynkkerCliExitCodes
{
public const int FileNotFound = 1;
+
+ public const int CleanupFailed = 2;
}
diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewCleanupTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewCleanupTests.cs
new file mode 100644
index 0000000..fe57291
--- /dev/null
+++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewCleanupTests.cs
@@ -0,0 +1,156 @@
+using AwesomeAssertions;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+using CreativeCoders.MacOS.HomeBrew.Tests.TestHelpers;
+using CreativeCoders.ProcessUtils.Execution;
+using FakeItEasy;
+
+namespace CreativeCoders.MacOS.HomeBrew.Tests.Cleanup;
+
+public class BrewCleanupTests
+{
+ [Fact]
+ public async Task CleanupAsync_WithoutOptions_PassesEmptyPlaceholders()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ var sut = new BrewCleanup(builder);
+
+ await sut.CleanupAsync();
+
+ A.CallTo(() => executor.ExecuteAsync(A>.That
+ .Matches(d => (string?)d["prune"] == "" && (string?)d["dryRun"] == "")))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task CleanupAsync_WithPruneAll_SetsPruneAllArgument()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ var sut = new BrewCleanup(builder);
+
+ await sut.CleanupAsync(new BrewCleanupOptions { Prune = BrewPruneOption.All });
+
+ A.CallTo(() => executor.ExecuteAsync(A>.That
+ .Matches(d => (string?)d["prune"] == "--prune=all" && (string?)d["dryRun"] == "")))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task CleanupAsync_WithPruneDays_SetsPruneDaysArgument()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ var sut = new BrewCleanup(builder);
+
+ await sut.CleanupAsync(new BrewCleanupOptions { Prune = BrewPruneOption.Days(14) });
+
+ A.CallTo(() => executor.ExecuteAsync(A>.That
+ .Matches(d => (string?)d["prune"] == "--prune=14")))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task CleanupAsync_WhenExecutionFails_ThrowsBrewCleanupFailedException()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Throws(new ProcessExecutionFailedException(3, "boom", "std"));
+ var sut = new BrewCleanup(builder);
+
+ var act = () => sut.CleanupAsync();
+
+ var ex = await act.Should().ThrowAsync();
+ ex.Which.ErrorOutput.Should().Be("boom");
+ ex.Which.ExitCode.Should().Be(3);
+ }
+
+ [Fact]
+ public async Task GetReclaimableSpaceAsync_SetsDryRunAndParsesOutput()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Returns(Task.FromResult("This operation would free approximately 3MB of disk space."));
+ var sut = new BrewCleanup(builder);
+
+ var bytes = await sut.GetReclaimableSpaceAsync(
+ new BrewCleanupOptions { Prune = BrewPruneOption.Days(7) });
+
+ bytes.Should().Be(3L * 1024 * 1024);
+ A.CallTo(() => executor.ExecuteAsync(A>.That
+ .Matches(d => (string?)d["prune"] == "--prune=7" && (string?)d["dryRun"] == "--dry-run")))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetReclaimableSpaceAsync_WithoutSizeInOutput_ReturnsZero()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Returns(Task.FromResult("Nothing to clean."));
+ var sut = new BrewCleanup(builder);
+
+ var bytes = await sut.GetReclaimableSpaceAsync();
+
+ bytes.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetReclaimableSpaceDetailsAsync_SetsDryRunAndParsesItemsAndTotal()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Returns(Task.FromResult("""
+ Removing: /tmp/a (1MB)
+ Removing: /tmp/b (2MB)
+ This operation would free approximately 3MB of disk space.
+ """));
+ var sut = new BrewCleanup(builder);
+
+ var result = await sut.GetReclaimableSpaceDetailsAsync(
+ new BrewCleanupOptions { Prune = BrewPruneOption.Days(7) });
+
+ result.TotalBytes.Should().Be(3L * 1024 * 1024);
+ result.Items.Should().HaveCount(2);
+ result.Items[0].Path.Should().Be("/tmp/a");
+ result.Items[0].SizeInBytes.Should().Be(1L * 1024 * 1024);
+ result.Items[1].Path.Should().Be("/tmp/b");
+ result.Items[1].SizeInBytes.Should().Be(2L * 1024 * 1024);
+ A.CallTo(() => executor.ExecuteAsync(A>.That
+ .Matches(d => (string?)d["prune"] == "--prune=7" && (string?)d["dryRun"] == "--dry-run")))
+ .MustHaveHappenedOnceExactly();
+ }
+
+ [Fact]
+ public async Task GetReclaimableSpaceDetailsAsync_WithoutSizeInOutput_ReturnsEmptyResult()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Returns(Task.FromResult("Nothing to clean."));
+ var sut = new BrewCleanup(builder);
+
+ var result = await sut.GetReclaimableSpaceDetailsAsync();
+
+ result.TotalBytes.Should().Be(0);
+ result.Items.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetReclaimableSpaceDetailsAsync_WhenExecutionFails_ThrowsBrewCleanupFailedException()
+ {
+ var builder = FakeProcessExecutorBuilder.Create(out var executor);
+ A.CallTo(() => executor.ExecuteAsync(A>._))
+ .Throws(new ProcessExecutionFailedException(2, "fail", "out"));
+ var sut = new BrewCleanup(builder);
+
+ var act = () => sut.GetReclaimableSpaceDetailsAsync();
+
+ var ex = await act.Should().ThrowAsync();
+ ex.Which.ExitCode.Should().Be(2);
+ }
+
+ [Fact]
+ public void Ctor_WhenBuilderIsNull_Throws()
+ {
+ var act = () => new BrewCleanup(null!);
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewPruneOptionTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewPruneOptionTests.cs
new file mode 100644
index 0000000..86b6a6f
--- /dev/null
+++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/BrewPruneOptionTests.cs
@@ -0,0 +1,30 @@
+using AwesomeAssertions;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+namespace CreativeCoders.MacOS.HomeBrew.Tests.Cleanup;
+
+public class BrewPruneOptionTests
+{
+ [Fact]
+ public void All_ProducesPruneAllArgument()
+ {
+ BrewPruneOption.All.ToCommandLineArgument().Should().Be("--prune=all");
+ }
+
+ [Theory]
+ [InlineData(0, "--prune=0")]
+ [InlineData(7, "--prune=7")]
+ [InlineData(365, "--prune=365")]
+ public void Days_ProducesExpectedArgument(int days, string expected)
+ {
+ BrewPruneOption.Days(days).ToCommandLineArgument().Should().Be(expected);
+ }
+
+ [Fact]
+ public void Days_WhenNegative_Throws()
+ {
+ var act = () => BrewPruneOption.Days(-1);
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/ReclaimableSpaceParserTests.cs b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/ReclaimableSpaceParserTests.cs
new file mode 100644
index 0000000..2e3459b
--- /dev/null
+++ b/tests/CreativeCoders.MacOS.HomeBrew.Tests/Cleanup/ReclaimableSpaceParserTests.cs
@@ -0,0 +1,139 @@
+using AwesomeAssertions;
+using CreativeCoders.MacOS.HomeBrew.Cleanup;
+
+namespace CreativeCoders.MacOS.HomeBrew.Tests.Cleanup;
+
+public class ReclaimableSpaceParserTests
+{
+ [Fact]
+ public void ParseBytes_WithNullOrWhitespace_ReturnsZero()
+ {
+ ReclaimableSpaceParser.ParseBytes(null).Should().Be(0);
+ ReclaimableSpaceParser.ParseBytes("").Should().Be(0);
+ ReclaimableSpaceParser.ParseBytes(" ").Should().Be(0);
+ }
+
+ [Fact]
+ public void ParseBytes_WithoutSize_ReturnsZero()
+ {
+ ReclaimableSpaceParser.ParseBytes("Nothing to clean.").Should().Be(0);
+ }
+
+ [Theory]
+ [InlineData("This operation would free approximately 512B of disk space.", 512L)]
+ [InlineData("This operation would free approximately 2KB of disk space.", 2L * 1024)]
+ [InlineData("This operation would free approximately 5MB of disk space.", 5L * 1024 * 1024)]
+ [InlineData("This operation would free approximately 1GB of disk space.", 1L * 1024 * 1024 * 1024)]
+ public void ParseBytes_ParsesSimpleUnits(string output, long expected)
+ {
+ ReclaimableSpaceParser.ParseBytes(output).Should().Be(expected);
+ }
+
+ [Fact]
+ public void ParseBytes_ParsesDecimalGigabytes()
+ {
+ var bytes = ReclaimableSpaceParser.ParseBytes(
+ "This operation would free approximately 1.5GB of disk space.");
+
+ bytes.Should().Be((long)(1.5 * 1024 * 1024 * 1024));
+ }
+
+ [Fact]
+ public void ParseBytes_PicksLargestSizeInMultilineOutput()
+ {
+ var output = """
+ Removing: /tmp/file (123KB)
+ Removing: /tmp/other (2MB)
+ This operation would free approximately 2MB of disk space.
+ """;
+
+ var bytes = ReclaimableSpaceParser.ParseBytes(output);
+
+ bytes.Should().Be(2L * 1024 * 1024);
+ }
+
+ [Fact]
+ public void Parse_WithNullOrWhitespace_ReturnsEmptyResult()
+ {
+ var result = ReclaimableSpaceParser.Parse(null);
+
+ result.TotalBytes.Should().Be(0);
+ result.Items.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void Parse_WithRemovingLines_ReturnsItemsAndTotal()
+ {
+ var output = """
+ Removing: /Users/foo/Library/Caches/Homebrew/bar--1.0.tar.gz (1.5MB)
+ Removing: /Users/foo/Library/Caches/Homebrew/baz--2.0.tar.gz (512KB)
+ This operation would free approximately 2MB of disk space.
+ """;
+
+ var result = ReclaimableSpaceParser.Parse(output);
+
+ result.TotalBytes.Should().Be(2L * 1024 * 1024);
+ result.Items.Should().HaveCount(2);
+ result.Items[0].Path.Should().Be("/Users/foo/Library/Caches/Homebrew/bar--1.0.tar.gz");
+ result.Items[0].SizeInBytes.Should().Be((long)(1.5 * 1024 * 1024));
+ result.Items[1].Path.Should().Be("/Users/foo/Library/Caches/Homebrew/baz--2.0.tar.gz");
+ result.Items[1].SizeInBytes.Should().Be(512L * 1024);
+ }
+
+ [Fact]
+ public void Parse_WithWouldRemovePrefix_RecognizesItems()
+ {
+ var output = """
+ Would remove: /tmp/a (1KB)
+ Would remove: /tmp/b (2KB)
+ This operation would free approximately 3KB of disk space.
+ """;
+
+ var result = ReclaimableSpaceParser.Parse(output);
+
+ result.TotalBytes.Should().Be(3L * 1024);
+ result.Items.Should().HaveCount(2);
+ result.Items.Select(i => i.Path).Should().ContainInOrder("/tmp/a", "/tmp/b");
+ }
+
+ [Fact]
+ public void Parse_WithoutTotalLine_FallsBackToSumOfItems()
+ {
+ var output = """
+ Removing: /tmp/a (1KB)
+ Removing: /tmp/b (2KB)
+ """;
+
+ var result = ReclaimableSpaceParser.Parse(output);
+
+ result.TotalBytes.Should().Be(3L * 1024);
+ result.Items.Should().HaveCount(2);
+ }
+
+ [Fact]
+ public void Parse_WithMixedUnits_ParsesAllItems()
+ {
+ var output = """
+ Removing: /tmp/a (512B)
+ Removing: /tmp/b (1.5MB)
+ Removing: /tmp/c (2GB)
+ """;
+
+ var result = ReclaimableSpaceParser.Parse(output);
+
+ result.Items.Should().HaveCount(3);
+ result.Items[0].SizeInBytes.Should().Be(512L);
+ result.Items[1].SizeInBytes.Should().Be((long)(1.5 * 1024 * 1024));
+ result.Items[2].SizeInBytes.Should().Be(2L * 1024 * 1024 * 1024);
+ }
+
+ [Fact]
+ public void Parse_WithoutItems_ReturnsEmptyItemsAndTotalFromApproximateLine()
+ {
+ var result = ReclaimableSpaceParser.Parse(
+ "This operation would free approximately 5MB of disk space.");
+
+ result.TotalBytes.Should().Be(5L * 1024 * 1024);
+ result.Items.Should().BeEmpty();
+ }
+}