From bf876ac6d1f51086008acc6d539306f13a4251d7 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 20 Jul 2026 14:36:30 -0400 Subject: [PATCH 1/2] detect Cask updates parsed with "!=" operator --- .../Homebrew.cs | 44 +++++-- .../InternalsVisibleTo.cs | 3 + .../Fixtures/Homebrew/outdated-verbose.txt | 5 + .../HomebrewManagerTests.cs | 110 ++++++++++++++++++ .../UniGetUI.PackageEngine.Tests.csproj | 1 + 5 files changed, 151 insertions(+), 12 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.Homebrew/InternalsVisibleTo.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Homebrew/outdated-verbose.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs index d74978debd..6c0f771b49 100644 --- a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs @@ -226,26 +226,49 @@ private IReadOnlyList ListInstalledByType(string typeFlag, string sourc return packages; } + // "brew outdated --verbose" prints a different comparison operator per package type: + // Formula: "name (old_version) < new_version" (versions are comparable) + // Cask: "name (old_version) != new_version" (versions are opaque strings) + // Both operators must be accepted, otherwise cask updates are silently dropped. + private static readonly Regex OutdatedLinePattern = + new(@"^(\S+)\s+\(([^)]+)\)\s+(?:<|!=)\s+(.+)$"); + protected override IReadOnlyList GetAvailableUpdates_UnSafe() { - var packages = new List(); - - // Build a lookup of installed packages to retrieve their sources - Dictionary installed = []; - foreach (var pkg in GetInstalledPackages()) - installed.TryAdd(pkg.Id, pkg); + IReadOnlyList installed = GetInstalledPackages(); using var p = new Process { StartInfo = MakeBrewStartInfo("outdated --verbose") }; IProcessTaskLogger logger = TaskLogger.CreateNew(LoggableTaskType.ListUpdates, p); p.Start(); - // Format: "name (old_version) < new_version" - var pattern = new Regex(@"^(\S+)\s+\(([^)]+)\)\s+<\s+(.+)$"); + var lines = new List(); string? line; while ((line = p.StandardOutput.ReadLine()) is not null) { logger.AddToStdOut(line); - var m = pattern.Match(line); + lines.Add(line); + } + + logger.AddToStdErr(p.StandardError.ReadToEnd()); + p.WaitForExit(); + logger.Close(p.ExitCode); + + return ParseAvailableUpdates(lines, installed); + } + + internal IReadOnlyList ParseAvailableUpdates( + IEnumerable lines, + IEnumerable installedPackages) + { + // Build a lookup of installed packages to retrieve their sources (Formula vs Cask) + Dictionary installed = []; + foreach (var pkg in installedPackages) + installed.TryAdd(pkg.Id, pkg); + + var packages = new List(); + foreach (var line in lines) + { + var m = OutdatedLinePattern.Match(line); if (!m.Success) continue; var id = m.Groups[1].Value.Trim(); @@ -265,9 +288,6 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() this)); } - logger.AddToStdErr(p.StandardError.ReadToEnd()); - p.WaitForExit(); - logger.Close(p.ExitCode); return packages; } } diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/InternalsVisibleTo.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/InternalsVisibleTo.cs new file mode 100644 index 0000000000..eeb63dad19 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UniGetUI.PackageEngine.Tests")] diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Homebrew/outdated-verbose.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Homebrew/outdated-verbose.txt new file mode 100644 index 0000000000..230c035ed5 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Homebrew/outdated-verbose.txt @@ -0,0 +1,5 @@ +fontconfig (2.17.1) < 2.18.2 +shaderc (2026.2) < 2026.3 +python@3.14 (3.14.3_1) < 3.14.6 +firefox (152.0.5) != 152.0.6 +visual-studio-code (1.90.0) != 1.91.0 diff --git a/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs new file mode 100644 index 0000000000..55bb324b29 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/HomebrewManagerTests.cs @@ -0,0 +1,110 @@ +using UniGetUI.Core.Data; +using UniGetUI.Core.SettingsEngine; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Managers.HomebrewManager; +using UniGetUI.PackageEngine.PackageClasses; +using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; +using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; + +namespace UniGetUI.PackageEngine.Tests; + +[CollectionDefinition("Homebrew manager tests", DisableParallelization = true)] +public sealed class HomebrewManagerTestCollection +{ + public const string Name = "Homebrew manager tests"; +} + +[Collection(HomebrewManagerTestCollection.Name)] +public sealed class HomebrewManagerTests : IDisposable +{ + private readonly string _testRoot = Path.Combine( + AppContext.BaseDirectory, + nameof(HomebrewManagerTests), + Guid.NewGuid().ToString("N") + ); + + public HomebrewManagerTests() + { + Directory.CreateDirectory(_testRoot); + CoreData.TEST_DataDirectoryOverride = Path.Combine(_testRoot, "Data"); + Directory.CreateDirectory(CoreData.UniGetUIUserConfigurationDirectory); + Settings.ResetSettings(); + } + + public void Dispose() + { + Settings.ResetSettings(); + CoreData.TEST_DataDirectoryOverride = null; + if (Directory.Exists(_testRoot)) + { + Directory.Delete(_testRoot, recursive: true); + } + } + + // Regression test for issue #5127: "brew outdated --verbose" prints "<" for outdated + // Formulae but "!=" for outdated Casks. The parser used to match only "<", so Cask + // updates were silently dropped. Both operators must produce update packages. + [Fact] + public void ParseAvailableUpdatesDetectsBothFormulaAndCaskUpdates() + { + var manager = new Homebrew(); + IManagerSource formulaSource = manager.SourcesHelper.Factory.GetSourceOrDefault("Homebrew"); + IManagerSource caskSource = manager.SourcesHelper.Factory.GetSourceOrDefault("Homebrew Cask"); + + // Installed packages carry the source (Formula vs Cask) that updates should inherit. + var installed = new List + { + new Package("Fontconfig", "fontconfig", "2.17.1", formulaSource, manager), + new Package("Shaderc", "shaderc", "2026.2", formulaSource, manager), + new Package("Python@3.14", "python@3.14", "3.14.3_1", formulaSource, manager), + new Package("Firefox", "firefox", "152.0.5", caskSource, manager), + new Package("Visual Studio Code", "visual-studio-code", "1.90.0", caskSource, manager), + }; + + var updates = manager.ParseAvailableUpdates( + ReadFixtureLines(Path.Combine("Homebrew", "outdated-verbose.txt")), + installed + ); + + Assert.Collection( + updates, + package => + { + PackageAssert.Matches(package, "Fontconfig", "fontconfig", "2.17.1", "2.18.2"); + PackageAssert.BelongsTo(package, manager, formulaSource); + }, + package => + { + PackageAssert.Matches(package, "Shaderc", "shaderc", "2026.2", "2026.3"); + PackageAssert.BelongsTo(package, manager, formulaSource); + }, + package => + { + PackageAssert.Matches(package, "Python@3.14", "python@3.14", "3.14.3_1", "3.14.6"); + PackageAssert.BelongsTo(package, manager, formulaSource); + }, + // The two Casks below (matched via "!=") were the ones dropped by the old parser. + package => + { + PackageAssert.Matches(package, "Firefox", "firefox", "152.0.5", "152.0.6"); + PackageAssert.BelongsTo(package, manager, caskSource); + }, + package => + { + PackageAssert.Matches( + package, + "Visual Studio Code", + "visual-studio-code", + "1.90.0", + "1.91.0" + ); + PackageAssert.BelongsTo(package, manager, caskSource); + } + ); + } + + private static string[] ReadFixtureLines(string relativePath) + { + return PackageEngineFixtureFiles.ReadAllText(relativePath).Replace("\r\n", "\n").Split('\n'); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj index a978364ac2..43a92033f1 100644 --- a/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj +++ b/src/UniGetUI.PackageEngine.Tests/UniGetUI.PackageEngine.Tests.csproj @@ -31,6 +31,7 @@ + From 2631d30575971062f517454719e9e4142daf8c30 Mon Sep 17 00:00:00 2001 From: Gabriel Dufresne Date: Mon, 20 Jul 2026 14:43:14 -0400 Subject: [PATCH 2/2] use source-generated regex for outdated parsing --- src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs index 6c0f771b49..5797ccbe51 100644 --- a/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs +++ b/src/UniGetUI.PackageEngine.Managers.Homebrew/Homebrew.cs @@ -27,7 +27,7 @@ public HomebrewSource(IPackageManager manager, string name, Uri url) } } -public class Homebrew : PackageManager +public partial class Homebrew : PackageManager { // Standard Homebrew installation paths, in priority order private static readonly string[] BREW_PATHS = @@ -230,8 +230,8 @@ private IReadOnlyList ListInstalledByType(string typeFlag, string sourc // Formula: "name (old_version) < new_version" (versions are comparable) // Cask: "name (old_version) != new_version" (versions are opaque strings) // Both operators must be accepted, otherwise cask updates are silently dropped. - private static readonly Regex OutdatedLinePattern = - new(@"^(\S+)\s+\(([^)]+)\)\s+(?:<|!=)\s+(.+)$"); + [GeneratedRegex(@"^(\S+)\s+\(([^)]+)\)\s+(?:<|!=)\s+(.+)$")] + private static partial Regex OutdatedLineRegex(); protected override IReadOnlyList GetAvailableUpdates_UnSafe() { @@ -268,7 +268,7 @@ internal IReadOnlyList ParseAvailableUpdates( var packages = new List(); foreach (var line in lines) { - var m = OutdatedLinePattern.Match(line); + var m = OutdatedLineRegex().Match(line); if (!m.Success) continue; var id = m.Groups[1].Value.Trim();