Skip to content
Merged
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
16 changes: 6 additions & 10 deletions dotnet/src/Devolutions.Pinget.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Nodes;
using Devolutions.Pinget.Cli;
using Devolutions.Pinget.Core;
using YamlDotNet.Serialization;

const string Version = "0.4.2";
const string Version = "0.6.0";
const string UpgradeUnsupportedWarning = "Upgrading packages is not supported on this platform; no changes were made.";

if (args.Length == 1 && (string.Equals(args[0], "--version", StringComparison.OrdinalIgnoreCase) || string.Equals(args[0], "-v", StringComparison.OrdinalIgnoreCase)))
Expand All @@ -21,8 +22,6 @@
outputOption.FromAmong("text", "json", "yaml");
rootCommand.AddGlobalOption(outputOption);

var JsonOpts = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };

var infoOption = new Option<bool>("--info", "Display general info");
rootCommand.AddGlobalOption(infoOption);

Expand Down Expand Up @@ -437,7 +436,7 @@
Explicit = s.Explicit,
Priority = s.Priority,
});
Console.WriteLine(JsonSerializer.Serialize(new { Sources = sources }, JsonOpts));
Console.WriteLine(StructuredOutputSerializer.SerializeJson(new { Sources = sources }));
});

sourceAddCmd.SetHandler((ctx) =>
Expand Down Expand Up @@ -580,7 +579,7 @@
}
}
};
File.WriteAllText(output, JsonSerializer.Serialize(export, JsonOpts));
File.WriteAllText(output, StructuredOutputSerializer.SerializeJson(export));
Console.WriteLine($"Exported {packages.Count} packages to {output}");
}, exOutputOpt, exSourceOpt, exVersionsOpt);

Expand Down Expand Up @@ -1266,13 +1265,10 @@ void WriteStructuredOutput(object value, OutputFormat output)
switch (output)
{
case OutputFormat.Json:
if (value is SerializableShowManifest showManifest)
Console.WriteLine(JsonSerializer.Serialize(showManifest, PingetJsonContext.Default.SerializableShowManifest));
else
Console.WriteLine(JsonSerializer.Serialize(value, JsonOpts));
Console.WriteLine(StructuredOutputSerializer.SerializeJson(value));
break;
case OutputFormat.Yaml:
Console.Write(new SerializerBuilder().Build().Serialize(value));
Console.Write(StructuredOutputSerializer.SerializeYaml(value));
break;
default:
throw new InvalidOperationException("Text output should be handled separately.");
Expand Down
18 changes: 18 additions & 0 deletions dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Text.Json;
using Devolutions.Pinget.Core;
using YamlDotNet.Serialization;

namespace Devolutions.Pinget.Cli;

public static class StructuredOutputSerializer
{
public static JsonSerializerOptions JsonOptions { get; } = new() { WriteIndented = true };

public static string SerializeJson(object value) =>
value is SerializableShowManifest showManifest
? JsonSerializer.Serialize(showManifest, PingetJsonContext.Default.SerializableShowManifest)
: JsonSerializer.Serialize(value, JsonOptions);

Check warning on line 14 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.

Check warning on line 14 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.

Check warning on line 14 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.

Check warning on line 14 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.

public static string SerializeYaml(object value) =>
new SerializerBuilder().Build().Serialize(value);

Check warning on line 17 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-arm64)

Using member 'YamlDotNet.Serialization.SerializerBuilder.SerializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the serializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticSerializerBuilder' object instead of this one.

Check warning on line 17 in dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs

View workflow job for this annotation

GitHub Actions / .NET NativeAOT CLI (win-x64)

Using member 'YamlDotNet.Serialization.SerializerBuilder.SerializerBuilder()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. This builder configures the serializer to use reflection which is not compatible with ahead-of-time compilation or assembly trimming. You need to use the code generator/analyzer to generate static code and use the 'StaticSerializerBuilder' object instead of this one.
}
262 changes: 262 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
using System.Text.Json;
using Devolutions.Pinget.Cli;
using Devolutions.Pinget.Core;
using Xunit;

namespace Devolutions.Pinget.Core.Tests;

public class CliJsonCompatibilityTests
{
[Fact]
public void StructuredJsonSerializer_UsesPascalCaseForListResponses()
{
var response = new ListResponse
{
Matches =
[
new ListMatch
{
Name = "PowerToys",
Id = "Microsoft.PowerToys",
LocalId = @"ARP\Machine\X64\PowerToys",
InstalledVersion = "0.98.1",
AvailableVersion = "0.99.0",
SourceName = "winget",
Publisher = "Microsoft",
}
],
Warnings = [],
Truncated = false,
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(response));
var match = document.RootElement.GetProperty("Matches")[0];

Assert.Equal("Microsoft.PowerToys", match.GetProperty("Id").GetString());
Assert.Equal(@"ARP\Machine\X64\PowerToys", match.GetProperty("LocalId").GetString());
Assert.Equal("0.98.1", match.GetProperty("InstalledVersion").GetString());
Assert.Equal("0.99.0", match.GetProperty("AvailableVersion").GetString());
Assert.Equal("winget", match.GetProperty("SourceName").GetString());
Assert.False(match.TryGetProperty("local_id", out _));
Assert.False(match.TryGetProperty("installed_version", out _));
Assert.False(match.TryGetProperty("available_version", out _));
Assert.False(match.TryGetProperty("source_name", out _));
}

[Fact]
public void StructuredJsonSerializer_UsesPascalCaseForSearchResponses()
{
var response = new SearchResponse
{
Matches =
[
new SearchMatch
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Microsoft.PowerToys",
Name = "PowerToys",
Version = "0.99.0",
MatchCriteria = "Tag",
}
],
Warnings = [],
Truncated = false,
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(response));
var match = document.RootElement.GetProperty("Matches")[0];

Assert.Equal("winget", match.GetProperty("SourceName").GetString());
Assert.Equal("Tag", match.GetProperty("MatchCriteria").GetString());
Assert.False(match.TryGetProperty("source_name", out _));
Assert.False(match.TryGetProperty("match_criteria", out _));
}

[Fact]
public void StructuredJsonSerializer_PreservesExistingShowManifestPropertyNames()
{
var manifest = new SerializableShowManifest
{
PackageIdentifier = "Microsoft.PowerToys",
PackageName = "PowerToys",
PackageVersion = "0.99.0",
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Author = "Contoso",
Description = "Fancy tools",
ShortDescription = "Tools",
Publisher = "Microsoft",
PackageUrl = "https://example.test/package",
LicenseUrl = "https://example.test/license",
ReleaseNotesUrl = "https://example.test/release-notes",
Tags = ["utilities", "powertoys"],
Installers =
[
new SerializableInstaller
{
InstallerUrl = "https://example.test/installer.exe",
InstallerSha256 = "ABC123",
InstallerType = "exe",
ReleaseDate = "2026-05-22",
}
],
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(manifest));
var root = document.RootElement;

Assert.Equal("Microsoft.PowerToys", root.GetProperty(nameof(SerializableShowManifest.PackageIdentifier)).GetString());
Assert.Equal("PowerToys", root.GetProperty(nameof(SerializableShowManifest.PackageName)).GetString());
Assert.Equal("winget", root.GetProperty(nameof(SerializableShowManifest.SourceName)).GetString());
Assert.Equal("Contoso", root.GetProperty(nameof(SerializableShowManifest.Author)).GetString());
Assert.Equal("Fancy tools", root.GetProperty(nameof(SerializableShowManifest.Description)).GetString());
Assert.Equal("Tools", root.GetProperty(nameof(SerializableShowManifest.ShortDescription)).GetString());
Assert.Equal("Microsoft", root.GetProperty(nameof(SerializableShowManifest.Publisher)).GetString());
Assert.Equal("https://example.test/package", root.GetProperty(nameof(SerializableShowManifest.PackageUrl)).GetString());
Assert.Equal("https://example.test/license", root.GetProperty(nameof(SerializableShowManifest.LicenseUrl)).GetString());
Assert.Equal("https://example.test/release-notes", root.GetProperty(nameof(SerializableShowManifest.ReleaseNotesUrl)).GetString());
Assert.Equal(["utilities", "powertoys"], root.GetProperty(nameof(SerializableShowManifest.Tags)).EnumerateArray().Select(item => item.GetString() ?? string.Empty).ToArray());
var installer = root.GetProperty(nameof(SerializableShowManifest.Installers))[0];
Assert.Equal("https://example.test/installer.exe", installer.GetProperty(nameof(SerializableInstaller.InstallerUrl)).GetString());
Assert.Equal("ABC123", installer.GetProperty(nameof(SerializableInstaller.InstallerSha256)).GetString());
Assert.Equal("exe", installer.GetProperty(nameof(SerializableInstaller.InstallerType)).GetString());
Assert.Equal("2026-05-22", installer.GetProperty(nameof(SerializableInstaller.ReleaseDate)).GetString());
Assert.False(root.TryGetProperty("package_identifier", out _));
Assert.False(root.TryGetProperty("package_name", out _));
Assert.False(root.TryGetProperty("source_name", out _));
Assert.False(root.TryGetProperty("packageIdentifier", out _));
Assert.False(root.TryGetProperty("packageName", out _));
Assert.False(root.TryGetProperty("sourceName", out _));
}

[Fact]
public void StructuredJsonSerializer_PreservesSourceExportPascalCaseShape()
{
var export = new
{
Sources = new[]
{
new
{
Name = "winget",
Type = "Microsoft.PreIndexed.Package",
Arg = "https://cdn.winget.microsoft.com/cache",
Data = "Microsoft.Winget.Source_8wekyb3d8bbwe",
Identifier = "Microsoft.Winget.Source_8wekyb3d8bbwe",
TrustLevel = "Trusted",
Explicit = false,
Priority = 0,
}
}
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(export));
var source = document.RootElement.GetProperty("Sources")[0];

Assert.Equal("winget", source.GetProperty("Name").GetString());
Assert.Equal("Microsoft.PreIndexed.Package", source.GetProperty("Type").GetString());
Assert.Equal("https://cdn.winget.microsoft.com/cache", source.GetProperty("Arg").GetString());
Assert.Equal("Microsoft.Winget.Source_8wekyb3d8bbwe", source.GetProperty("Data").GetString());
Assert.Equal("Microsoft.Winget.Source_8wekyb3d8bbwe", source.GetProperty("Identifier").GetString());
Assert.Equal("Trusted", source.GetProperty("TrustLevel").GetString());
Assert.False(source.GetProperty("Explicit").GetBoolean());
Assert.Equal(0, source.GetProperty("Priority").GetInt32());
Assert.False(document.RootElement.TryGetProperty("sources", out _));
}

[Fact]
public void StructuredJsonSerializer_UsesPascalCaseForVersionsResults()
{
var result = new VersionsResult
{
Package = new SearchMatch
{
SourceName = "winget",
SourceKind = SourceKind.PreIndexed,
Id = "Microsoft.PowerToys",
Name = "PowerToys",
Version = "0.99.0",
},
Versions =
[
new VersionKey { Version = "0.99.0", Channel = "stable" },
new VersionKey { Version = "0.100.0-preview", Channel = "preview" },
],
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(result));
var versions = document.RootElement.GetProperty("Versions");

Assert.Equal("0.99.0", versions[0].GetProperty("Version").GetString());
Assert.Equal("stable", versions[0].GetProperty("Channel").GetString());
Assert.Equal("0.100.0-preview", versions[1].GetProperty("Version").GetString());
Assert.Equal("preview", versions[1].GetProperty("Channel").GetString());
Assert.False(document.RootElement.TryGetProperty("versions", out _));
}

[Fact]
public void StructuredJsonSerializer_PreservesNullableListFields()
{
var response = new ListResponse
{
Matches =
[
new ListMatch
{
Name = "Contoso Tool",
Id = "Contoso.Tool",
LocalId = @"ARP\User\X64\Contoso.Tool",
InstalledVersion = "1.2.3",
AvailableVersion = null,
SourceName = null,
Publisher = null,
Scope = null,
InstallerCategory = null,
InstallLocation = null,
}
],
Warnings = [],
Truncated = false,
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(response));
var match = document.RootElement.GetProperty("Matches")[0];

Assert.Equal(JsonValueKind.Null, match.GetProperty("AvailableVersion").ValueKind);
Assert.Equal(JsonValueKind.Null, match.GetProperty("SourceName").ValueKind);
Assert.Equal(JsonValueKind.Null, match.GetProperty("Publisher").ValueKind);
Assert.Equal(JsonValueKind.Null, match.GetProperty("Scope").ValueKind);
Assert.Equal(JsonValueKind.Null, match.GetProperty("InstallerCategory").ValueKind);
Assert.Equal(JsonValueKind.Null, match.GetProperty("InstallLocation").ValueKind);
}

[Fact]
public void StructuredJsonSerializer_PreservesMinimumSearchShapeUsedByUnigetui()
{
var response = new SearchResponse
{
Matches =
[
new SearchMatch
{
SourceName = "msstore",
SourceKind = SourceKind.Rest,
Id = "9WZDNCRFJBMP",
Name = "Microsoft To Do",
Version = "2.123.456.0",
}
],
Warnings = [],
Truncated = false,
};

using var document = JsonDocument.Parse(StructuredOutputSerializer.SerializeJson(response));
var match = document.RootElement.GetProperty("Matches")[0];

Assert.Equal("Microsoft To Do", match.GetProperty("Name").GetString());
Assert.Equal("9WZDNCRFJBMP", match.GetProperty("Id").GetString());
Assert.Equal("2.123.456.0", match.GetProperty("Version").GetString());
Assert.Equal("msstore", match.GetProperty("SourceName").GetString());
}
}
6 changes: 6 additions & 0 deletions dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,12 @@ public void NormalizePublisher_Strips_Common_Suffixes()
Assert.Equal("foo", NameNormalization.NormalizePublisher("Foo GmbH"));
}

[Fact]
public void NormalizePublisher_Software_Strips_To_BaseName()
{
Assert.Equal("sweetscape", NameNormalization.NormalizePublisher("SweetScape Software"));
}

[Fact]
public void NormalizeName_Strips_VersionDelimited_Token()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<PackageReference Include="Xunit.SkippableFact" Version="1.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Devolutions.Pinget.Cli\Devolutions.Pinget.Cli.csproj" />
<ProjectReference Include="..\Devolutions.Pinget.Core\Devolutions.Pinget.Core.csproj" />
</ItemGroup>
</Project>
2 changes: 1 addition & 1 deletion dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,6 @@ bool PushSegment(string segment)
"AB", "AD", "AG", "APS", "AS", "ASA", "BV", "CO", "COMPANY", "CORP", "CORPORATION", "CV",
"DOO", "EV", "GES", "GESMBH", "GMBH", "HOLDING", "HOLDINGS", "INC", "INCORPORATED", "KG",
"KS", "LIMITED", "LLC", "LP", "LTD", "LTDA", "MBH", "NV", "PLC", "PS", "PTY", "PVT", "SA",
"SARL", "SC", "SCA", "SL", "SP", "SPA", "SRL", "SRO", "SUBSIDIARY",
"SARL", "SC", "SCA", "SL", "SOFTWARE", "SP", "SPA", "SRL", "SRO", "SUBSIDIARY",
];
}
Loading
Loading