diff --git a/schemas/JSON/settings/settings.schema.0.2.json b/schemas/JSON/settings/settings.schema.0.2.json index 25b04e3d1f..529ae05828 100644 --- a/schemas/JSON/settings/settings.schema.0.2.json +++ b/schemas/JSON/settings/settings.schema.0.2.json @@ -340,6 +340,36 @@ "default": false } } + }, + "Output": { + "description": "Output display settings", + "type": "object", + "properties": { + "sortOrder": { + "description": "Fields to sort output by, in priority order. Use an empty array to disable sorting.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "relevance", + "name", + "id", + "version", + "source", + "available" + ] + } + }, + "sortDirection": { + "description": "Sort direction for output ordering", + "type": "string", + "enum": [ + "ascending", + "descending" + ], + "default": "ascending" + } + } } }, "allOf": [ @@ -409,6 +439,12 @@ }, "additionalItems": true }, + { + "properties": { + "output": { "$ref": "#/definitions/Output" } + }, + "additionalItems": true + }, { "properties": { "experimentalFeatures": { "$ref": "#/definitions/Experimental" } diff --git a/src/AppInstallerCLICore/Argument.cpp b/src/AppInstallerCLICore/Argument.cpp index 32bc8651c7..99699bdb0a 100644 --- a/src/AppInstallerCLICore/Argument.cpp +++ b/src/AppInstallerCLICore/Argument.cpp @@ -189,6 +189,12 @@ namespace AppInstaller::CLI return { type, "upgrade-available"_liv}; case Execution::Args::Type::ListDetails: return { type, "details"_liv }; + case Execution::Args::Type::Sort: + return { type, "sort"_liv }; + case Execution::Args::Type::SortAscending: + return { type, "ascending"_liv, "asc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection }; + case Execution::Args::Type::SortDescending: + return { type, "descending"_liv, "desc"_liv, ArgTypeCategory::None, ArgTypeExclusiveSet::SortDirection }; // Pin command case Execution::Args::Type::GatedVersion: diff --git a/src/AppInstallerCLICore/Argument.h b/src/AppInstallerCLICore/Argument.h index e7b438f1a7..c1a573db6a 100644 --- a/src/AppInstallerCLICore/Argument.h +++ b/src/AppInstallerCLICore/Argument.h @@ -92,6 +92,7 @@ namespace AppInstaller::CLI ConfigurationSetChoice = 0x80, DscResourceFunction = 0x100, DependenciesConflict = 0x200, + SortDirection = 0x400, // This must always be at the end Max diff --git a/src/AppInstallerCLICore/Commands/ListCommand.cpp b/src/AppInstallerCLICore/Commands/ListCommand.cpp index 334ddb56c9..acb12bdd46 100644 --- a/src/AppInstallerCLICore/Commands/ListCommand.cpp +++ b/src/AppInstallerCLICore/Commands/ListCommand.cpp @@ -32,6 +32,9 @@ 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::SortAscending, Resource::String::SortAscendingArgumentDescription, ArgumentType::Flag }, + Argument{ Execution::Args::Type::SortDescending, Resource::String::SortDescendingArgumentDescription, ArgumentType::Flag }, }; } diff --git a/src/AppInstallerCLICore/ExecutionArgs.h b/src/AppInstallerCLICore/ExecutionArgs.h index fa7e9c6358..675576a9d7 100644 --- a/src/AppInstallerCLICore/ExecutionArgs.h +++ b/src/AppInstallerCLICore/ExecutionArgs.h @@ -114,6 +114,9 @@ namespace AppInstaller::CLI::Execution // List Command Upgrade, // Used in List command to only show versions with upgrades ListDetails, + Sort, // Sort output by field (repeatable: --sort name --sort id) + SortAscending, // Sort output in ascending order + SortDescending, // Sort output in descending order // Pin command GatedVersion, // Differs from Version in that this supports wildcards diff --git a/src/AppInstallerCLICore/Resources.h b/src/AppInstallerCLICore/Resources.h index 11b4b8d1a7..fa369b8ae5 100644 --- a/src/AppInstallerCLICore/Resources.h +++ b/src/AppInstallerCLICore/Resources.h @@ -685,6 +685,9 @@ namespace AppInstaller::CLI::Resource WINGET_DEFINE_RESOURCE_STRINGID(ShowVersion); WINGET_DEFINE_RESOURCE_STRINGID(SilentArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(SingleCharAfterDashError); + WINGET_DEFINE_RESOURCE_STRINGID(SortArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(SortAscendingArgumentDescription); + WINGET_DEFINE_RESOURCE_STRINGID(SortDescendingArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(SkipDependenciesArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyArgumentDescription); WINGET_DEFINE_RESOURCE_STRINGID(DependenciesOnlyMessage); diff --git a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw index 7293ac52a6..3e51c8589b 100644 --- a/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw +++ b/src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw @@ -524,6 +524,18 @@ They can be configured through the settings file 'winget settings'. Only the single character alias can occur after a single -: '{0}' {Locked="{0}"} Error message displayed when the user provides more than a single character command line alias argument after an alias argument specifier '-'. {0} is a placeholder replaced by the user's argument input. + + Sort results by a specified field (can be repeated for multi-field sort) + Description for the --sort argument used to sort output by field name. Valid values are: name, id, version, source, available. + + + Sort results in ascending order + Description for the --ascending (--asc) flag that sets ascending sort direction for output. + + + Sort results in descending order + Description for the --descending (--desc) flag that sets descending sort direction for output. + A source with the given name already exists and refers to a different location: diff --git a/src/AppInstallerCLITests/UserSettings.cpp b/src/AppInstallerCLITests/UserSettings.cpp index 3d0820f8d5..dfe2a4ba54 100644 --- a/src/AppInstallerCLITests/UserSettings.cpp +++ b/src/AppInstallerCLITests/UserSettings.cpp @@ -660,3 +660,243 @@ TEST_CASE("LoggingChannels", "[settings]") REQUIRE(userSettingTest.Get() == (Channel::CLI | Channel::SQL)); } } + +TEST_CASE("SettingOutputSortOrder", "[settings]") +{ + auto again = DeleteUserSettingsFiles(); + + SECTION("Default value - empty vector sentinel for context-aware defaults") + { + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Single field") + { + std::string_view json = R"({ "output": { "sortOrder": ["id"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 1); + REQUIRE(sortOrder[0] == SortField::Id); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Relevance field") + { + std::string_view json = R"({ "output": { "sortOrder": ["relevance"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 1); + REQUIRE(sortOrder[0] == SortField::Relevance); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Multiple fields") + { + std::string_view json = R"({ "output": { "sortOrder": ["available", "name"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 2); + REQUIRE(sortOrder[0] == SortField::Available); + REQUIRE(sortOrder[1] == SortField::Name); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("All valid fields") + { + std::string_view json = R"({ "output": { "sortOrder": ["relevance", "name", "id", "version", "source", "available"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 6); + REQUIRE(sortOrder[0] == SortField::Relevance); + REQUIRE(sortOrder[1] == SortField::Name); + REQUIRE(sortOrder[2] == SortField::Id); + REQUIRE(sortOrder[3] == SortField::Version); + REQUIRE(sortOrder[4] == SortField::Source); + REQUIRE(sortOrder[5] == SortField::Available); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Case insensitive") + { + std::string_view json = R"({ "output": { "sortOrder": ["Name", "ID", "VERSION"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 3); + REQUIRE(sortOrder[0] == SortField::Name); + REQUIRE(sortOrder[1] == SortField::Id); + REQUIRE(sortOrder[2] == SortField::Version); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Empty array disables sorting") + { + std::string_view json = R"({ "output": { "sortOrder": [] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 0); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Invalid field name") + { + std::string_view json = R"({ "output": { "sortOrder": ["invalid"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Mixed valid and invalid field") + { + std::string_view json = R"({ "output": { "sortOrder": ["name", "bogus"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Duplicate values - case-insensitive") + { + std::string_view json = R"({ "output": { "sortOrder": ["name", "id", "Name"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.size() == 3); + REQUIRE(sortOrder[0] == SortField::Name); + REQUIRE(sortOrder[1] == SortField::Id); + REQUIRE(sortOrder[2] == SortField::Name); + REQUIRE(userSettingTest.GetWarnings().empty()); + } + SECTION("Wrong type - string instead of array") + { + std::string_view json = R"({ "output": { "sortOrder": "name" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Wrong type - number in array") + { + std::string_view json = R"({ "output": { "sortOrder": [1] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto sortOrder = userSettingTest.Get(); + REQUIRE(sortOrder.empty()); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } +} + +TEST_CASE("SettingOutputSortDirection", "[settings]") +{ + auto again = DeleteUserSettingsFiles(); + + SECTION("Default value") + { + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Ascending); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Ascending") + { + std::string_view json = R"({ "output": { "sortDirection": "ascending" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Ascending); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Descending") + { + std::string_view json = R"({ "output": { "sortDirection": "descending" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Descending); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Case insensitive") + { + std::string_view json = R"({ "output": { "sortDirection": "DESCENDING" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Descending); + REQUIRE(userSettingTest.GetWarnings().size() == 0); + } + SECTION("Invalid value") + { + std::string_view json = R"({ "output": { "sortDirection": "sideways" } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Ascending); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } + SECTION("Wrong type - array instead of string") + { + std::string_view json = R"({ "output": { "sortDirection": ["ascending"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + REQUIRE(userSettingTest.Get() == SortDirection::Ascending); + REQUIRE(userSettingTest.GetWarnings().size() == 1); + } +} + +TEST_CASE("ConvertToSortField", "[settings]") +{ + SECTION("Valid values - lowercase") + { + REQUIRE(ConvertToSortField("relevance") == SortField::Relevance); + REQUIRE(ConvertToSortField("name") == SortField::Name); + REQUIRE(ConvertToSortField("id") == SortField::Id); + REQUIRE(ConvertToSortField("version") == SortField::Version); + REQUIRE(ConvertToSortField("source") == SortField::Source); + REQUIRE(ConvertToSortField("available") == SortField::Available); + } + SECTION("Case-insensitive") + { + REQUIRE(ConvertToSortField("RELEVANCE") == SortField::Relevance); + REQUIRE(ConvertToSortField("NAME") == SortField::Name); + REQUIRE(ConvertToSortField("Id") == SortField::Id); + REQUIRE(ConvertToSortField("VERSION") == SortField::Version); + REQUIRE(ConvertToSortField("Source") == SortField::Source); + REQUIRE(ConvertToSortField("AVAILABLE") == SortField::Available); + } + SECTION("Invalid values return nullopt") + { + REQUIRE_FALSE(ConvertToSortField("").has_value()); + REQUIRE_FALSE(ConvertToSortField("foo").has_value()); + REQUIRE_FALSE(ConvertToSortField("names").has_value()); + REQUIRE_FALSE(ConvertToSortField("nam").has_value()); + } + SECTION("Settings round-trip with ConvertToSortField") + { + auto again = DeleteUserSettingsFiles(); + + std::string_view json = R"({ "output": { "sortOrder": ["name", "id"] } })"; + SetSetting(Stream::PrimaryUserSettings, json); + UserSettingsTest userSettingTest; + + auto fields = userSettingTest.Get(); + REQUIRE(fields.size() == 2); + REQUIRE(fields[0] == SortField::Name); + REQUIRE(fields[1] == SortField::Id); + } +} diff --git a/src/AppInstallerCommonCore/Public/winget/UserSettings.h b/src/AppInstallerCommonCore/Public/winget/UserSettings.h index 95fbe73549..3fbf1f2952 100644 --- a/src/AppInstallerCommonCore/Public/winget/UserSettings.h +++ b/src/AppInstallerCommonCore/Public/winget/UserSettings.h @@ -47,6 +47,27 @@ namespace AppInstaller::Settings Disabled, }; + // Sort field for output ordering. + enum class SortField + { + Relevance, // Preserves current natural order (source-defined relevance ranking) + Name, + Id, + Version, + Source, + Available, + }; + + // Sort direction for output ordering. + enum class SortDirection + { + Ascending, + 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 { @@ -114,6 +135,9 @@ namespace AppInstaller::Settings ConfigureDefaultModuleRoot, // Interactivity InteractivityDisable, + // Output behavior + OutputSortOrder, + OutputSortDirection, #ifndef AICLI_DISABLE_TEST_HOOKS // Debug EnableSelfInitiatedMinidump, @@ -208,6 +232,9 @@ namespace AppInstaller::Settings SETTINGMAPPING_SPECIALIZATION(Setting::LoggingFileCountLimit, uint32_t, uint32_t, 0, ".logging.file.countLimit"sv); // Interactivity SETTINGMAPPING_SPECIALIZATION(Setting::InteractivityDisable, bool, bool, false, ".interactivity.disable"sv); + // Output behavior + SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortOrder, std::vector, std::vector, std::vector{}, ".output.sortOrder"sv); + SETTINGMAPPING_SPECIALIZATION(Setting::OutputSortDirection, std::string, SortDirection, SortDirection::Ascending, ".output.sortDirection"sv); // Used to deduce the SettingVariant type; making a variant that includes std::monostate and all SettingMapping types. template diff --git a/src/AppInstallerCommonCore/UserSettings.cpp b/src/AppInstallerCommonCore/UserSettings.cpp index db276351ff..f15883be34 100644 --- a/src/AppInstallerCommonCore/UserSettings.cpp +++ b/src/AppInstallerCommonCore/UserSettings.cpp @@ -215,6 +215,26 @@ namespace AppInstaller::Settings } } + std::optional ConvertToSortField(std::string_view value) + { + static constexpr std::string_view s_sortField_relevance = "relevance"; + static constexpr std::string_view s_sortField_name = "name"; + static constexpr std::string_view s_sortField_id = "id"; + static constexpr std::string_view s_sortField_version = "version"; + static constexpr std::string_view s_sortField_source = "source"; + static constexpr std::string_view s_sortField_available = "available"; + + std::string lowered = Utility::ToLower(value); + + if (lowered == s_sortField_relevance) return SortField::Relevance; + if (lowered == s_sortField_name) return SortField::Name; + if (lowered == s_sortField_id) return SortField::Id; + if (lowered == s_sortField_version) return SortField::Version; + if (lowered == s_sortField_source) return SortField::Source; + if (lowered == s_sortField_available) return SortField::Available; + return std::nullopt; + } + namespace details { #define WINGET_VALIDATE_SIGNATURE(_setting_) \ @@ -482,6 +502,69 @@ namespace AppInstaller::Settings { return value * 24h; } + + WINGET_VALIDATE_SIGNATURE(OutputSortOrder) + { + static constexpr std::string_view s_sortField_relevance = "relevance"; + static constexpr std::string_view s_sortField_name = "name"; + static constexpr std::string_view s_sortField_id = "id"; + static constexpr std::string_view s_sortField_version = "version"; + static constexpr std::string_view s_sortField_source = "source"; + static constexpr std::string_view s_sortField_available = "available"; + + std::vector fields; + for (auto const& entry : value) + { + std::string lowered = Utility::ToLower(entry); + + if (lowered == s_sortField_relevance) + { + fields.emplace_back(SortField::Relevance); + } + else if (lowered == s_sortField_name) + { + fields.emplace_back(SortField::Name); + } + else if (lowered == s_sortField_id) + { + fields.emplace_back(SortField::Id); + } + else if (lowered == s_sortField_version) + { + fields.emplace_back(SortField::Version); + } + else if (lowered == s_sortField_source) + { + fields.emplace_back(SortField::Source); + } + else if (lowered == s_sortField_available) + { + fields.emplace_back(SortField::Available); + } + else + { + return {}; + } + } + return fields; + } + + WINGET_VALIDATE_SIGNATURE(OutputSortDirection) + { + static constexpr std::string_view s_sortDirection_ascending = "ascending"; + static constexpr std::string_view s_sortDirection_descending = "descending"; + + if (Utility::CaseInsensitiveEquals(value, s_sortDirection_ascending)) + { + return SortDirection::Ascending; + } + else if (Utility::CaseInsensitiveEquals(value, s_sortDirection_descending)) + { + return SortDirection::Descending; + } + + return {}; + } } #ifndef AICLI_DISABLE_TEST_HOOKS