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