diff --git a/dotnet/src/Devolutions.Pinget.Cli/Program.cs b/dotnet/src/Devolutions.Pinget.Cli/Program.cs index f3ec3c7..87d6406 100644 --- a/dotnet/src/Devolutions.Pinget.Cli/Program.cs +++ b/dotnet/src/Devolutions.Pinget.Cli/Program.cs @@ -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))) @@ -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("--info", "Display general info"); rootCommand.AddGlobalOption(infoOption); @@ -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) => @@ -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); @@ -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."); diff --git a/dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs b/dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs new file mode 100644 index 0000000..de7f89f --- /dev/null +++ b/dotnet/src/Devolutions.Pinget.Cli/StructuredOutputSerializer.cs @@ -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); + + public static string SerializeYaml(object value) => + new SerializerBuilder().Build().Serialize(value); +} \ No newline at end of file diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs new file mode 100644 index 0000000..3d544b0 --- /dev/null +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CliJsonCompatibilityTests.cs @@ -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()); + } +} \ No newline at end of file diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs index 0928da1..c7bd067 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/CoreTests.cs @@ -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() { diff --git a/dotnet/src/Devolutions.Pinget.Core.Tests/Devolutions.Pinget.Core.Tests.csproj b/dotnet/src/Devolutions.Pinget.Core.Tests/Devolutions.Pinget.Core.Tests.csproj index d9e49db..bc64b4d 100644 --- a/dotnet/src/Devolutions.Pinget.Core.Tests/Devolutions.Pinget.Core.Tests.csproj +++ b/dotnet/src/Devolutions.Pinget.Core.Tests/Devolutions.Pinget.Core.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs b/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs index 4756fa1..58a8a49 100644 --- a/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs +++ b/dotnet/src/Devolutions.Pinget.Core/NameNormalization.cs @@ -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", ]; } diff --git a/dotnet/src/Devolutions.Pinget.Core/Repository.cs b/dotnet/src/Devolutions.Pinget.Core/Repository.cs index ab41c00..b8ab448 100644 --- a/dotnet/src/Devolutions.Pinget.Core/Repository.cs +++ b/dotnet/src/Devolutions.Pinget.Core/Repository.cs @@ -496,40 +496,30 @@ public ListResponse List(ListQuery query) if (!OperatingSystem.IsWindows()) warnings.Add(InstalledStateUnsupportedWarning); - if (needsAvailable) - { - // Authoritative correlation via the v2 index's identity tables - // (PackageFamilyName / ProductCode / UpgradeCode). This is - // winget's primary path and resolves cases where display-name - // matching is ambiguous (Microsoft.Teams vs Microsoft.Teams.Free) - // or impossible (MSIX with `ms-resource:` placeholder names). - warnings.AddRange(CorrelateInstalledViaIndex(installed, query.Source)); - - // ARP entries without identity keys (no PFN/PC/UC) still match - // winget's ARP correlation when their (DisplayName, Publisher), - // run through NameNormalization, lands on a single package in - // norm_names2 ∩ norm_publishers2. Covers Inno Setup-style - // installers, MSIs without ProductCode in ARP, and vendors - // that publish a DisplayName different from the catalog's - // PackageName but matching an AppsAndFeaturesEntries name. - warnings.AddRange(CorrelateInstalledByNormalizedIdentity(installed, query.Source)); - } + // Even plain `list` should canonicalize installed package ids and + // sources when the local package can be correlated back to a source + // catalog entry. Available-version lookups stay gated below. + warnings.AddRange(CorrelateInstalledViaIndex(installed, query.Source)); + warnings.AddRange(CorrelateInstalledByNormalizedIdentity(installed, query.Source)); - if (needsAvailable && hasFilter) + if (needsAvailable) { var availableQuery = PackageQueryFromListQuery(query); - var (matches, srcWarnings, _, _) = SearchLocated(availableQuery, SearchSemantics.Many); - warnings.AddRange(srcWarnings); - var candidates = matches.Select(m => m.Display).ToList(); - foreach (var pkg in installed) + if (hasFilter) { - if (pkg.Correlated is not null) continue; - pkg.Correlated = CorrelateInstalledPackage(pkg, candidates, AllowLooseListCorrelation(query)); + var (matches, srcWarnings, _, _) = SearchLocated(availableQuery, SearchSemantics.Many); + warnings.AddRange(srcWarnings); + var candidates = matches.Select(m => m.Display).ToList(); + foreach (var pkg in installed) + { + if (pkg.Correlated is not null) continue; + pkg.Correlated = CorrelateInstalledPackage(pkg, candidates, AllowLooseListCorrelation(query)); + } + } + else + { + warnings.AddRange(CorrelateAllInstalled(installed)); } - } - else if (needsAvailable) - { - warnings.AddRange(CorrelateAllInstalled(installed)); } if (needsAvailable) diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 index 82dc476..0b87e3f 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Cmdlets/ModuleFiles/Devolutions.Pinget.Client.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Pinget.Client.psm1' - ModuleVersion = '0.4.2' + ModuleVersion = '0.6.0' CompatiblePSEditions = @('Desktop', 'Core') GUID = 'c6d1b5f2-5ccd-4771-9480-25caad7c58bd' Author = 'Devolutions' diff --git a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs index 13358fa..174ad38 100644 --- a/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs +++ b/dotnet/src/Devolutions.Pinget.PowerShell.Engine/PowerShellEngineVersion.cs @@ -2,5 +2,5 @@ namespace Devolutions.Pinget.PowerShell.Engine; public static class PowerShellEngineVersion { - public const string Current = "0.4.2"; + public const string Current = "0.6.0"; } diff --git a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj index b5903af..4f3eb9e 100644 --- a/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj +++ b/nuget/Devolutions.Pinget.Cli.DotNet/Devolutions.Pinget.Cli.DotNet.csproj @@ -1,7 +1,7 @@ - 0.4.2 + 0.6.0 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.DotNet diff --git a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj index 13dac65..045ca3c 100644 --- a/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj +++ b/nuget/Devolutions.Pinget.Cli.Rust/Devolutions.Pinget.Cli.Rust.csproj @@ -1,7 +1,7 @@ - 0.4.2 + 0.6.0 Devolutions Inc. Devolutions Devolutions.Pinget.Cli.Rust diff --git a/rust/crates/pinget-cli/Cargo.toml b/rust/crates/pinget-cli/Cargo.toml index 269e880..4067fd4 100644 --- a/rust/crates/pinget-cli/Cargo.toml +++ b/rust/crates/pinget-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-cli" -version = "0.4.2" +version = "0.6.0" edition = "2024" [lints] @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.102" clap = { version = "4.6.1", features = ["derive"] } -pinget-core = { version = "0.4.2", path = "../pinget-core" } +pinget-core = { version = "0.6.0", path = "../pinget-core" } chrono = "0.4.44" dirs = "6.0" jsonschema = "0.30" diff --git a/rust/crates/pinget-cli/src/main.rs b/rust/crates/pinget-cli/src/main.rs index 84f7280..6ce94c9 100644 --- a/rust/crates/pinget-cli/src/main.rs +++ b/rust/crates/pinget-cli/src/main.rs @@ -1033,7 +1033,7 @@ fn run() -> Result<()> { fn print_serialized(value: &T, output: OutputFormat) -> Result<()> { match output { OutputFormat::Text => bail!("structured output requested without a serializer"), - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), + OutputFormat::Json => println!("{}", serialize_json_pretty(value)?), OutputFormat::Yaml => print!("{}", serde_yaml::to_string(value)?), } Ok(()) @@ -1042,7 +1042,7 @@ fn print_serialized(value: &T, output: OutputFormat) -> Res fn print_manifest_serialized(value: &serde_json::Value, output: OutputFormat) -> Result<()> { match output { OutputFormat::Text => bail!("structured output requested without a serializer"), - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?), + OutputFormat::Json => println!("{}", serialize_json_pretty(value)?), OutputFormat::Yaml => { if let serde_json::Value::Array(documents) = value { for document in documents { @@ -1056,6 +1056,10 @@ fn print_manifest_serialized(value: &serde_json::Value, output: OutputFormat) -> Ok(()) } +fn serialize_json_pretty(value: &T) -> Result { + Ok(serde_json::to_string_pretty(value)?) +} + impl From for PackageQuery { fn from(value: QueryArgs) -> Self { Self { @@ -2575,6 +2579,8 @@ fn can_ignore_unavailable_import_failure(error: &anyhow::Error) -> bool { #[cfg(test)] mod tests { + use pinget_core::VersionKey; + use super::*; #[test] @@ -2604,4 +2610,183 @@ mod tests { assert!(!parse_boolean_setting_value("false").expect("bool")); assert!(!parse_boolean_setting_value("0").expect("bool")); } + + #[test] + fn serialize_json_pretty_preserves_pascal_case_list_fields() { + let response = ListResponse { + matches: vec![ListMatch { + name: "PowerToys".to_owned(), + id: "Microsoft.PowerToys".to_owned(), + local_id: r"ARP\Machine\X64\PowerToys".to_owned(), + installed_version: "0.98.1".to_owned(), + available_version: Some("0.99.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: Some("Microsoft".to_owned()), + scope: Some("Machine".to_owned()), + installer_category: Some("msi".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }], + warnings: Vec::new(), + truncated: false, + }; + + let json = serialize_json_pretty(&response).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parsed json"); + let match_value = &value["Matches"][0]; + + assert_eq!(match_value["Id"], "Microsoft.PowerToys"); + assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys"); + assert_eq!(match_value["InstalledVersion"], "0.98.1"); + assert_eq!(match_value["AvailableVersion"], "0.99.0"); + assert_eq!(match_value["SourceName"], "winget"); + assert!(match_value.get("local_id").is_none()); + assert!(match_value.get("installed_version").is_none()); + assert!(match_value.get("available_version").is_none()); + assert!(match_value.get("source_name").is_none()); + } + + #[test] + fn serialize_json_pretty_preserves_pascal_case_search_fields() { + let response = SearchResponse { + matches: vec![SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.PowerToys".to_owned(), + name: "PowerToys".to_owned(), + moniker: None, + version: Some("0.99.0".to_owned()), + channel: None, + match_criteria: Some("Tag".to_owned()), + }], + warnings: Vec::new(), + truncated: false, + }; + + let json = serialize_json_pretty(&response).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parsed json"); + let match_value = &value["Matches"][0]; + + assert_eq!(match_value["SourceName"], "winget"); + assert_eq!(match_value["MatchCriteria"], "Tag"); + assert!(match_value.get("source_name").is_none()); + assert!(match_value.get("match_criteria").is_none()); + } + + #[test] + fn serialize_json_pretty_preserves_pascal_case_versions_fields() { + let result = VersionsResult { + package: SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.PowerToys".to_owned(), + name: "PowerToys".to_owned(), + moniker: None, + version: Some("0.99.0".to_owned()), + channel: None, + match_criteria: None, + }, + versions: vec![ + VersionKey { + version: "0.99.0".to_owned(), + channel: "stable".to_owned(), + }, + VersionKey { + version: "0.100.0-preview".to_owned(), + channel: "preview".to_owned(), + }, + ], + warnings: Vec::new(), + }; + + let json = serialize_json_pretty(&result).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parsed json"); + + assert_eq!(value["Versions"][0]["Version"], "0.99.0"); + assert_eq!(value["Versions"][0]["Channel"], "stable"); + assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview"); + assert_eq!(value["Versions"][1]["Channel"], "preview"); + assert!(value.get("versions").is_none()); + } + + #[test] + fn serialize_json_pretty_preserves_source_export_shape() { + let export = serde_json::json!({ + "Sources": [ + { + "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 + } + ] + }); + + let json = serialize_json_pretty(&export).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parsed json"); + let source = &value["Sources"][0]; + + assert_eq!(source["Name"], "winget"); + assert_eq!(source["Type"], "Microsoft.PreIndexed.Package"); + assert_eq!(source["Arg"], "https://cdn.winget.microsoft.com/cache"); + assert_eq!(source["Data"], "Microsoft.Winget.Source_8wekyb3d8bbwe"); + assert_eq!(source["Identifier"], "Microsoft.Winget.Source_8wekyb3d8bbwe"); + assert_eq!(source["TrustLevel"], "Trusted"); + assert_eq!(source["Explicit"], false); + assert_eq!(source["Priority"], 0); + assert!(value.get("sources").is_none()); + } + + #[test] + fn serialize_json_pretty_preserves_show_manifest_shape() { + let document = serde_json::json!({ + "PackageIdentifier": "Microsoft.PowerToys", + "PackageName": "PowerToys", + "PackageVersion": "0.99.0", + "Publisher": "Microsoft", + "Author": "Contoso", + "Description": "Fancy tools", + "PackageUrl": "https://example.test/package", + "LicenseUrl": "https://example.test/license", + "ReleaseNotesUrl": "https://example.test/release-notes", + "Tags": ["utilities", "powertoys"], + "PackageDependencies": ["Microsoft.VCRedist.2015+.x64"], + "Installers": [ + { + "InstallerUrl": "https://example.test/installer.exe", + "InstallerSha256": "ABC123", + "InstallerType": "exe", + "ReleaseDate": "2026-05-22" + } + ] + }); + + let json = serialize_json_pretty(&document).expect("json"); + let value: serde_json::Value = serde_json::from_str(&json).expect("parsed json"); + + assert_eq!(value["PackageIdentifier"], "Microsoft.PowerToys"); + assert_eq!(value["PackageName"], "PowerToys"); + assert_eq!(value["Publisher"], "Microsoft"); + assert_eq!(value["Author"], "Contoso"); + assert_eq!(value["Description"], "Fancy tools"); + assert_eq!(value["PackageUrl"], "https://example.test/package"); + assert_eq!(value["LicenseUrl"], "https://example.test/license"); + assert_eq!(value["ReleaseNotesUrl"], "https://example.test/release-notes"); + assert_eq!(value["Tags"][0], "utilities"); + assert_eq!( + value["Installers"][0]["InstallerUrl"], + "https://example.test/installer.exe" + ); + assert_eq!(value["Installers"][0]["InstallerSha256"], "ABC123"); + assert_eq!(value["Installers"][0]["InstallerType"], "exe"); + assert_eq!(value["Installers"][0]["ReleaseDate"], "2026-05-22"); + assert!(value.get("package_identifier").is_none()); + assert!(value.get("source_name").is_none()); + } } diff --git a/rust/crates/pinget-com/Cargo.toml b/rust/crates/pinget-com/Cargo.toml index 15a63bd..6c2b8e1 100644 --- a/rust/crates/pinget-com/Cargo.toml +++ b/rust/crates/pinget-com/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-com" -version = "0.4.2" +version = "0.6.0" edition = "2024" description = "Windows-only native COM bridge for Pinget backed by pinget-core." license = "MIT" diff --git a/rust/crates/pinget-core/Cargo.toml b/rust/crates/pinget-core/Cargo.toml index 481b5fc..e7c23eb 100644 --- a/rust/crates/pinget-core/Cargo.toml +++ b/rust/crates/pinget-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pinget-core" -version = "0.4.2" +version = "0.6.0" edition = "2024" description = "Pure Rust Pinget core library that works directly with source caches, REST endpoints, and installed package state without COM." license = "MIT" diff --git a/rust/crates/pinget-core/src/lib.rs b/rust/crates/pinget-core/src/lib.rs index cb79552..45525a2 100644 --- a/rust/crates/pinget-core/src/lib.rs +++ b/rust/crates/pinget-core/src/lib.rs @@ -68,9 +68,9 @@ const REST_SUPPORTED_CONTRACTS: &[&str] = &[ #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub enum SourceKind { - #[serde(rename = "preIndexed", alias = "PreIndexed")] + #[serde(rename = "PreIndexed", alias = "preIndexed")] PreIndexed, - #[serde(rename = "rest", alias = "Rest")] + #[serde(rename = "Rest", alias = "rest")] Rest, } @@ -207,6 +207,7 @@ pub struct ListQuery { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct SearchMatch { pub source_name: String, pub source_kind: SourceKind, @@ -219,6 +220,7 @@ pub struct SearchMatch { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct SearchResponse { pub matches: Vec, pub warnings: Vec, @@ -226,6 +228,7 @@ pub struct SearchResponse { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct ListMatch { pub name: String, pub id: String, @@ -243,6 +246,7 @@ pub struct ListMatch { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct ListResponse { pub matches: Vec, pub warnings: Vec, @@ -250,6 +254,7 @@ pub struct ListResponse { } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct VersionKey { pub version: String, pub channel: String, @@ -454,6 +459,7 @@ impl ShowResult { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct VersionsResult { pub package: SearchMatch, pub versions: Vec, @@ -461,6 +467,7 @@ pub struct VersionsResult { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct CacheWarmResult { pub package: SearchMatch, pub cached_files: Vec, @@ -468,6 +475,7 @@ pub struct CacheWarmResult { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct SourceUpdateResult { pub name: String, pub kind: SourceKind, @@ -475,6 +483,7 @@ pub struct SourceUpdateResult { } #[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "PascalCase")] pub struct PinRecord { pub package_id: String, pub version: String, @@ -1003,7 +1012,11 @@ impl Repository { } let has_filter = list_query_needs_available_lookup(query); - let needs_available = has_filter || query.upgrade_only; + // Plain `list` should still correlate installed packages back to their + // catalog identities so callers see canonical package ids/source names, + // matching winget and the fixed C# Pinget behavior. `upgrade_only` + // still adds the available-version specific filtering on top. + let needs_available = true; let mut warnings = Vec::new(); if !installed_package_discovery_supported() { @@ -10510,6 +10523,178 @@ Installers: assert!(latest_arp_anchored_version(&entries).is_none()); } + #[test] + fn list_match_from_installed_prefers_correlated_id_and_source_name() { + let package = InstalledPackage { + name: "AzCopy v10".to_owned(), + local_id: r"ARP\Machine\X64\AzCopy".to_owned(), + installed_version: "10.32.2".to_owned(), + publisher: Some("Microsoft".to_owned()), + scope: Some("Machine".to_owned()), + installer_category: Some("exe".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: Some(SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.Azure.AZCopy.10".to_owned(), + name: "AzCopy v10".to_owned(), + moniker: None, + version: Some("10.32.3".to_owned()), + channel: None, + match_criteria: None, + }), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let item = list_match_from_installed(package); + + assert_eq!(item.id, "Microsoft.Azure.AZCopy.10"); + assert_eq!(item.local_id, r"ARP\Machine\X64\AzCopy"); + assert_eq!(item.source_name.as_deref(), Some("winget")); + assert_eq!(item.available_version.as_deref(), Some("10.32.3")); + } + + #[test] + fn plain_list_keeps_canonical_id_distinct_from_local_id_for_correlated_rows() { + let package = InstalledPackage { + name: "Atlassian CLI".to_owned(), + local_id: r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe".to_owned(), + installed_version: "1.3.18-stable".to_owned(), + publisher: Some("Atlassian".to_owned()), + scope: Some("User".to_owned()), + installer_category: Some("exe".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + correlated: Some(SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Atlassian.AtlassianCLI".to_owned(), + name: "Atlassian CLI".to_owned(), + moniker: None, + version: Some("1.3.18-stable".to_owned()), + channel: None, + match_criteria: Some("ProductCode".to_owned()), + }), + installed_version_canonical: false, + correlated_requires_explicit_upgrade: false, + correlated_lacks_compatible_installer: false, + }; + + let item = list_match_from_installed(package); + + assert_eq!(item.id, "Atlassian.AtlassianCLI"); + assert_eq!( + item.local_id, + r"ARP\User\X64\Atlassian.AtlassianCLI_Microsoft.Winget.Source_8wekyb3d8bbwe" + ); + assert_ne!(item.id, item.local_id); + assert_eq!(item.source_name.as_deref(), Some("winget")); + } + + #[test] + fn list_response_serialization_keeps_pascal_case_fields() { + let response = ListResponse { + matches: vec![ListMatch { + name: "PowerToys".to_owned(), + id: "Microsoft.PowerToys".to_owned(), + local_id: r"ARP\Machine\X64\PowerToys".to_owned(), + installed_version: "0.98.1".to_owned(), + available_version: Some("0.99.0".to_owned()), + source_name: Some("winget".to_owned()), + publisher: Some("Microsoft".to_owned()), + scope: Some("Machine".to_owned()), + installer_category: Some("msi".to_owned()), + install_location: None, + package_family_names: Vec::new(), + product_codes: Vec::new(), + upgrade_codes: Vec::new(), + }], + warnings: Vec::new(), + truncated: false, + }; + + let value = serde_json::to_value(&response).expect("serialize list response"); + let match_value = &value["Matches"][0]; + + assert_eq!(match_value["LocalId"], r"ARP\Machine\X64\PowerToys"); + assert_eq!(match_value["InstalledVersion"], "0.98.1"); + assert_eq!(match_value["AvailableVersion"], "0.99.0"); + assert_eq!(match_value["SourceName"], "winget"); + assert!(match_value.get("local_id").is_none()); + assert!(match_value.get("installed_version").is_none()); + assert!(match_value.get("available_version").is_none()); + assert!(match_value.get("source_name").is_none()); + } + + #[test] + fn search_response_serialization_preserves_minimum_pascal_case_shape() { + let response = SearchResponse { + matches: vec![SearchMatch { + source_name: "msstore".to_owned(), + source_kind: SourceKind::Rest, + id: "9WZDNCRFJBMP".to_owned(), + name: "Microsoft To Do".to_owned(), + moniker: None, + version: Some("2.123.456.0".to_owned()), + channel: None, + match_criteria: None, + }], + warnings: Vec::new(), + truncated: false, + }; + + let value = serde_json::to_value(&response).expect("serialize search response"); + let match_value = &value["Matches"][0]; + + assert_eq!(match_value["Name"], "Microsoft To Do"); + assert_eq!(match_value["Id"], "9WZDNCRFJBMP"); + assert_eq!(match_value["Version"], "2.123.456.0"); + assert_eq!(match_value["SourceName"], "msstore"); + assert!(match_value.get("source_name").is_none()); + } + + #[test] + fn versions_result_serialization_keeps_pascal_case_fields() { + let result = VersionsResult { + package: SearchMatch { + source_name: "winget".to_owned(), + source_kind: SourceKind::PreIndexed, + id: "Microsoft.PowerToys".to_owned(), + name: "PowerToys".to_owned(), + moniker: None, + version: Some("0.99.0".to_owned()), + channel: None, + match_criteria: None, + }, + versions: vec![ + VersionKey { + version: "0.99.0".to_owned(), + channel: "stable".to_owned(), + }, + VersionKey { + version: "0.100.0-preview".to_owned(), + channel: "preview".to_owned(), + }, + ], + warnings: Vec::new(), + }; + + let value = serde_json::to_value(&result).expect("serialize versions result"); + + assert_eq!(value["Versions"][0]["Version"], "0.99.0"); + assert_eq!(value["Versions"][0]["Channel"], "stable"); + assert_eq!(value["Versions"][1]["Version"], "0.100.0-preview"); + assert_eq!(value["Versions"][1]["Channel"], "preview"); + assert!(value.get("versions").is_none()); + } + #[test] fn version_data_parses_arp_bounds_from_winget_payload() { // Sanity check that aMiV/aMaV deserialize from a real-world