Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions schemas/JSON/settings/settings.schema.0.2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -409,6 +439,12 @@
},
"additionalItems": true
},
{
"properties": {
"output": { "$ref": "#/definitions/Output" }
},
"additionalItems": true
},
{
"properties": {
"experimentalFeatures": { "$ref": "#/definitions/Experimental" }
Expand Down
6 changes: 6 additions & 0 deletions src/AppInstallerCLICore/Argument.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions src/AppInstallerCLICore/Argument.h
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ namespace AppInstaller::CLI
ConfigurationSetChoice = 0x80,
DscResourceFunction = 0x100,
DependenciesConflict = 0x200,
SortDirection = 0x400,

// This must always be at the end
Max
Expand Down
3 changes: 3 additions & 0 deletions src/AppInstallerCLICore/Commands/ListCommand.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/AppInstallerCLICore/ExecutionArgs.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/AppInstallerCLICore/Resources.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/AppInstallerCLIPackage/Shared/Strings/en-us/winget.resw
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,18 @@ They can be configured through the settings file 'winget settings'.</value>
<value>Only the single character alias can occur after a single -: '{0}'</value>
<comment>{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.</comment>
</data>
<data name="SortArgumentDescription" xml:space="preserve">
<value>Sort results by a specified field (can be repeated for multi-field sort)</value>
<comment>Description for the --sort argument used to sort output by field name. Valid values are: name, id, version, source, available.</comment>
</data>
<data name="SortAscendingArgumentDescription" xml:space="preserve">
<value>Sort results in ascending order</value>
<comment>Description for the --ascending (--asc) flag that sets ascending sort direction for output.</comment>
</data>
<data name="SortDescendingArgumentDescription" xml:space="preserve">
<value>Sort results in descending order</value>
<comment>Description for the --descending (--desc) flag that sets descending sort direction for output.</comment>
</data>
<data name="SourceAddAlreadyExistsDifferentArg" xml:space="preserve">
<value>A source with the given name already exists and refers to a different location:</value>
</data>
Expand Down
240 changes: 240 additions & 0 deletions src/AppInstallerCLITests/UserSettings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -660,3 +660,243 @@ TEST_CASE("LoggingChannels", "[settings]")
REQUIRE(userSettingTest.Get<Setting::LoggingChannelPreference>() == (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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
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<Setting::OutputSortOrder>();
REQUIRE(sortOrder.empty());
REQUIRE(userSettingTest.GetWarnings().size() == 1);
}
}

TEST_CASE("SettingOutputSortDirection", "[settings]")
{
auto again = DeleteUserSettingsFiles();

SECTION("Default value")
{
UserSettingsTest userSettingTest;

REQUIRE(userSettingTest.Get<Setting::OutputSortDirection>() == 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<Setting::OutputSortDirection>() == 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<Setting::OutputSortDirection>() == 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<Setting::OutputSortDirection>() == 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<Setting::OutputSortDirection>() == 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<Setting::OutputSortDirection>() == 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<Setting::OutputSortOrder>();
REQUIRE(fields.size() == 2);
REQUIRE(fields[0] == SortField::Name);
REQUIRE(fields[1] == SortField::Id);
}
}
Loading