From 34f3a5815a7636d6ab46b483cd2df749bd85d1fb Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 22 Sep 2026 15:20:47 -0400 Subject: [PATCH 1/5] Stop PowerShell from truncating Scoop listing output (#5414) --- .../Helpers/ScoopSourceHelper.cs | 5 +- .../Scoop.cs | 9 ++- .../ScoopManagerTests.cs | 58 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs index 6652267779..54a5fa3e62 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs @@ -49,7 +49,10 @@ protected override IReadOnlyList GetSources_UnSafe() StartInfo = new ProcessStartInfo { FileName = Manager.Status.ExecutablePath, - Arguments = Manager.Status.ExecutableCallArgs + " bucket list", + Arguments = + Manager.Status.ExecutableCallArgs + + " bucket list" + + Scoop.UntruncatedTableOutput, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index 07fcf51dce..ed76b6acf7 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -50,6 +50,9 @@ internal static string RequireSafePackageSpec(string spec) "removed,", ]; + internal const string UntruncatedTableOutput = + " | Format-Table -AutoSize | Out-String -Width 4096"; + private const int VersionProbeTimeout = 20_000; private const int StreamDrainTimeout = 5_000; @@ -446,7 +449,8 @@ protected override IReadOnlyList GetAvailableUpdates_UnSafe() StartInfo = new ProcessStartInfo { FileName = Status.ExecutablePath, - Arguments = Status.ExecutableCallArgs + " status -l", + Arguments = + Status.ExecutableCallArgs + " status -l" + UntruncatedTableOutput, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -472,7 +476,8 @@ private IReadOnlyList _getInstalledPackages_UnSafe() StartInfo = new ProcessStartInfo { FileName = Status.ExecutablePath, - Arguments = Status.ExecutableCallArgs + " list", + Arguments = + Status.ExecutableCallArgs + " list" + UntruncatedTableOutput, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs index 028dd28213..b043dfff1a 100644 --- a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -1,4 +1,5 @@ #if WINDOWS +using System.Diagnostics; using UniGetUI.Core.Data; using UniGetUI.Core.SettingsEngine; using UniGetUI.PackageEngine.Enums; @@ -8,6 +9,7 @@ using UniGetUI.PackageEngine.Structs; using UniGetUI.PackageEngine.Tests.Infrastructure.Assertions; using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; +using UniGetUI.PackageEngine.Tests.Infrastructure.Fakes; using UniGetUI.PackageEngine.Tests.Infrastructure.Helpers; using Architecture = UniGetUI.PackageEngine.Enums.Architecture; @@ -22,6 +24,11 @@ public sealed class ScoopManagerTestCollection [Collection(ScoopManagerTestCollection.Name)] public sealed class ScoopManagerTests : IDisposable { + private const string LongId = + "a-scoop-package-whose-manifest-name-is-long-enough-to-overflow-the-default-console-width-by-far"; + private const string LongVersion = "20260727133500-nightly"; + private const string LongNewVersion = "20260820144900-nightly"; + private readonly string _testRoot = Path.Combine( AppContext.BaseDirectory, nameof(ScoopManagerTests), @@ -367,6 +374,57 @@ public void OperationResultDoesNotElevateShimFailureWhenElevationProhibited() Assert.False(package.OverridenOptions.RunAsAdministrator); } + [Fact] + public void ParseAvailableUpdatesKeepsRowsThatOverflowTheDefaultConsoleWidth() + { + var manager = CreateManagerWithKnownSources("main"); + + var installedPackages = manager.ParseInstalledPackages( + RunPowerShellTable( + $"@(@('{LongId}','{LongVersion}'),@('7zip','26.03')) " + + "| ForEach-Object { [PSCustomObject][ordered]@{ Name = $_[0]; " + + "Version = $_[1]; Source = 'main' } }" + ) + ); + + var packages = manager.ParseAvailableUpdates( + RunPowerShellTable( + $"@(@('{LongId}','{LongVersion}','{LongNewVersion}'),@('7zip','26.03','26.04')) " + + "| ForEach-Object { [PSCustomObject][ordered]@{ Name = $_[0]; " + + "'Installed Version' = $_[1]; 'Latest Version' = $_[2] } }" + ), + installedPackages + ); + + var package = Assert.Single(packages, package => package.Id == LongId); + Assert.Equal(LongVersion, package.VersionString); + Assert.Equal(LongNewVersion, package.NewVersionString); + } + + private static string[] RunPowerShellTable(string script) + { + using Process p = new() + { + StartInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = + "-NoProfile -ExecutionPolicy Bypass -Command \"" + + script + + Scoop.UntruncatedTableOutput + + "\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = System.Text.Encoding.UTF8, + }, + }; + + p.Start(); + return [.. ScoopProcess.ReadLines(p, new TestProcessTaskLogger())]; + } + private static Scoop CreateManagerWithKnownSources(params string[] sourceNames) { var manager = new Scoop(); From 3eb1d0f7b677ce22c5385df1e0c92e489631c109 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 22 Sep 2026 15:35:35 -0400 Subject: [PATCH 2/5] Read Scoop update rows by column instead of collapsing whitespace --- .../Scoop.cs | 93 +++++++++++++------ .../Scoop/list-output-not-outdated.txt | 7 ++ .../Scoop/status-output-not-outdated.txt | 7 ++ .../ScoopManagerTests.cs | 17 ++++ 4 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-not-outdated.txt create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-not-outdated.txt diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index ed76b6acf7..3bd03a8265 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -252,42 +252,35 @@ IEnumerable installedPackages } List packages = []; - bool dashesPassed = false; - foreach (string line in lines) + IReadOnlyList? columns = null; + foreach (string rawLine in lines) { - if (!dashesPassed) - { - if (line.Contains("---")) - { - dashesPassed = true; - } - - continue; - } + string line = StripAnsiSequences(rawLine); - if (string.IsNullOrWhiteSpace(line)) + if (columns is null) { + columns = ReadColumnStarts(line); continue; } - string[] elements = Regex - .Replace(line, " {2,}", " ") - .Trim() - .Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (elements.Length < 3) + if (columns.Count < 3 || string.IsNullOrWhiteSpace(line)) { continue; } - for (int i = 0; i < elements.Length; i++) + string id = ReadColumn(line, columns, 0); + string version = ReadColumn(line, columns, 1); + string newVersion = ReadColumn(line, columns, 2); + + if (id.Length is 0 || version.Length is 0 || newVersion.Length is 0) { - elements[i] = elements[i].Trim(); + continue; } if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[2]) + FALSE_PACKAGE_IDS.Contains(id) + || FALSE_PACKAGE_VERSIONS.Contains(version) + || FALSE_PACKAGE_VERSIONS.Contains(newVersion) ) { continue; @@ -295,7 +288,7 @@ IEnumerable installedPackages if ( installedPackageMap.TryGetValue( - elements[0] + "." + elements[1], + id + "." + version, out IPackage? installedPackage ) ) @@ -303,10 +296,10 @@ out IPackage? installedPackage OverridenInstallationOptions options = new(installedPackage.OverridenOptions.Scope); packages.Add( new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - elements[2], + CoreTools.FormatAsName(id), + id, + version, + newVersion, installedPackage.Source, this, options @@ -318,6 +311,52 @@ out IPackage? installedPackage return packages; } + private static readonly Regex AnsiSequence = new( + @"\x1b\[[0-9;]*m", + RegexOptions.Compiled + ); + + private static string StripAnsiSequences(string line) => + line.Contains('\x1b') ? AnsiSequence.Replace(line, "") : line; + + private static IReadOnlyList? ReadColumnStarts(string line) + { + if (!line.Contains("---")) + { + return null; + } + + List starts = []; + for (int i = 0; i < line.Length; i++) + { + if (line[i] is not '-') + { + continue; + } + + starts.Add(i); + while (i < line.Length && line[i] is '-') + { + i++; + } + } + + return starts; + } + + private static string ReadColumn(string line, IReadOnlyList starts, int index) + { + int start = starts[index]; + if (start >= line.Length) + { + return ""; + } + + int end = + index + 1 < starts.Count ? Math.Min(starts[index + 1], line.Length) : line.Length; + return line[start..end].Trim(); + } + internal IReadOnlyList ParseInstalledPackages(IEnumerable lines) { List packages = []; diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-not-outdated.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-not-outdated.txt new file mode 100644 index 0000000000..277c5ad45e --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-not-outdated.txt @@ -0,0 +1,7 @@ +Name Version Source Updated Info +---- ------- ------ ------- ---- +outdated-app 1.0.0 main 2026-09-22 14:00:00 +missingdeps-app 1.2.3 main 2026-09-22 14:00:00 +deprecated-app 4.5.6 main 2026-09-22 14:00:00 +held-app 7.8.9 main 2026-09-22 14:00:00 Global install +failed-app 1.1.1 main 2026-09-22 14:00:00 diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-not-outdated.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-not-outdated.txt new file mode 100644 index 0000000000..419529a87f --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-not-outdated.txt @@ -0,0 +1,7 @@ +Name Installed Version Latest Version Missing Dependencies Info +---- ----------------- -------------- -------------------- ---- +outdated-app 1.0.0 2.0.0 +missingdeps-app 1.2.3 somedep | otherdep +deprecated-app 4.5.6 Deprecated +held-app 7.8.9 Held package +failed-app 1.1.1 Install failed diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs index b043dfff1a..57e6e4a788 100644 --- a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -374,6 +374,23 @@ public void OperationResultDoesNotElevateShimFailureWhenElevationProhibited() Assert.False(package.OverridenOptions.RunAsAdministrator); } + [Fact] + public void ParseAvailableUpdatesSkipsRowsListedWithoutANewerVersion() + { + var manager = CreateManagerWithKnownSources("main"); + var installedPackages = manager.ParseInstalledPackages( + ReadFixtureLines(@"Scoop\list-output-not-outdated.txt") + ); + + var packages = manager.ParseAvailableUpdates( + ReadFixtureLines(@"Scoop\status-output-not-outdated.txt"), + installedPackages + ); + + var package = Assert.Single(packages); + PackageAssert.Matches(package, "Outdated App", "outdated-app", "1.0.0", "2.0.0"); + } + [Fact] public void ParseAvailableUpdatesKeepsRowsThatOverflowTheDefaultConsoleWidth() { From 75b26ea60e5375ba725ce378ab852e103285e94e Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 22 Sep 2026 15:54:16 -0400 Subject: [PATCH 3/5] Read Scoop bucket rows by column so paths with spaces survive --- .../Helpers/ScoopSourceHelper.cs | 117 +++++++----------- .../Helpers/ScoopTable.cs | 53 ++++++++ .../Scoop.cs | 56 +-------- .../Scoop/bucket-list-output-spaced-path.txt | 4 + .../ScoopManagerTests.cs | 25 ++++ 5 files changed, 135 insertions(+), 120 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output-spaced-path.txt diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs index 54a5fa3e62..f7e1acf46a 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs @@ -77,81 +77,60 @@ protected override IReadOnlyList GetSources_UnSafe() internal IReadOnlyList ParseSources(IEnumerable lines) { List sources = []; - bool dashesPassed = false; + IReadOnlyList? columns = null; - foreach (string line in lines) + foreach (string rawLine in lines) { + string line = ScoopTable.StripAnsiSequences(rawLine); + + if (columns is null) + { + columns = ScoopTable.ReadColumnStarts(line); + continue; + } + + if (columns.Count < 4 || string.IsNullOrWhiteSpace(line)) + { + continue; + } + + string name = ScoopTable.ReadColumn(line, columns, 0); + string source = ScoopTable.ReadColumn(line, columns, 1); + string updated = ScoopTable.ReadColumn(line, columns, 2); + string manifests = ScoopTable.ReadColumn(line, columns, 3); + + if (name.Length is 0 || source.Length is 0 || manifests.Length is 0) + { + continue; + } + try { - if (!dashesPassed) - { - if (line.Contains("---")) - { - dashesPassed = true; - } - - continue; - } - - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - string[] elements = Regex - .Replace( - Regex.Replace(line, "[1234567890 :.-][AaPp][Mm][\\W]", "").Trim(), - " {2,}", - " " - ) - .Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (elements.Length < 5) - { - continue; - } - - if ( - !elements[1].Contains("https://") - && !elements[1].Contains("http://") - ) - { - elements[1] = Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "scoop", - "buckets", - elements[0].Trim() - ); - } - else - { - elements[1] = Regex.Replace(elements[1], @"^(.*)\.git$", "$1"); - } - - try - { - sources.Add( - new ManagerSource( - Manager, - elements[0].Trim(), - new Uri(elements[1]), - int.Parse(elements[4].Trim()), - elements[2].Trim() + " " + elements[3].Trim() - ) - ); - } - catch (Exception ex) - { - Logger.Warn(ex); - sources.Add( - new ManagerSource( + Uri url = + source.Contains("https://") || source.Contains("http://") + ? new Uri(Regex.Replace(source, @"^(.*)\.git$", "$1")) + : new Uri( + Path.Join( + Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile + ), + "scoop", + "buckets", + name + ) + ); + + sources.Add( + int.TryParse(manifests, out int packageCount) + ? new ManagerSource( Manager, - elements[0].Trim(), - new Uri(elements[1]), - -1, - "1/1/1970" + name, + url, + packageCount, + Regex.Replace(updated, @"\s+[AaPp][Mm]$", "") ) - ); - } + : new ManagerSource(Manager, name, url, -1, "1/1/1970") + ); } catch (Exception e) { diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs new file mode 100644 index 0000000000..907877e2f1 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace UniGetUI.PackageEngine.Managers.ScoopManager +{ + internal static class ScoopTable + { + private static readonly Regex AnsiSequence = new( + @"\x1b\[[0-9;]*m", + RegexOptions.Compiled + ); + + public static string StripAnsiSequences(string line) => + line.Contains('\x1b') ? AnsiSequence.Replace(line, "") : line; + + public static IReadOnlyList? ReadColumnStarts(string line) + { + if (!line.Contains("---")) + { + return null; + } + + List starts = []; + for (int i = 0; i < line.Length; i++) + { + if (line[i] is not '-') + { + continue; + } + + starts.Add(i); + while (i < line.Length && line[i] is '-') + { + i++; + } + } + + return starts; + } + + public static string ReadColumn(string line, IReadOnlyList starts, int index) + { + int start = starts[index]; + if (start >= line.Length) + { + return ""; + } + + int end = + index + 1 < starts.Count ? Math.Min(starts[index + 1], line.Length) : line.Length; + return line[start..end].Trim(); + } + } +} diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index 3bd03a8265..634e3ad461 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -255,11 +255,11 @@ IEnumerable installedPackages IReadOnlyList? columns = null; foreach (string rawLine in lines) { - string line = StripAnsiSequences(rawLine); + string line = ScoopTable.StripAnsiSequences(rawLine); if (columns is null) { - columns = ReadColumnStarts(line); + columns = ScoopTable.ReadColumnStarts(line); continue; } @@ -268,9 +268,9 @@ IEnumerable installedPackages continue; } - string id = ReadColumn(line, columns, 0); - string version = ReadColumn(line, columns, 1); - string newVersion = ReadColumn(line, columns, 2); + string id = ScoopTable.ReadColumn(line, columns, 0); + string version = ScoopTable.ReadColumn(line, columns, 1); + string newVersion = ScoopTable.ReadColumn(line, columns, 2); if (id.Length is 0 || version.Length is 0 || newVersion.Length is 0) { @@ -311,52 +311,6 @@ out IPackage? installedPackage return packages; } - private static readonly Regex AnsiSequence = new( - @"\x1b\[[0-9;]*m", - RegexOptions.Compiled - ); - - private static string StripAnsiSequences(string line) => - line.Contains('\x1b') ? AnsiSequence.Replace(line, "") : line; - - private static IReadOnlyList? ReadColumnStarts(string line) - { - if (!line.Contains("---")) - { - return null; - } - - List starts = []; - for (int i = 0; i < line.Length; i++) - { - if (line[i] is not '-') - { - continue; - } - - starts.Add(i); - while (i < line.Length && line[i] is '-') - { - i++; - } - } - - return starts; - } - - private static string ReadColumn(string line, IReadOnlyList starts, int index) - { - int start = starts[index]; - if (start >= line.Length) - { - return ""; - } - - int end = - index + 1 < starts.Count ? Math.Min(starts[index + 1], line.Length) : line.Length; - return line[start..end].Trim(); - } - internal IReadOnlyList ParseInstalledPackages(IEnumerable lines) { List packages = []; diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output-spaced-path.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output-spaced-path.txt new file mode 100644 index 0000000000..e5183d570a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/bucket-list-output-spaced-path.txt @@ -0,0 +1,4 @@ +Name Source Updated Manifests +---- ------ ------- --------- +main ~\AppData\Local\Temp\My Scoop\buckets\main 2026-09-22 3:46:27 PM 2 +extras ~\AppData\Local\Temp\My Scoop\buckets\extras 2026-09-22 3:46:27 PM 3 diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs index 57e6e4a788..ea9b0142e9 100644 --- a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -139,6 +139,31 @@ public void ParseAvailableUpdatesPreservesInstalledSourceAndScope() ); } + [Fact] + public void ParseSourcesKeepsLocalBucketsWhosePathContainsSpaces() + { + var manager = new Scoop(); + var helper = Assert.IsType(manager.SourcesHelper); + + var sources = helper.ParseSources(ReadFixtureLines(@"Scoop\bucket-list-output-spaced-path.txt")); + + Assert.Collection( + sources, + source => + { + Assert.Equal("main", source.Name); + Assert.Equal(2, source.PackageCount); + Assert.Equal("2026-09-22 3:46:27", source.UpdateDate); + }, + source => + { + Assert.Equal("extras", source.Name); + Assert.Equal(3, source.PackageCount); + Assert.Equal("2026-09-22 3:46:27", source.UpdateDate); + } + ); + } + [Fact] public void ParseSourcesNormalizesGitUrlsAndLocalBuckets() { From e4dedbd67b2eb8447bb456bd35ad95d29bf0f792 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 22 Sep 2026 16:11:22 -0400 Subject: [PATCH 4/5] Address the Copilot review on the Scoop bucket parser --- .../Helpers/ScoopSourceHelper.cs | 34 ++++++++++------ .../Helpers/ScoopTable.cs | 10 ++--- .../Fixtures/Scoop/status-output-ansi.txt | 7 ++++ .../ScoopManagerTests.cs | 40 ++++++++++++++----- 4 files changed, 61 insertions(+), 30 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-ansi.txt diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs index f7e1acf46a..9f3dafcef8 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopSourceHelper.cs @@ -106,19 +106,7 @@ internal IReadOnlyList ParseSources(IEnumerable lines) try { - Uri url = - source.Contains("https://") || source.Contains("http://") - ? new Uri(Regex.Replace(source, @"^(.*)\.git$", "$1")) - : new Uri( - Path.Join( - Environment.GetFolderPath( - Environment.SpecialFolder.UserProfile - ), - "scoop", - "buckets", - name - ) - ); + Uri url = BuildSourceUrl(name, source); sources.Add( int.TryParse(manifests, out int packageCount) @@ -140,5 +128,25 @@ internal IReadOnlyList ParseSources(IEnumerable lines) return sources; } + + private static Uri BuildSourceUrl(string name, string source) + { + if (source.Contains("https://") || source.Contains("http://")) + { + return new Uri(Regex.Replace(source, @"^(.*)\.git$", "$1")); + } + + string userProfile = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile + ); + string path = + source.StartsWith("~/") || source.StartsWith(@"~\") + ? Path.Join(userProfile, source[2..]) + : source; + + return Path.IsPathFullyQualified(path) + ? new Uri(path) + : new Uri(Path.Join(userProfile, "scoop", "buckets", name)); + } } } diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs index 907877e2f1..4d3c33b62e 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Helpers/ScoopTable.cs @@ -2,15 +2,13 @@ namespace UniGetUI.PackageEngine.Managers.ScoopManager { - internal static class ScoopTable + internal static partial class ScoopTable { - private static readonly Regex AnsiSequence = new( - @"\x1b\[[0-9;]*m", - RegexOptions.Compiled - ); + [GeneratedRegex(@"\x1b\[[0-9;]*m")] + private static partial Regex AnsiSequence(); public static string StripAnsiSequences(string line) => - line.Contains('\x1b') ? AnsiSequence.Replace(line, "") : line; + line.Contains('\x1b') ? AnsiSequence().Replace(line, "") : line; public static IReadOnlyList? ReadColumnStarts(string line) { diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-ansi.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-ansi.txt new file mode 100644 index 0000000000..8c6514c50d --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/status-output-ansi.txt @@ -0,0 +1,7 @@ +Name  Installed Version Latest Version Missing Dependencies Info +----  ----------------- -------------- -------------------- ---- +outdated-app 1.0.0 2.0.0 +missingdeps-app 1.2.3 somedep | otherdep +deprecated-app 4.5.6 Dep. +held-app 7.8.9 Hel. +failed-app 1.1.1 Ins. diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs index ea9b0142e9..8c063e46af 100644 --- a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -152,12 +152,14 @@ public void ParseSourcesKeepsLocalBucketsWhosePathContainsSpaces() source => { Assert.Equal("main", source.Name); + Assert.Equal(SpacedBucketUrl("main"), source.Url); Assert.Equal(2, source.PackageCount); Assert.Equal("2026-09-22 3:46:27", source.UpdateDate); }, source => { Assert.Equal("extras", source.Name); + Assert.Equal(SpacedBucketUrl("extras"), source.Url); Assert.Equal(3, source.PackageCount); Assert.Equal("2026-09-22 3:46:27", source.UpdateDate); } @@ -184,17 +186,7 @@ public void ParseSourcesNormalizesGitUrlsAndLocalBuckets() source => { Assert.Equal("extras", source.Name); - Assert.Equal( - new Uri( - Path.Join( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "scoop", - "buckets", - "extras" - ) - ), - source.Url - ); + Assert.Equal(new Uri(@"C:\Users\fixture\scoop\buckets\extras"), source.Url); Assert.Equal(321, source.PackageCount); Assert.Equal("2024-02-02 09:08:07", source.UpdateDate); } @@ -399,6 +391,23 @@ public void OperationResultDoesNotElevateShimFailureWhenElevationProhibited() Assert.False(package.OverridenOptions.RunAsAdministrator); } + [Fact] + public void ParseAvailableUpdatesReadsColumnsThroughAnsiColourCodes() + { + var manager = CreateManagerWithKnownSources("main"); + var installedPackages = manager.ParseInstalledPackages( + ReadFixtureLines(@"Scoop\list-output-not-outdated.txt") + ); + + var packages = manager.ParseAvailableUpdates( + ReadFixtureLines(@"Scoop\status-output-ansi.txt"), + installedPackages + ); + + var package = Assert.Single(packages); + PackageAssert.Matches(package, "Outdated App", "outdated-app", "1.0.0", "2.0.0"); + } + [Fact] public void ParseAvailableUpdatesSkipsRowsListedWithoutANewerVersion() { @@ -467,6 +476,15 @@ private static string[] RunPowerShellTable(string script) return [.. ScoopProcess.ReadLines(p, new TestProcessTaskLogger())]; } + private static Uri SpacedBucketUrl(string bucket) => + new( + Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + @"AppData\Local\Temp\My Scoop\buckets", + bucket + ) + ); + private static Scoop CreateManagerWithKnownSources(params string[] sourceNames) { var manager = new Scoop(); From 00c04164242f82239975b5685fcbcba14531a5bb Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Tue, 22 Sep 2026 16:33:24 -0400 Subject: [PATCH 5/5] Read Scoop installed rows by column so empty and spaced sources survive --- .../Scoop.cs | 60 +++++++------------ .../Fixtures/Scoop/list-output-edge-cases.txt | 7 +++ .../Fixtures/Scoop/list-output.txt | 10 ++-- .../ScoopManagerTests.cs | 32 ++++++++++ 4 files changed, 64 insertions(+), 45 deletions(-) create mode 100644 src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-edge-cases.txt diff --git a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs index 634e3ad461..4cfeae59b5 100644 --- a/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs +++ b/src/UniGetUI.PackageEngine.Managers.Scoop/Scoop.cs @@ -1,5 +1,4 @@ using System.Diagnostics; -using System.Text.RegularExpressions; using UniGetUI.Core.Classes; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; @@ -314,68 +313,49 @@ out IPackage? installedPackage internal IReadOnlyList ParseInstalledPackages(IEnumerable lines) { List packages = []; - bool dashesPassed = false; - foreach (string line in lines) + IReadOnlyList? columns = null; + foreach (string rawLine in lines) { - if (!dashesPassed) - { - if (line.Contains("---")) - { - dashesPassed = true; - } - - continue; - } + string line = ScoopTable.StripAnsiSequences(rawLine); - if (string.IsNullOrWhiteSpace(line)) + if (columns is null) { + columns = ScoopTable.ReadColumnStarts(line); continue; } - string[] elements = Regex - .Replace(line, " {2,}", " ") - .Trim() - .Split(' ', StringSplitOptions.RemoveEmptyEntries); - if (elements.Length < 3) + if (columns.Count < 3 || string.IsNullOrWhiteSpace(line)) { continue; } - if (elements[2].Contains(":\\")) - { - var path = Regex.Match( - line, - "[A-Za-z]:(?:[\\\\\\/][^\\\\\\/\\n]+)+(?:.json|…)" - ); - if (!string.IsNullOrEmpty(path.Value)) - { - elements[2] = path.Value; - } - } + string id = ScoopTable.ReadColumn(line, columns, 0); + string version = ScoopTable.ReadColumn(line, columns, 1); + string source = ScoopTable.ReadColumn(line, columns, 2); + string info = columns.Count > 4 ? ScoopTable.ReadColumn(line, columns, 4) : ""; - for (int i = 0; i < elements.Length; i++) + if (id.Length is 0 || version.Length is 0) { - elements[i] = elements[i].Trim(); + continue; } - if ( - FALSE_PACKAGE_IDS.Contains(elements[0]) - || FALSE_PACKAGE_VERSIONS.Contains(elements[1]) - ) + if (FALSE_PACKAGE_IDS.Contains(id) || FALSE_PACKAGE_VERSIONS.Contains(version)) { continue; } OverridenInstallationOptions options = new( - line.Contains("Global install") ? PackageScope.Global : PackageScope.User + info.Contains("Global install") ? PackageScope.Global : PackageScope.User ); packages.Add( new Package( - CoreTools.FormatAsName(elements[0]), - elements[0], - elements[1], - SourcesHelper.Factory.GetSourceOrDefault(elements[2]), + CoreTools.FormatAsName(id), + id, + version, + source.Length is 0 + ? Properties.DefaultSource + : SourcesHelper.Factory.GetSourceOrDefault(source), this, options ) diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-edge-cases.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-edge-cases.txt new file mode 100644 index 0000000000..8c023ad96a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output-edge-cases.txt @@ -0,0 +1,7 @@ +Installed apps: +WARN error: Could not find a part of the path 'C:\Users\Jane Doe\git\Extras\bucket\spaced-app.json'. +Name Version Source Updated Info +---- ------- ------ ------- ---- +normal-app 3.0.0 main 2026-09-22 16:26:39 +orphan-app 1.0.0 2026-09-22 16:26:39 +spaced-app 2.0.0 C:\Users\Jane Doe\git\Extras\bucket\spaced-app.json 2026-09-22 16:26:39 diff --git a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt index 90cec9e5f3..999c8ff56a 100644 --- a/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt +++ b/src/UniGetUI.PackageEngine.Tests/Fixtures/Scoop/list-output.txt @@ -1,5 +1,5 @@ -Name Version Source ----- ------- ------ -git 2.47.1 main -pwsh 7.4.6 versions Global install -No packages installed +Installed apps: +Name Version Source Updated Info +---- ------- ------ ------- ---- +git 2.47.1 main 2026-09-22 16:26:39 +pwsh 7.4.6 versions 2026-09-22 16:26:39 Global install diff --git a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs index 8c063e46af..857cbc27c9 100644 --- a/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ScoopManagerTests.cs @@ -391,6 +391,38 @@ public void OperationResultDoesNotElevateShimFailureWhenElevationProhibited() Assert.False(package.OverridenOptions.RunAsAdministrator); } + [Fact] + public void ParseInstalledPackagesReadsSourcesThatAreEmptyOrContainSpaces() + { + var manager = CreateManagerWithKnownSources("main"); + + var packages = manager.ParseInstalledPackages( + ReadFixtureLines(@"Scoop\list-output-edge-cases.txt") + ); + + Assert.Collection( + packages, + package => + { + PackageAssert.Matches(package, "Normal App", "normal-app", "3.0.0"); + Assert.Equal("main", package.Source.Name); + }, + package => + { + PackageAssert.Matches(package, "Orphan App", "orphan-app", "1.0.0"); + Assert.Same(manager.DefaultSource, package.Source); + }, + package => + { + PackageAssert.Matches(package, "Spaced App", "spaced-app", "2.0.0"); + Assert.Equal( + @"C:\Users\Jane Doe\git\Extras\bucket\spaced-app.json", + package.Source.Name + ); + } + ); + } + [Fact] public void ParseAvailableUpdatesReadsColumnsThroughAnsiColourCodes() {