diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 53175f0afa..2e16b1d0c4 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -290,6 +290,7 @@ LEBOM lhs LIBYAML liv +listsort liwpx localizationpriority localsource diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index ad5dd4dc9a..0cd5e0c75c 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -73,6 +73,10 @@ Added a user setting (`logging.fileNameStrategy`) for controlling the default na | guid | The log name is a GUID | | shortguid | The log name is the first 8 characters of a GUID | +### Sortable `list` output + +`winget list` now supports sorting results via `--sort ` (repeatable for multi-field sorting), `--ascending`/`--descending` direction flags, and a persistent `output.sortOrder` setting. Available sort fields: `name`, `id`, `version`, `source`, `available`, `relevance`. By default, results are sorted alphabetically by name when no query is present; use `--sort relevance` to preserve the previous source-determined ordering. + ## Bug Fixes * `winget export` now works when the destination path is a hidden file diff --git a/doc/windows/package-manager/winget/list.md b/doc/windows/package-manager/winget/list.md index af918a0e38..81a91dc5b5 100644 --- a/doc/windows/package-manager/winget/list.md +++ b/doc/windows/package-manager/winget/list.md @@ -50,6 +50,9 @@ The options allow you to customize the list experience to meet your needs. | **--upgrade-available** | Lists only packages which have an upgrade available. | | **-u,--unknown,--include-unknown** | List packages even if their current version cannot be determined. Can only be used with the --upgrade-available argument. | | **--pinned,--include-pinned** | List packages even if they have a pin that prevents upgrade. Can only be used with the --upgrade-available argument. | +| **--sort** | Sort results by a property. Can be repeated for multi-field sorting (e.g., `--sort source --sort name`). Valid values: `name`, `id`, `version`, `source`, `available`, `relevance`. | +| **--ascending,--asc** | Sort results in ascending order (default). | +| **--descending,--desc** | Sort results in descending order. | | **-?,--help** | Get additional help on this command. | | **--wait** | Prompts the user to press any key before exiting. | | **--logs,--open-logs** | Open the default logs location. | @@ -70,6 +73,42 @@ The following example limits the output of list to 9 apps. ![list count command](images/list-count.png) +## Sorting output + +By default, results are sorted by name in ascending order. When a query argument is used (for example, `winget list foo`), results preserve relevance ordering from the package source. You can override either default through command-line arguments or user settings. + +### Sort via command-line arguments + +Use `--sort` to sort by one or more fields. When multiple `--sort` options are specified, results are sorted by the first field, then ties are broken by the second field, and so on. + +```cmd +winget list --sort name +winget list --sort source --sort name +winget list --sort name --descending +``` + +### Sort via user settings + +You can set a default sort order in your [settings](https://aka.ms/winget-settings) under `output.sortOrder`: + +```json +{ + "output": { + "sortOrder": ["source", "name"] + } +} +``` + +An empty array (`[]`) results in default sorting (sorted by name when listing, relevance preserved when querying). + +### Resolution order + +When both settings and command-line arguments are present, the following priority applies: + +1. **`--sort` command-line argument** — takes highest priority, overrides settings. +2. **`output.sortOrder` in settings** — used when no `--sort` argument is provided. If the user has configured a sort order in settings, it is applied even when a query is present. +3. **Default** — sorted by name in ascending order. When a query is used, relevance ordering is preserved instead. + ## List with Update As stated above, the **list** command allows you to see what apps you have installed that have updates available. diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj index 2106c1faba..19a4f62cc3 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj @@ -366,6 +366,7 @@ + @@ -459,6 +460,7 @@ + diff --git a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters index 3a939771c6..f6e9ca7dd2 100644 --- a/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters +++ b/src/AppInstallerCLICore/AppInstallerCLICore.vcxproj.filters @@ -50,6 +50,9 @@ Workflows + + Workflows + Workflows @@ -322,6 +325,9 @@ Workflows + + Workflows + Commands diff --git a/src/AppInstallerCLICore/Command.cpp b/src/AppInstallerCLICore/Command.cpp index 7da363c616..b24ce3050d 100644 --- a/src/AppInstallerCLICore/Command.cpp +++ b/src/AppInstallerCLICore/Command.cpp @@ -806,6 +806,19 @@ namespace AppInstaller::CLI } } + if (execArgs.Contains(Execution::Args::Type::Sort)) + { + for (const auto& arg : *execArgs.GetArgs(Execution::Args::Type::Sort)) + { + if (!Settings::ConvertToSortField(arg)) + { + auto validOptions = Utility::Join(", "_liv, std::vector{ + "name"_lis, "id"_lis, "version"_lis, "source"_lis, "available"_lis, "relevance"_lis }); + throw CommandException(Resource::String::InvalidArgumentValueError(ArgumentCommon::ForType(Execution::Args::Type::Sort).Name, validOptions)); + } + } + } + Argument::ValidateExclusiveArguments(execArgs); ValidateArgumentsInternal(execArgs); diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp index acb12bdd46..310a8038e0 100644 --- a/src/AppInstallerCLICore/Commands/ListCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp @@ -32,7 +32,7 @@ namespace AppInstaller::CLI Argument{ Execution::Args::Type::IncludeUnknown, Resource::String::IncludeUnknownInListArgumentDescription, ArgumentType::Flag }, Argument{ Execution::Args::Type::IncludePinned, Resource::String::IncludePinnedInListArgumentDescription, ArgumentType::Flag}, Argument::ForType(Execution::Args::Type::ListDetails), - Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard }, + Argument{ Execution::Args::Type::Sort, Resource::String::SortArgumentDescription, ArgumentType::Standard }.SetCountLimit(6), Argument{ Execution::Args::Type::SortAscending, Resource::String::SortAscendingArgumentDescription, ArgumentType::Flag }, Argument{ Execution::Args::Type::SortDescending, Resource::String::SortDescendingArgumentDescription, ArgumentType::Flag }, }; diff --git a/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.cpp b/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.cpp new file mode 100644 index 0000000000..2f06cf9550 --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.cpp @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "PackageTableSortHelper.h" + +namespace AppInstaller::CLI::Workflow +{ + SortablePackageEntry::SortablePackageEntry( + size_t originalIndex, + std::string_view name, + std::string_view id, + std::string_view installedVersion, + std::string_view availableVersion, + std::string_view source, + Settings::SortField fieldMask) + : OriginalIndex(originalIndex) + { + if (WI_IsFlagSet(fieldMask, Settings::SortField::Name)) + { + FoldedName = Utility::FoldCase(name); + } + if (WI_IsFlagSet(fieldMask, Settings::SortField::Id)) + { + FoldedId = Utility::FoldCase(id); + } + if (WI_IsFlagSet(fieldMask, Settings::SortField::Source)) + { + FoldedSource = Utility::FoldCase(source); + } + if (WI_IsFlagSet(fieldMask, Settings::SortField::Version)) + { + ParsedInstalledVersion = Utility::Version{ std::string{ installedVersion } }; + } + if (WI_IsFlagSet(fieldMask, Settings::SortField::Available)) + { + HasAvailableVersion = !availableVersion.empty(); + if (HasAvailableVersion) + { + ParsedAvailableVersion = Utility::Version{ std::string{ availableVersion } }; + } + } + } + + Settings::SortField ComputeSortFieldMask(const std::vector& sortFields) + { + Settings::SortField mask = Settings::SortField::Relevance; + for (const auto& f : sortFields) + { + mask |= f; + } + return mask; + } + + int CompareByField(const SortablePackageEntry& a, const SortablePackageEntry& b, Settings::SortField field) + { + using Settings::SortField; + + switch (field) + { + case SortField::Name: + return a.FoldedName.compare(b.FoldedName); + case SortField::Id: + return a.FoldedId.compare(b.FoldedId); + case SortField::Version: + { + if (a.ParsedInstalledVersion < b.ParsedInstalledVersion) return -1; + if (b.ParsedInstalledVersion < a.ParsedInstalledVersion) return 1; + return 0; + } + case SortField::Source: + return a.FoldedSource.compare(b.FoldedSource); + case SortField::Available: + { + if (a.HasAvailableVersion != b.HasAvailableVersion) + { + // Ascending: has-update sorts before no-update + return a.HasAvailableVersion ? -1 : 1; + } + if (a.HasAvailableVersion && b.HasAvailableVersion) + { + if (a.ParsedAvailableVersion < b.ParsedAvailableVersion) return -1; + if (b.ParsedAvailableVersion < a.ParsedAvailableVersion) return 1; + } + return 0; + } + default: + return 0; + } + } + + void SortEntries( + std::vector& entries, + const std::vector& sortFields, + Settings::SortDirection direction) + { + if (entries.size() <= 1 || sortFields.empty()) + { + return; + } + + // Relevance-only means no sorting + if (sortFields.size() == 1 && sortFields[0] == Settings::SortField::Relevance) + { + return; + } + + std::stable_sort(entries.begin(), entries.end(), + [&sortFields, direction](const SortablePackageEntry& a, const SortablePackageEntry& b) + { + for (const auto& field : sortFields) + { + int cmp = CompareByField(a, b, field); + if (cmp != 0) + { + return direction == Settings::SortDirection::Ascending ? (cmp < 0) : (cmp > 0); + } + } + return false; + }); + } +} diff --git a/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.h b/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.h new file mode 100644 index 0000000000..8ea2ad11ff --- /dev/null +++ b/src/AppInstallerCLICore/Workflows/PackageTableSortHelper.h @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#pragma once +#include +#include +#include + +#include +#include + +namespace AppInstaller::CLI::Workflow +{ + // Lightweight sortable representation of a package row with precomputed sort keys. + // Decoupled from ICompositePackage/IPackageVersion to ease unit testing. + struct SortablePackageEntry + { + size_t OriginalIndex = 0; + + // Precomputed case-folded sort keys + std::string FoldedName; + std::string FoldedId; + std::string FoldedSource; + + // Precomputed parsed versions + Utility::Version ParsedInstalledVersion; + Utility::Version ParsedAvailableVersion; + bool HasAvailableVersion = false; + + SortablePackageEntry() = default; + + SortablePackageEntry( + size_t originalIndex, + std::string_view name, + std::string_view id, + std::string_view installedVersion, + std::string_view availableVersion, + std::string_view source, + Settings::SortField fieldMask); + }; + + // Compares two sortable entries by the given field using precomputed sort keys. + // Returns negative if a < b, positive if a > b, 0 if equal. + int CompareByField(const SortablePackageEntry& a, const SortablePackageEntry& b, Settings::SortField field); + + // Sorts a vector of sortable entries by the given fields and direction. + void SortEntries( + std::vector& entries, + const std::vector& sortFields, + Settings::SortDirection direction); + + // Computes a bitmask of all sort fields so the constructor can skip unused fields. + Settings::SortField ComputeSortFieldMask(const std::vector& sortFields); + + // Sorts a vector of arbitrary items by projecting each into a SortablePackageEntry + // via a caller-supplied converter, sorting the projections, then reordering the + // source vector to match. The converter signature is: + // SortablePackageEntry converter(const T& item, size_t index) + // The caller is responsible for pre-computing the SortFieldMask and capturing + // it in the converter closure so the SortablePackageEntry constructor only + // initializes the fields actually needed for comparison. + template + void SortBy( + std::vector& items, + Converter&& converter, + const std::vector& sortFields, + Settings::SortDirection direction) + { + if (items.size() <= 1 || sortFields.empty()) + { + return; + } + + if (sortFields.size() == 1 && sortFields[0] == Settings::SortField::Relevance) + { + return; + } + + std::vector entries; + entries.reserve(items.size()); + for (size_t i = 0; i < items.size(); ++i) + { + entries.push_back(converter(items[i], i)); + } + + SortEntries(entries, sortFields, direction); + + std::vector sorted; + sorted.reserve(items.size()); + for (const auto& entry : entries) + { + sorted.push_back(std::move(items[entry.OriginalIndex])); + } + items = std::move(sorted); + } +} diff --git a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp index 9ee72f47db..7cf2f8644d 100644 --- a/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp +++ b/src/AppInstallerCLICore/Workflows/WorkflowBase.cpp @@ -3,11 +3,13 @@ #include "pch.h" #include "WorkflowBase.h" #include "ExecutionContext.h" -#include +#include "PackageTableSortHelper.h" #include "PromptFlow.h" #include "ShowFlow.h" #include "Sixel.h" #include "TableOutput.h" +#include +#include #include #include #include @@ -459,8 +461,103 @@ namespace AppInstaller::CLI::Workflow } } + // Sorts a vector of InstalledPackagesTableLine according to the user's sort preferences. + // Resolution order: CLI args (--sort) > settings (output.sortOrder) > query-aware default. + void SortInstalledPackagesTableLines(Execution::Context& context, std::vector& lines) + { + if (lines.size() <= 1) + { + return; + } + + // 1. Determine sort fields: CLI --sort overrides everything + std::vector sortFields; + bool hasExplicitSort = context.Args.Contains(Execution::Args::Type::Sort); + + if (hasExplicitSort) + { + for (const auto& arg : *context.Args.GetArgs(Execution::Args::Type::Sort)) + { + auto field = ConvertToSortField(arg); + if (field) + { + sortFields.emplace_back(field.value()); + } + else + { + // Invalid values should not reach here; ValidateArguments + // rejects them with a CommandException before workflow execution begins. + FAIL_FAST_MSG("Unexpected sort field value reached workflow; validation should have caught this."); + } + } + } + else + { + sortFields = User().Get(); + + if (sortFields.empty()) + { + if (context.Args.Contains(Execution::Args::Type::Query) || + context.Args.Contains(Execution::Args::Type::MultiQuery)) + { + // When the free-text query argument is present and the user has NOT + // configured a sort preference in settings, preserve relevance ordering. + // Only the positional query argument populates searchRequest.Query and + // produces meaningful relevance ranking; filter arguments like --id, + // --name, --tag etc. use exact/substring matching where all results + // have equivalent relevance. + // If the user explicitly configured output.sortOrder, respect it even + // with queries — that is an explicit user preference. + return; + } + + // No settings configured and no query — apply default sort by name + // so that output is deterministic and user-friendly. + sortFields.emplace_back(SortField::Name); + } + } + + // Relevance-only means preserve source ordering — no sorting needed. + if (sortFields.size() == 1 && sortFields[0] == SortField::Relevance) + { + return; + } + + // 2. Determine direction: CLI flags override settings + SortDirection direction = SortDirection::Ascending; + if (context.Args.Contains(Execution::Args::Type::SortDescending)) + { + direction = SortDirection::Descending; + } + else if (context.Args.Contains(Execution::Args::Type::SortAscending)) + { + direction = SortDirection::Ascending; + } + else + { + direction = User().Get(); + } + + // 3. Sort using the helper's production pipeline + const SortField mask = ComputeSortFieldMask(sortFields); + SortBy(lines, + [mask](const InstalledPackagesTableLine& line, size_t index) { + return SortablePackageEntry( + index, + line.Name.get(), + line.Id.get(), + line.InstalledVersion.get(), + line.AvailableVersion.get(), + line.Source.get(), + mask); + }, + sortFields, direction); + } + void OutputInstalledPackages(Execution::Context& context, std::vector& lines) { + SortInstalledPackagesTableLines(context, lines); + if (context.Args.Contains(Execution::Args::Type::ListDetails)) { OutputInstalledPackagesDetails(context, lines); diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj index f796e3d925..95b088d983 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj @@ -302,6 +302,7 @@ + diff --git a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters index 5ac6008d1d..d04ebb0d02 100644 --- a/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters +++ b/src/AppInstallerCLITests/AppInstallerCLITests.vcxproj.filters @@ -236,6 +236,9 @@ Source Files + + Source Files\CLI + Source Files\Common diff --git a/src/AppInstallerCLITests/Command.cpp b/src/AppInstallerCLITests/Command.cpp index 13345d67c6..f80cf66cfc 100644 --- a/src/AppInstallerCLITests/Command.cpp +++ b/src/AppInstallerCLITests/Command.cpp @@ -4,7 +4,9 @@ #include "TestCommon.h" #include #include +#include #include +#include using namespace std::string_literals; using namespace std::string_view_literals; @@ -665,3 +667,55 @@ TEST_CASE("ParseArguments_PositionalWithTooManyValues", "[command]") REQUIRE_COMMAND_EXCEPTION(command.ParseArguments(inv, args), CLI::Resource::String::ExtraPositionalError(Utility::LocIndView{ values.back() })); } + +TEST_CASE("EnsureListSortFieldCountMatchesLimit", "[command]") +{ + // Verifies that the ListCommand --sort argument count limit matches + // the number of valid SortField enum values. If a new SortField is + // added but SetCountLimit is not updated (or vice versa), this fails. + ListCommand listCmd(""); + auto args = listCmd.GetArguments(); + + size_t sortLimit = 0; + for (const auto& arg : args) + { + if (arg.ExecArgType() == Execution::Args::Type::Sort) + { + sortLimit = arg.Limit(); + break; + } + } + REQUIRE(sortLimit > 0); + + // Every valid sort field string must convert successfully. + std::vector allFieldNames = { + "relevance", "name", "id", "version", "source", "available" + }; + + REQUIRE(allFieldNames.size() == sortLimit); + + for (const auto& name : allFieldNames) + { + auto field = Settings::ConvertToSortField(name); + REQUIRE(field.has_value()); + } +} + +TEST_CASE("ValidateArguments_StandardArgExceedsCountLimit", "[command]") +{ + Args args; + TestCommand command({ + Argument{ "sort", 's', Args::Type::Sort, DefaultDesc, ArgumentType::Standard }.SetCountLimit(6), + }); + + // All 6 valid sort fields plus a duplicate — 7 total exceeds the limit of 6. + Invocation inv{ std::vector{ + "--sort", "name", "--sort", "id", "--sort", "version", + "--sort", "source", "--sort", "available", "--sort", "relevance", + "--sort", "name" } }; + + command.ParseArguments(inv, args); + REQUIRE(args.GetCount(Args::Type::Sort) == 7); + + REQUIRE_COMMAND_EXCEPTION(command.ValidateArguments(args), CLI::Resource::String::TooManyArgError(ArgumentCommon::ForType(Args::Type::Sort).Name)); +} \ No newline at end of file diff --git a/src/AppInstallerCLITests/PackageTableSortHelper.cpp b/src/AppInstallerCLITests/PackageTableSortHelper.cpp new file mode 100644 index 0000000000..eda092d92c --- /dev/null +++ b/src/AppInstallerCLITests/PackageTableSortHelper.cpp @@ -0,0 +1,385 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#include "pch.h" +#include "TestCommon.h" +#include "Workflows/PackageTableSortHelper.h" + +using namespace AppInstaller::CLI::Workflow; +using namespace AppInstaller::Settings; + +namespace +{ + // Use all-fields mask for tests so every field is precomputed. + constexpr SortField AllFieldsMask = + SortField::Name | SortField::Id | + SortField::Version | SortField::Source | + SortField::Available; + + SortablePackageEntry MakeEntry(std::string name, std::string id, std::string version, std::string available = {}, std::string source = {}) + { + return SortablePackageEntry{ 0, name, id, version, available, source, AllFieldsMask }; + } + + // Validates that all precomputed fields in actual match expected, entry by entry. + void ValidateSortResult( + const std::vector& actual, + const std::vector& expected) + { + REQUIRE(actual.size() == expected.size()); + for (size_t i = 0; i < actual.size(); ++i) + { + INFO("Entry index: " << i); + REQUIRE(actual[i].FoldedName == expected[i].FoldedName); + REQUIRE(actual[i].FoldedId == expected[i].FoldedId); + REQUIRE(actual[i].FoldedSource == expected[i].FoldedSource); + REQUIRE(actual[i].HasAvailableVersion == expected[i].HasAvailableVersion); + REQUIRE(actual[i].ParsedInstalledVersion == expected[i].ParsedInstalledVersion); + REQUIRE(actual[i].ParsedAvailableVersion == expected[i].ParsedAvailableVersion); + } + } + +} + +TEST_CASE("ListSort_CompareByField_Name", "[listsort]") +{ + auto a = MakeEntry("Alpha", "a.id", "1.0"); + auto b = MakeEntry("Beta", "b.id", "1.0"); + + SECTION("Less than") + { + REQUIRE(CompareByField(a, b, SortField::Name) < 0); + } + SECTION("Greater than") + { + REQUIRE(CompareByField(b, a, SortField::Name) > 0); + } + SECTION("Equal") + { + REQUIRE(CompareByField(a, a, SortField::Name) == 0); + } + SECTION("Case-insensitive") + { + auto upper = MakeEntry("ALPHA", "a.id", "1.0"); + REQUIRE(CompareByField(a, upper, SortField::Name) == 0); + } +} + +TEST_CASE("ListSort_CompareByField_Id", "[listsort]") +{ + auto a = MakeEntry("Name", "com.alpha", "1.0"); + auto b = MakeEntry("Name", "com.beta", "1.0"); + + REQUIRE(CompareByField(a, b, SortField::Id) < 0); + REQUIRE(CompareByField(b, a, SortField::Id) > 0); + + SECTION("Case-insensitive") + { + auto upper = MakeEntry("Name", "COM.ALPHA", "1.0"); + REQUIRE(CompareByField(a, upper, SortField::Id) == 0); + } +} + +TEST_CASE("ListSort_CompareByField_Version", "[listsort]") +{ + auto v1 = MakeEntry("App", "app", "1.0.0"); + auto v2 = MakeEntry("App", "app", "2.0.0"); + auto v10 = MakeEntry("App", "app", "10.0.0"); + + SECTION("Semantic ordering") + { + REQUIRE(CompareByField(v1, v2, SortField::Version) < 0); + REQUIRE(CompareByField(v2, v1, SortField::Version) > 0); + } + SECTION("Numeric not lexicographic - 10.0 > 2.0") + { + REQUIRE(CompareByField(v10, v2, SortField::Version) > 0); + } + SECTION("Equal versions") + { + REQUIRE(CompareByField(v1, v1, SortField::Version) == 0); + } +} + +TEST_CASE("ListSort_CompareByField_Source", "[listsort]") +{ + auto a = MakeEntry("App", "app", "1.0", "", "msstore"); + auto b = MakeEntry("App", "app", "1.0", "", "winget"); + + REQUIRE(CompareByField(a, b, SortField::Source) < 0); + REQUIRE(CompareByField(b, a, SortField::Source) > 0); +} + +TEST_CASE("ListSort_CompareByField_Available", "[listsort]") +{ + auto withUpdate = MakeEntry("App", "app", "1.0", "2.0"); + auto noUpdate = MakeEntry("App", "app", "1.0", ""); + auto higherUpdate = MakeEntry("App", "app", "1.0", "3.0"); + + SECTION("Has-update before no-update in ascending") + { + REQUIRE(CompareByField(withUpdate, noUpdate, SortField::Available) < 0); + REQUIRE(CompareByField(noUpdate, withUpdate, SortField::Available) > 0); + } + SECTION("Both have updates - compare versions") + { + REQUIRE(CompareByField(withUpdate, higherUpdate, SortField::Available) < 0); + REQUIRE(CompareByField(higherUpdate, withUpdate, SortField::Available) > 0); + } + SECTION("Both empty - equal") + { + REQUIRE(CompareByField(noUpdate, noUpdate, SortField::Available) == 0); + } +} + +TEST_CASE("ListSort_SortEntries_ByName_Ascending", "[listsort]") +{ + std::vector entries = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + }; + + SortEntries(entries, { SortField::Name }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Alpha", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + MakeEntry("Charlie", "c", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_ByName_Descending", "[listsort]") +{ + std::vector entries = { + MakeEntry("Alpha", "a", "1.0"), + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Beta", "b", "1.0"), + }; + + SortEntries(entries, { SortField::Name }, SortDirection::Descending); + + std::vector expected = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Beta", "b", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_ByName_CaseInsensitive", "[listsort]") +{ + std::vector entries = { + MakeEntry("charlie", "c", "1.0"), + MakeEntry("ALPHA", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + }; + + SortEntries(entries, { SortField::Name }, SortDirection::Ascending); + + // Expected uses same casing as input — ValidateSortResult compares folded values + std::vector expected = { + MakeEntry("ALPHA", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + MakeEntry("charlie", "c", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_ById", "[listsort]") +{ + std::vector entries = { + MakeEntry("Z App", "com.zeta", "1.0"), + MakeEntry("A App", "com.alpha", "1.0"), + MakeEntry("M App", "com.mu", "1.0"), + }; + + SortEntries(entries, { SortField::Id }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("A App", "com.alpha", "1.0"), + MakeEntry("M App", "com.mu", "1.0"), + MakeEntry("Z App", "com.zeta", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_ByVersion", "[listsort]") +{ + std::vector entries = { + MakeEntry("App C", "c", "10.0.0"), + MakeEntry("App A", "a", "2.0.0"), + MakeEntry("App B", "b", "1.0.0"), + }; + + SortEntries(entries, { SortField::Version }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("App B", "b", "1.0.0"), + MakeEntry("App A", "a", "2.0.0"), + MakeEntry("App C", "c", "10.0.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_MultiField", "[listsort]") +{ + std::vector entries = { + MakeEntry("Beta", "b.2", "2.0"), + MakeEntry("Alpha", "a.1", "1.0"), + MakeEntry("Beta", "b.1", "1.0"), + MakeEntry("Alpha", "a.2", "2.0"), + }; + + SortEntries(entries, { SortField::Name, SortField::Id }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Alpha", "a.1", "1.0"), + MakeEntry("Alpha", "a.2", "2.0"), + MakeEntry("Beta", "b.1", "1.0"), + MakeEntry("Beta", "b.2", "2.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_Available_GroupsByPresence", "[listsort]") +{ + std::vector entries = { + MakeEntry("NoUpdate1", "n1", "1.0", ""), + MakeEntry("HasUpdate", "h1", "1.0", "2.0"), + MakeEntry("NoUpdate2", "n2", "1.0", ""), + }; + + SortEntries(entries, { SortField::Available }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("HasUpdate", "h1", "1.0", "2.0"), + MakeEntry("NoUpdate1", "n1", "1.0", ""), + MakeEntry("NoUpdate2", "n2", "1.0", ""), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_Relevance_NoOp", "[listsort]") +{ + std::vector entries = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + }; + + // Relevance means preserve original order + SortEntries(entries, { SortField::Relevance }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + MakeEntry("Beta", "b", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_EmptyFields_NoOp", "[listsort]") +{ + std::vector entries = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + }; + + // Empty sort fields means no sorting + SortEntries(entries, {}, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Charlie", "c", "1.0"), + MakeEntry("Alpha", "a", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_SingleElement", "[listsort]") +{ + std::vector entries = { + MakeEntry("Only", "only", "1.0"), + }; + + SortEntries(entries, { SortField::Name }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Only", "only", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +TEST_CASE("ListSort_SortEntries_StableSort", "[listsort]") +{ + // Two entries with same Name — stable sort preserves original order + std::vector entries = { + MakeEntry("Same", "first", "1.0"), + MakeEntry("Same", "second", "1.0"), + }; + + SortEntries(entries, { SortField::Name }, SortDirection::Ascending); + + std::vector expected = { + MakeEntry("Same", "first", "1.0"), + MakeEntry("Same", "second", "1.0"), + }; + ValidateSortResult(entries, expected); +} + +// Tests for SortBy template — validates the production sort pipeline +// that converts arbitrary types to SortablePackageEntry and reorders in place. +TEST_CASE("ListSort_SortBy_ReordersSourceItems", "[listsort]") +{ + struct Row { std::string name; std::string id; std::string ver; int extra; }; + + std::vector rows = { + { "Charlie", "c", "1.0", 100 }, + { "Alpha", "a", "1.0", 200 }, + { "Beta", "b", "1.0", 300 }, + }; + + SortBy(rows, + [](const Row& r, size_t i) { + return SortablePackageEntry(i, r.name, r.id, r.ver, "", "", AllFieldsMask); + }, + { SortField::Name }, SortDirection::Ascending); + + // Verify all fields of each row after sort + REQUIRE(rows.size() == 3); + REQUIRE(rows[0].name == "Alpha"); + REQUIRE(rows[0].id == "a"); + REQUIRE(rows[0].ver == "1.0"); + REQUIRE(rows[0].extra == 200); + REQUIRE(rows[1].name == "Beta"); + REQUIRE(rows[1].id == "b"); + REQUIRE(rows[1].ver == "1.0"); + REQUIRE(rows[1].extra == 300); + REQUIRE(rows[2].name == "Charlie"); + REQUIRE(rows[2].id == "c"); + REQUIRE(rows[2].ver == "1.0"); + REQUIRE(rows[2].extra == 100); +} + +TEST_CASE("ListSort_SortBy_PreservesExtraFields", "[listsort]") +{ + struct Row { std::string name; std::string payload; }; + + std::vector rows = { + { "Zeta", "payload-z" }, + { "Alpha", "payload-a" }, + }; + + SortBy(rows, + [](const Row& r, size_t i) { + return SortablePackageEntry(i, r.name, "", "", "", "", AllFieldsMask); + }, + { SortField::Name }, SortDirection::Ascending); + + // Verify all fields preserved after sort + REQUIRE(rows.size() == 2); + REQUIRE(rows[0].name == "Alpha"); + REQUIRE(rows[0].payload == "payload-a"); + REQUIRE(rows[1].name == "Zeta"); + REQUIRE(rows[1].payload == "payload-z"); +} diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 59a22d1913..9b378b3347 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -47,17 +47,23 @@ namespace AppInstaller::Settings Disabled, }; - // Sort field for output ordering. - enum class SortField + // Sort field for output ordering. Flag-bit values enable bitmask composition + // via ComputeSortFieldMask, so the constructor can skip unused field computation. + enum class SortField : uint32_t { - Relevance, // Preserves current natural order (source-defined relevance ranking) - Name, - Id, - Version, - Source, - Available, + Relevance = 0x0, // Preserves current natural order (source-defined relevance ranking) + Name = 0x1, + Id = 0x2, + Version = 0x4, + Source = 0x8, + Available = 0x10, }; + DEFINE_ENUM_FLAG_OPERATORS(SortField); + + // Converts a string to SortField. Returns std::nullopt for unrecognized values. + std::optional ConvertToSortField(std::string_view value); + // Sort direction for output ordering. enum class SortDirection { @@ -65,9 +71,6 @@ namespace AppInstaller::Settings Descending, }; - // Converts a string to SortField. Returns std::nullopt for unrecognized values. - std::optional ConvertToSortField(std::string_view value); - // The download code to use for *installers*. enum class InstallerDownloader {