diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d262654..67859ad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - os: [windows-latest] + os: [windows-2022, windows-latest] steps: - uses: actions/checkout@v3 @@ -40,8 +40,8 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-latest, ubuntu-latest, windows-latest] - + os: [macos-latest, ubuntu-latest, windows-2022, windows-latest] + steps: - uses: actions/checkout@v3 - name: Install modules diff --git a/.gitignore b/.gitignore index ae3e075..d4bcd94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ src/obj src/bin +src/*/obj +src/*/bin +src/*/*/obj +src/*/*/bin + +# Test results +TestResults*.xml diff --git a/README.md b/README.md index 501d1e2..5aa1e27 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The ```lib``` folder contains the YamlDotNet assemblies. They are not really req ## Installation -This module is available for installation via [Powershell Gallery](http://www.powershellgallery.com/). Simply run the following command: +This module is available for installation via [Powershell Gallery](http://www.powershellgallery.com/). ```powershell Install-Module powershell-yaml diff --git a/Tests/CaseSensitiveKeys.ps1 b/Tests/CaseSensitiveKeys.ps1 new file mode 100644 index 0000000..4cb099f --- /dev/null +++ b/Tests/CaseSensitiveKeys.ps1 @@ -0,0 +1,51 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test class demonstrating YamlKey attribute for case-sensitive YAML keys +# PowerShell class properties are case-insensitive, so we need the attribute +# to distinguish between "Test" and "test" in the YAML +class CaseSensitiveTest : YamlBase { + [YamlKey("Test")] + [string]$CapitalizedTest = "" + + [YamlKey("test")] + [int]$LowercaseTest = 0 +} + +# Test class with mixed attribute and auto-conversion +class MixedKeysTest : YamlBase { + # Uses attribute + [YamlKey("custom-key")] + [string]$CustomProperty = "" + + # Uses automatic PascalCase -> hyphenated-case conversion + [int]$AutoConvertedKey = 0 +} + +# Test class that will fail due to duplicate key without explicit mapping +class IWillFailDueToDuplicateKey : YamlBase { + [string]$test = "" +} + +# Test class that succeeds because all duplicate keys are explicitly mapped +class IWillSucceedBecauseIHaveAMappedKey : YamlBase { + [YamlKey("test")] + [string]$test = "" + + [YamlKey("Test")] + [string]$alsoTestButUppercase = "" +} diff --git a/Tests/DeepNestedConvertersClasses.ps1 b/Tests/DeepNestedConvertersClasses.ps1 new file mode 100644 index 0000000..89b1728 --- /dev/null +++ b/Tests/DeepNestedConvertersClasses.ps1 @@ -0,0 +1,177 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Test classes for deep nesting with custom converters + +using namespace PowerShellYaml +using namespace System +using namespace System.Collections.Generic + +# Custom type: IP Address (renamed to avoid conflict with System.Net.IPAddress) +class CustomIPAddress { + [byte]$Octet1 = 0 + [byte]$Octet2 = 0 + [byte]$Octet3 = 0 + [byte]$Octet4 = 0 + + CustomIPAddress() { + $this.Octet1 = 0 + $this.Octet2 = 0 + $this.Octet3 = 0 + $this.Octet4 = 0 + } + + [string] ToString() { + return "$($this.Octet1).$($this.Octet2).$($this.Octet3).$($this.Octet4)" + } +} + +# Custom converter for IP Address +class IPAddressConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [CustomIPAddress] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $ip = [CustomIPAddress]@{} + + if ($data -is [string]) { + # Parse string format: "192.168.1.1" + $octets = $data -split '\.' + if ($octets.Length -ne 4) { + throw [FormatException]::new("Invalid IP address format: $data") + } + $ip.Octet1 = [byte]$octets[0] + $ip.Octet2 = [byte]$octets[1] + $ip.Octet3 = [byte]$octets[2] + $ip.Octet4 = [byte]$octets[3] + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format + if ($data.ContainsKey('a')) { $ip.Octet1 = [byte]$data['a'] } + if ($data.ContainsKey('b')) { $ip.Octet2 = [byte]$data['b'] } + if ($data.ContainsKey('c')) { $ip.Octet3 = [byte]$data['c'] } + if ($data.ContainsKey('d')) { $ip.Octet4 = [byte]$data['d'] } + } + + return $ip + } + + [object] ConvertToYaml([object]$value) { + $ip = [CustomIPAddress]$value + return @{ + Value = $ip.ToString() + Tag = '!ipaddr' + } + } +} + +# Custom type: Duration (simple time span) +class Duration { + [int]$Hours = 0 + [int]$Minutes = 0 + [int]$Seconds = 0 + + Duration() { + $this.Hours = 0 + $this.Minutes = 0 + $this.Seconds = 0 + } + + [string] ToString() { + return "$($this.Hours)h$($this.Minutes)m$($this.Seconds)s" + } +} + +# Custom converter for Duration +class DurationConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + return $targetType -eq [Duration] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $duration = [Duration]::new() + + if ($data -is [string]) { + # Parse string format: "2h30m15s" + if ($data -match '^(\d+)h(\d+)m(\d+)s$') { + $duration.Hours = [int]$Matches[1] + $duration.Minutes = [int]$Matches[2] + $duration.Seconds = [int]$Matches[3] + } + else { + throw [FormatException]::new("Invalid duration format: $data") + } + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + if ($data.ContainsKey('hours')) { $duration.Hours = [int]$data['hours'] } + if ($data.ContainsKey('minutes')) { $duration.Minutes = [int]$data['minutes'] } + if ($data.ContainsKey('seconds')) { $duration.Seconds = [int]$data['seconds'] } + } + + return $duration + } + + [object] ConvertToYaml([object]$value) { + $duration = [Duration]$value + return @{ + Value = $duration.ToString() + Tag = '!duration' + } + } +} + +# Level 3: Server configuration (deepest level with converters) +class ServerConfig : YamlBase { + [string]$Hostname = "" + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$Address = $null + + [int]$Port = 0 + + [YamlConverter("DurationConverter")] + [Duration]$Timeout = $null +} + +# Level 2: Database configuration (middle level with converters) +class DatabaseConfig : YamlBase { + [string]$Name = "" + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$Host = $null + + [int]$Port = 0 + + [ServerConfig]$PrimaryServer = $null + [ServerConfig]$ReplicaServer = $null + + [YamlConverter("DurationConverter")] + [Duration]$ConnectionTimeout = $null +} + +# Level 1: Application configuration (top level) +class ApplicationConfig : YamlBase { + [string]$AppName = "" + [string]$Environment = "" + + [DatabaseConfig]$Database = $null + + [YamlConverter("IPAddressConverter")] + [CustomIPAddress]$ApiGateway = $null + + [YamlConverter("DurationConverter")] + [Duration]$RequestTimeout = $null +} diff --git a/Tests/DeepNestingClasses.ps1 b/Tests/DeepNestingClasses.ps1 new file mode 100644 index 0000000..0fc03ae --- /dev/null +++ b/Tests/DeepNestingClasses.ps1 @@ -0,0 +1,35 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Simple classes using default YamlBase implementations +# No need to implement ToDictionary/FromDictionary! + +class Person : YamlBase { + [string]$Name = "" + [int]$Age = 0 +} + +class Family : YamlBase { + [Person]$Mom = $null + [Person]$Dad = $null + [Person[]]$Children = $null +} + +class MyClass : YamlBase { + [string]$Title = "" + [Family]$Family = $null +} diff --git a/Tests/MappingStyleClasses.ps1 b/Tests/MappingStyleClasses.ps1 new file mode 100644 index 0000000..713b00c --- /dev/null +++ b/Tests/MappingStyleClasses.ps1 @@ -0,0 +1,36 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test classes for mapping style +class Address : YamlBase { + [string]$Street = "" + [string]$City = "" + [string]$Zip = "" +} + +class Person : YamlBase { + [string]$Name = "" + [int]$Age = 0 + [Address]$Address = $null +} + +class Company : YamlBase { + [string]$Name = "" + [Person]$Ceo = $null + [Person[]]$Employees = @() +} diff --git a/Tests/TaggedScalars.ps1 b/Tests/TaggedScalars.ps1 new file mode 100644 index 0000000..4c70f76 --- /dev/null +++ b/Tests/TaggedScalars.ps1 @@ -0,0 +1,22 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Test class for tagged scalar values +class TaggedScalarsTest : YamlBase { + [string]$StringValue = "" + [int]$IntValue = 0 +} diff --git a/Tests/TypedYamlTestClasses.ps1 b/Tests/TypedYamlTestClasses.ps1 new file mode 100644 index 0000000..1b011c6 --- /dev/null +++ b/Tests/TypedYamlTestClasses.ps1 @@ -0,0 +1,188 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml +using namespace System.Collections.Generic + +class SimpleConfig : YamlBase { + [string]$Name + [int]$Port = 8080 + [bool]$Enabled = $true + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + $dict['enabled'] = $this.Enabled + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('enabled')) { + # Handle both bool and string representations + $val = $data['enabled'] + if ($val -is [bool]) { + $this.Enabled = $val + } else { + $this.Enabled = [System.Convert]::ToBoolean($val) + } + } + } +} + +class DatabaseConfig : YamlBase { + [string]$Host = 'localhost' + [int]$Port = 5432 + [string]$Database + [bool]$UseSsl = $true + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['host'] = $this.Host + $dict['port'] = $this.Port + $dict['database'] = $this.Database + $dict['use-ssl'] = $this.UseSsl + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('host')) { $this.Host = [string]$data['host'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('database')) { $this.Database = [string]$data['database'] } + if ($data.ContainsKey('use-ssl')) { + # Handle both bool and string representations + $val = $data['use-ssl'] + if ($val -is [bool]) { + $this.UseSsl = $val + } else { + $this.UseSsl = [System.Convert]::ToBoolean($val) + } + } + } +} + +class ComplexConfig : YamlBase { + [string]$AppName + [DatabaseConfig]$Database + [string[]]$Tags = @() + [int]$MaxConnections = 100 + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['app-name'] = $this.AppName + if ($this.Database) { + # Return the YamlBase object itself, not its dictionary + # This preserves metadata during serialization + $dict['database'] = $this.Database + } + $dict['tags'] = $this.Tags + $dict['max-connections'] = $this.MaxConnections + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('app-name')) { $this.AppName = [string]$data['app-name'] } + if ($data.ContainsKey('database')) { + $this.Database = [DatabaseConfig]::new() + $this.Database.FromDictionary($data['database']) + } + if ($data.ContainsKey('tags')) { + $val = $data['tags'] + if ($val -eq $null) { + $this.Tags = @() + } else { + $this.Tags = @($val) + } + } + if ($data.ContainsKey('max-connections')) { $this.MaxConnections = [int]$data['max-connections'] } + } +} + +class SimpleConfigWithArray : YamlBase { + [string]$Name + [int]$Port = 8080 + [string[]]$Tags = @() + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + $dict['tags'] = $this.Tags + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + if ($data.ContainsKey('tags')) { + $val = $data['tags'] + if ($val -eq $null) { + $this.Tags = @() + } else { + $this.Tags = @($val) + } + } + } +} + +class ServerInfo : YamlBase { + [string]$Name + [int]$Port + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['name'] = $this.Name + $dict['port'] = $this.Port + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('name')) { $this.Name = [string]$data['name'] } + if ($data.ContainsKey('port')) { $this.Port = [int]$data['port'] } + } +} + +class ConfigWithServers : YamlBase { + [ServerInfo[]]$Servers = @() + [DatabaseConfig]$Database + + [Dictionary[string, object]] ToDictionary() { + $dict = [Dictionary[string, object]]::new() + $dict['servers'] = $this.Servers + if ($this.Database) { + $dict['database'] = $this.Database + } + return $dict + } + + [void] FromDictionary([Dictionary[string, object]]$data) { + if ($data.ContainsKey('servers')) { + $val = $data['servers'] + if ($val -is [System.Collections.IList]) { + $this.Servers = @($val | ForEach-Object { + $server = [ServerInfo]::new() + $server.FromDictionary($_) + $server + }) + } + } + if ($data.ContainsKey('database')) { + $this.Database = [DatabaseConfig]::new() + $this.Database.FromDictionary($data['database']) + } + } +} diff --git a/Tests/case-sensitive-keys.Tests.ps1 b/Tests/case-sensitive-keys.Tests.ps1 new file mode 100644 index 0000000..cd76fca --- /dev/null +++ b/Tests/case-sensitive-keys.Tests.ps1 @@ -0,0 +1,199 @@ +#!/usr/bin/env pwsh +# Case-Sensitive Keys Tests: Demonstrates YamlKey attribute for mapping YAML keys to properties + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/CaseSensitiveKeys.ps1" +} + +Describe "YamlKey Attribute: Case-Sensitive YAML Keys" { + It "Should map case-sensitive YAML keys to different properties" { + # YAML with keys that differ only by case + $yaml = @" +Test: I am uppercase +test: 42 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + + # Verify values were mapped to correct properties + $obj.CapitalizedTest | Should -Be "I am uppercase" + $obj.LowercaseTest | Should -Be 42 + $obj.CapitalizedTest | Should -BeOfType [string] + $obj.LowercaseTest | Should -BeOfType [int] + } + + It "Should serialize with correct case-sensitive keys" { + # Create object + $obj = [CaseSensitiveTest]::new() + $obj.CapitalizedTest = "Hello" + $obj.LowercaseTest = 100 + + # Serialize + $yaml = $obj | ConvertTo-Yaml + + # Verify keys have correct case + $yaml | Should -Match "Test: Hello" + $yaml | Should -Match "test: 100" + } + + It "Should round-trip case-sensitive keys" { + $yaml = @" +Test: Original uppercase +test: 999 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + + # Modify values + $obj.CapitalizedTest = "Modified uppercase" + $obj.LowercaseTest = 888 + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify case is preserved + $newYaml | Should -Match "Test: Modified uppercase" + $newYaml | Should -Match "test: 888" + + # Deserialize again to verify + $obj2 = $newYaml | ConvertFrom-Yaml -As ([CaseSensitiveTest]) + $obj2.CapitalizedTest | Should -Be "Modified uppercase" + $obj2.LowercaseTest | Should -Be 888 + } + + It "Should work with mixed attribute and auto-conversion" { + $yaml = @" +custom-key: Custom value +auto-converted-key: 200 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MixedKeysTest]) + + # Attribute-mapped property + $obj.CustomProperty | Should -Be "Custom value" + # Auto-converted property (AutoConvertedKey -> auto-converted-key) + $obj.AutoConvertedKey | Should -Be 200 + } + + It "Should serialize mixed keys correctly" { + $obj = [MixedKeysTest]::new() + $obj.CustomProperty = "Custom" + $obj.AutoConvertedKey = 300 + + $yaml = $obj | ConvertTo-Yaml + + # Verify both key types + $yaml | Should -Match "custom-key: Custom" + $yaml | Should -Match "auto-converted-key: 300" + } + + It "Should preserve metadata for attribute-mapped properties" { + $yaml = @" +# This is a custom key comment +custom-key: Value with comment +auto-converted-key: 150 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MixedKeysTest]) + + # Verify comment is associated with the correct property + $obj.GetPropertyComment('CustomProperty') | Should -Match "custom key comment" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Comment should be preserved + $newYaml | Should -Match "# This is a custom key comment" + $newYaml | Should -Match "custom-key: Value with comment" + } +} + +Describe "Duplicate Key Detection" { + It "Should error on duplicate keys in PSCustomObject mode" { + $yaml = @" +test: hello +Test: world +"@ + + # This should throw because PSCustomObject mode doesn't allow case-insensitive duplicates + { $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) } | Should -Throw "*Duplicate key*" + } + + It "Should error on duplicate keys without explicit YamlKey mapping in typed mode" { + $yaml = @" +test: hello +Test: world +"@ + + # This should throw because the type doesn't have YamlKey attributes for both variations + { $yaml | ConvertFrom-Yaml -As ([IWillFailDueToDuplicateKey]) } | Should -Throw "*case-insensitive duplicate keys*" + } + + It "Should succeed with duplicate keys when all are explicitly mapped" { + $yaml = @" +test: hello +Test: world +"@ + + # This should work because both keys are explicitly mapped with YamlKey attributes + $obj = $yaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + + # Verify both values were mapped correctly + $obj.test | Should -Be "hello" + $obj.alsoTestButUppercase | Should -Be "world" + } + + It "Should round-trip duplicate keys with explicit mappings" { + $yaml = @" +test: lowercase value +Test: uppercase value +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + + # Modify values + $obj.test = "modified lowercase" + $obj.alsoTestButUppercase = "modified uppercase" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify both keys are preserved with correct case + $newYaml | Should -Match "test: modified lowercase" + $newYaml | Should -Match "Test: modified uppercase" + + # Deserialize again to verify + $obj2 = $newYaml | ConvertFrom-Yaml -As ([IWillSucceedBecauseIHaveAMappedKey]) + $obj2.test | Should -Be "modified lowercase" + $obj2.alsoTestButUppercase | Should -Be "modified uppercase" + } + + It "Should error with helpful message indicating which keys are unmapped" { + $yaml = @" +test: value1 +Test: value2 +TEST: value3 +"@ + + # This should throw with a message indicating which keys lack explicit mappings + $errorMessage = "" + try { + $yaml | ConvertFrom-Yaml -As ([IWillFailDueToDuplicateKey]) + } catch { + $errorMessage = $_.Exception.Message + } + + # Error should mention the duplicate keys + $errorMessage | Should -Match "test" + $errorMessage | Should -Match "Test" + # Error should mention the solution + $errorMessage | Should -Match "YamlKey" + } +} diff --git a/Tests/custom-converters.Tests.ps1 b/Tests/custom-converters.Tests.ps1 new file mode 100644 index 0000000..a07c08b --- /dev/null +++ b/Tests/custom-converters.Tests.ps1 @@ -0,0 +1,205 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for custom type converters + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Define test classes + . "$PSScriptRoot/../examples/classes/CustomConverters.ps1" +} + +Describe "Custom Type Converters" { + Context "Local tags (single !)" { + It "Should deserialize !semver tag" { + $yaml = @" +app-name: "LocalTagTest" +version: !semver "1.2.3" +release-date: !datetime "2024-01-15 10:30:00 UTC" +build-date: !datetime "2024-01-15 09:00:00 UTC" +features: ["feature1"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version | Should -Not -BeNullOrEmpty + $config.Version.Major | Should -Be 1 + $config.Version.Minor | Should -Be 2 + $config.Version.Patch | Should -Be 3 + $config.Version.PreRelease | Should -Be "" + } + + It "Should deserialize !datetime tag" { + $yaml = @" +app-name: "LocalTagTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-15 10:30:00 UTC" +build-date: !datetime "2024-01-15 09:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.ReleaseDate.Year | Should -Be 2024 + $config.ReleaseDate.Month | Should -Be 1 + $config.ReleaseDate.Day | Should -Be 15 + $config.ReleaseDate.Hour | Should -Be 10 + $config.ReleaseDate.Minute | Should -Be 30 + } + } + + Context "Global tags (double !!)" { + It "Should deserialize !!semver tag" { + $yaml = @" +app-name: "GlobalTagTest" +version: !!semver "2.1.5-beta" +release-date: !!datetime "2024-02-20 14:45:30 UTC" +build-date: !!datetime "2024-02-20 12:00:00 UTC" +features: ["feature1", "feature2"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 2 + $config.Version.Minor | Should -Be 1 + $config.Version.Patch | Should -Be 5 + $config.Version.PreRelease | Should -Be "beta" + } + } + + Context "Full URI tags" { + It "Should deserialize full URI tags" { + $yaml = @" +app-name: "URITagTest" +version: ! "3.0.0" +release-date: ! "2024-03-25 16:20:45 UTC" +build-date: ! "2024-03-25 15:00:00 UTC" +features: ["advanced-feature"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 3 + $config.Version.Minor | Should -Be 0 + $config.Version.Patch | Should -Be 0 + } + } + + Context "Tag preservation in round-trip" { + It "Should preserve !semver tag in serialization" { + $yaml = @" +app-name: "RoundTripTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: ["initial"] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!semver" + } + + It "Should preserve !datetime tag in serialization" { + $yaml = @" +app-name: "RoundTripTest" +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!datetime" + } + } + + Context "Multiple input formats" { + It "Should deserialize semver from string format" { + $yaml = @" +app-name: "StringFormat" +version: !semver "2.1.5-beta3" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.ToString() | Should -Be "2.1.5-beta3" + } + + It "Should deserialize semver from dictionary format" { + $yaml = @" +app-name: "DictFormat" +version: !semver { major: 3, minor: 0, patch: 0, pre: "alpha1" } +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.Version.Major | Should -Be 3 + $config.Version.Minor | Should -Be 0 + $config.Version.Patch | Should -Be 0 + $config.Version.PreRelease | Should -Be "alpha1" + } + + It "Should deserialize datetime from dictionary format" { + $yaml = @" +app-name: "DictFormat" +version: !semver "1.0.0" +release-date: !datetime { year: 2025, month: 6, day: 1, hour: 12, minute: 0, second: 0 } +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + $config.ReleaseDate.Year | Should -Be 2025 + $config.ReleaseDate.Month | Should -Be 6 + $config.ReleaseDate.Day | Should -Be 1 + $config.ReleaseDate.Hour | Should -Be 12 + } + } + + Context "Error handling" { + It "Should throw error for invalid semver format" { + $yaml = @" +app-name: "ErrorTest" +version: !semver "not-a-valid-version" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + { $yaml | ConvertFrom-Yaml -As ([AppRelease]) } | Should -Throw + } + } + + Context "Converter registration with string type name" { + It "Should find converter by string type name" { + # This test verifies that [YamlConverter("SemVerConverter")] works + # by resolving the type name to the actual type + $yaml = @" +app-name: "StringTypeNameTest" +version: !semver "1.2.3" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + $config = $yaml | ConvertFrom-Yaml -As ([AppRelease]) + + # If this succeeds, it means the string type name was resolved correctly + $config.Version | Should -Not -BeNullOrEmpty + $config.Version.Major | Should -Be 1 + } + } +} diff --git a/Tests/deep-nested-converters.Tests.ps1 b/Tests/deep-nested-converters.Tests.ps1 new file mode 100644 index 0000000..9af67d9 --- /dev/null +++ b/Tests/deep-nested-converters.Tests.ps1 @@ -0,0 +1,344 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for deeply nested structures with custom type converters + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + . "$PSScriptRoot/DeepNestedConvertersClasses.ps1" +} + +Describe "Deep Nested Custom Converters" { + Context "Three-level nesting with converters at each level" { + It "Should deserialize converters at all nesting levels" { + $yaml = @" +app-name: "ProductionApp" +environment: "prod" +# API Gateway address +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "5h30m0s" +database: + name: "main_db" + # Database host address + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h1m30s" + primary-server: + hostname: "db-primary-01" + address: !ipaddr "192.168.1.101" + port: 5432 + timeout: !duration "0h0m30s" + replica-server: + hostname: "db-replica-01" + address: !ipaddr "192.168.1.102" + port: 5432 + timeout: !duration "0h0m45s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Level 1 converters + $config.ApiGateway | Should -Not -BeNullOrEmpty + $config.ApiGateway.ToString() | Should -Be "10.0.0.1" + $config.ApiGateway.Octet1 | Should -Be 10 + $config.ApiGateway.Octet4 | Should -Be 1 + + $config.RequestTimeout | Should -Not -BeNullOrEmpty + $config.RequestTimeout.ToString() | Should -Be "5h30m0s" + $config.RequestTimeout.Hours | Should -Be 5 + $config.RequestTimeout.Minutes | Should -Be 30 + + # Level 2 converters + $config.Database.Host.ToString() | Should -Be "192.168.1.100" + $config.Database.Host.Octet3 | Should -Be 1 + $config.Database.Host.Octet4 | Should -Be 100 + + $config.Database.ConnectionTimeout.ToString() | Should -Be "0h1m30s" + $config.Database.ConnectionTimeout.Minutes | Should -Be 1 + $config.Database.ConnectionTimeout.Seconds | Should -Be 30 + + # Level 3 converters (primary server) + $config.Database.PrimaryServer.Address.ToString() | Should -Be "192.168.1.101" + $config.Database.PrimaryServer.Address.Octet4 | Should -Be 101 + + $config.Database.PrimaryServer.Timeout.ToString() | Should -Be "0h0m30s" + $config.Database.PrimaryServer.Timeout.Seconds | Should -Be 30 + + # Level 3 converters (replica server) + $config.Database.ReplicaServer.Address.ToString() | Should -Be "192.168.1.102" + $config.Database.ReplicaServer.Address.Octet4 | Should -Be 102 + + $config.Database.ReplicaServer.Timeout.ToString() | Should -Be "0h0m45s" + $config.Database.ReplicaServer.Timeout.Seconds | Should -Be 45 + } + + It "Should preserve tags at all nesting levels during round-trip" { + $yaml = @" +app-name: "TestApp" +environment: "test" +api-gateway: !ipaddr "10.1.2.3" +request-timeout: !duration "1h0m0s" +database: + name: "test_db" + host: !ipaddr "127.0.0.1" + port: 3306 + connection-timeout: !duration "0h0m10s" + primary-server: + hostname: "localhost" + address: !ipaddr "127.0.0.1" + port: 3306 + timeout: !duration "0h0m5s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + $roundTripped = $config | ConvertTo-Yaml + + # Verify all tags are preserved (note: order may vary) + $roundTripped | Should -Match "!ipaddr" + $roundTripped | Should -Match "!duration" + $roundTripped | Should -Match "10\.1\.2\.3" + $roundTripped | Should -Match "1h0m0s" + $roundTripped | Should -Match "127\.0\.0\.1" + $roundTripped | Should -Match "0h0m10s" + $roundTripped | Should -Match "0h0m5s" + } + + It "Should handle dictionary format for nested converters" { + $yaml = @" +app-name: "DictFormatApp" +environment: "dev" +api-gateway: !ipaddr { a: 172, b: 16, c: 0, d: 1 } +request-timeout: !duration { hours: 2, minutes: 15, seconds: 30 } +database: + name: "dev_db" + host: !ipaddr { a: 192, b: 168, c: 1, d: 50 } + port: 5432 + connection-timeout: !duration { hours: 0, minutes: 2, seconds: 0 } + primary-server: + hostname: "dev-primary" + address: !ipaddr { a: 192, b: 168, c: 1, d: 51 } + port: 5432 + timeout: !duration { hours: 0, minutes: 0, seconds: 20 } +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Verify dictionary format was parsed correctly + $config.ApiGateway.ToString() | Should -Be "172.16.0.1" + $config.RequestTimeout.Hours | Should -Be 2 + $config.RequestTimeout.Minutes | Should -Be 15 + $config.RequestTimeout.Seconds | Should -Be 30 + + $config.Database.Host.ToString() | Should -Be "192.168.1.50" + $config.Database.ConnectionTimeout.Minutes | Should -Be 2 + + $config.Database.PrimaryServer.Address.ToString() | Should -Be "192.168.1.51" + $config.Database.PrimaryServer.Timeout.Seconds | Should -Be 20 + } + + It "Should handle null nested objects with converters" { + $yaml = @" +app-name: "MinimalApp" +environment: "test" +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "1h0m0s" +database: + name: "minimal_db" + host: !ipaddr "127.0.0.1" + port: 5432 + connection-timeout: !duration "0h0m30s" + primary-server: null + replica-server: null +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + $config.ApiGateway | Should -Not -BeNullOrEmpty + $config.Database | Should -Not -BeNullOrEmpty + $config.Database.Host | Should -Not -BeNullOrEmpty + $config.Database.PrimaryServer | Should -BeNullOrEmpty + $config.Database.ReplicaServer | Should -BeNullOrEmpty + } + + It "Should preserve comments with nested converters" { + $yaml = @" +# Main application config +app-name: "CommentedApp" +environment: "prod" +# Gateway IP +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "2h0m0s" +database: + name: "prod_db" + # Database IP address + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h1m0s" + primary-server: + hostname: "primary" + # Primary server address + address: !ipaddr "192.168.1.101" + port: 5432 + # Server timeout + timeout: !duration "0h0m30s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Verify comments are preserved + $config.GetPropertyComment('AppName') | Should -Match "Main application config" + $config.GetPropertyComment('ApiGateway') | Should -Match "Gateway IP" + $config.Database.GetPropertyComment('Host') | Should -Match "Database IP address" + $config.Database.PrimaryServer.GetPropertyComment('Address') | Should -Match "Primary server address" + $config.Database.PrimaryServer.GetPropertyComment('Timeout') | Should -Match "Server timeout" + + # Round-trip and verify comments in output + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "# Main application config" + $roundTripped | Should -Match "# Gateway IP" + $roundTripped | Should -Match "# Database IP address" + $roundTripped | Should -Match "# Primary server address" + $roundTripped | Should -Match "# Server timeout" + } + + It "Should handle modification of nested converter values" { + $yaml = @" +app-name: "ModifiableApp" +environment: "dev" +api-gateway: !ipaddr "10.0.0.1" +request-timeout: !duration "1h0m0s" +database: + name: "dev_db" + host: !ipaddr "192.168.1.100" + port: 5432 + connection-timeout: !duration "0h0m30s" + primary-server: + hostname: "primary" + address: !ipaddr "192.168.1.101" + port: 5432 + timeout: !duration "0h0m15s" +"@ + + $config = $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) + + # Modify nested converter values + $newIp = [CustomIPAddress]@{ + Octet1 = 172 + Octet2 = 16 + Octet3 = 0 + Octet4 = 1 + } + $config.Database.PrimaryServer.Address = $newIp + + $newTimeout = [Duration]@{ + Hours = 0 + Minutes = 1 + Seconds = 0 + } + $config.Database.PrimaryServer.Timeout = $newTimeout + + # Serialize and verify + $roundTripped = $config | ConvertTo-Yaml + + $roundTripped | Should -Match "!ipaddr.*172\.16\.0\.1" + $roundTripped | Should -Match "!duration.*0h1m0s" + } + + It "Should handle errors in nested converter parsing" { + $yaml = @" +app-name: "ErrorApp" +environment: "test" +api-gateway: !ipaddr "not.a.valid.ip" +request-timeout: !duration "1h0m0s" +database: + name: "test_db" + host: !ipaddr "127.0.0.1" + port: 5432 + connection-timeout: !duration "0h0m30s" +"@ + + { $yaml | ConvertFrom-Yaml -As ([ApplicationConfig]) } | Should -Throw + } + + It "Should work with -OmitNull on nested converters" { + $config = [ApplicationConfig]::new() + $config.AppName = "NullOmitApp" + $config.Environment = "test" + + $ip = [CustomIPAddress]@{ + Octet1 = 10 + Octet2 = 0 + Octet3 = 0 + Octet4 = 1 + } + $config.ApiGateway = $ip + + $duration = [Duration]@{ + Hours = 1 + Minutes = 0 + Seconds = 0 + } + $config.RequestTimeout = $duration + + # Database is null - should be omitted with -OmitNull + $yaml = $config | ConvertTo-Yaml -OmitNull + + $yaml | Should -Match "api-gateway: !ipaddr 10\.0\.0\.1" + $yaml | Should -Match "request-timeout: !duration 1h0m0s" + $yaml | Should -Not -Match "database:" + } + + It "Should work with -EmitTags on nested converters" { + $config = [ApplicationConfig]::new() + $config.AppName = "TagEmitApp" + $config.Environment = "prod" + + $ip = [CustomIPAddress]@{ + Octet1 = 192 + Octet2 = 168 + Octet3 = 1 + Octet4 = 1 + } + $config.ApiGateway = $ip + + $db = [DatabaseConfig]::new() + $db.Name = "prod_db" + $db.Port = 5432 + + $dbIp = [CustomIPAddress]@{ + Octet1 = 192 + Octet2 = 168 + Octet3 = 1 + Octet4 = 100 + } + $db.Host = $dbIp + + $config.Database = $db + + $yaml = $config | ConvertTo-Yaml -EmitTags + + # Custom converter tags + $yaml | Should -Match "api-gateway: !ipaddr 192\.168\.1\.1" + $yaml | Should -Match "host: !ipaddr 192\.168\.1\.100" + + # Standard tags + $yaml | Should -Match "app-name: !!str TagEmitApp" + $yaml | Should -Match "environment: !!str prod" + $yaml | Should -Match "name: !!str prod_db" + $yaml | Should -Match "port: !!int 5432" + } + } +} diff --git a/Tests/deep-nesting.Tests.ps1 b/Tests/deep-nesting.Tests.ps1 new file mode 100644 index 0000000..9bad68f --- /dev/null +++ b/Tests/deep-nesting.Tests.ps1 @@ -0,0 +1,206 @@ +#!/usr/bin/env pwsh +# Deep Nesting Tests: Demonstrates round-tripping with deeply nested YamlBase objects + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/DeepNestingClasses.ps1" +} + +Describe "Deep Nesting: Round-trip with Complex Object Hierarchies" { + It "Should round-trip deeply nested objects with tags and comments" { + $yaml = @" +# Document describing a family +title: The Smith Family +family: + # Mother's information + mom: + name: Jane Smith + age: !!int 45 + # Father's information + dad: + name: John Smith + age: !!int 47 + # Children in the family + children: + - name: Alice Smith + age: !!int 18 + - name: Bob Smith + age: !!int 15 +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Verify structure + $obj.Title | Should -Be "The Smith Family" + $obj.Family | Should -Not -BeNullOrEmpty + $obj.Family.Mom.Name | Should -Be "Jane Smith" + $obj.Family.Mom.Age | Should -Be 45 + $obj.Family.Dad.Name | Should -Be "John Smith" + $obj.Family.Dad.Age | Should -Be 47 + $obj.Family.Children.Count | Should -Be 2 + $obj.Family.Children[0].Name | Should -Be "Alice Smith" + $obj.Family.Children[0].Age | Should -Be 18 + + # Verify comments were preserved + $obj.GetPropertyComment('Title') | Should -Match "Document describing a family" + $obj.Family.GetPropertyComment('Mom') | Should -Match "Mother's information" + $obj.Family.GetPropertyComment('Dad') | Should -Match "Father's information" + $obj.Family.GetPropertyComment('Children') | Should -Match "Children in the family" + + # Verify tags were preserved + $obj.Family.Mom.GetPropertyTag('Age') | Should -Be "tag:yaml.org,2002:int" + $obj.Family.Dad.GetPropertyTag('Age') | Should -Be "tag:yaml.org,2002:int" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify tags are in output + $newYaml | Should -Match "age: !!int 45" + $newYaml | Should -Match "age: !!int 47" + $newYaml | Should -Match "age: !!int 18" + $newYaml | Should -Match "age: !!int 15" + + # Verify comments are in output + $newYaml | Should -Match "# Document describing a family" + $newYaml | Should -Match "# Mother's information" + $newYaml | Should -Match "# Father's information" + $newYaml | Should -Match "# Children in the family" + + # Verify structure is preserved + $newYaml | Should -Match "title: The Smith Family" + $newYaml | Should -Match "name: Jane Smith" + $newYaml | Should -Match "name: John Smith" + $newYaml | Should -Match "name: Alice Smith" + $newYaml | Should -Match "name: Bob Smith" + } + + It "Should handle partial null values with -OmitNull" { + $yaml = @" +title: The Johnson Family +family: + mom: + name: Mary Johnson + age: 42 + dad: null + children: + - name: Charlie + age: 10 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Verify structure + $obj.Family.Mom | Should -Not -BeNullOrEmpty + $obj.Family.Dad | Should -BeNullOrEmpty + $obj.Family.Children.Count | Should -Be 1 + + # Serialize without -OmitNull + $yaml1 = $obj | ConvertTo-Yaml + $yaml1 | Should -Match "dad: null" + + # Serialize with -OmitNull + $yaml2 = $obj | ConvertTo-Yaml -OmitNull + $yaml2 | Should -Not -Match "dad:" + $yaml2 | Should -Match "mom:" + $yaml2 | Should -Match "children:" + } + + It "Should emit all tags with -EmitTags on deeply nested objects" { + $obj = [MyClass]::new() + $obj.Title = "The Davis Family" + $obj.Family = [Family]::new() + $obj.Family.Mom = [Person]::new() + $obj.Family.Mom.Name = "Lisa Davis" + $obj.Family.Mom.Age = 38 + $obj.Family.Dad = [Person]::new() + $obj.Family.Dad.Name = "Mike Davis" + $obj.Family.Dad.Age = 40 + $obj.Family.Children = @( + [Person]@{ Name = "Emma Davis"; Age = 12 } + ) + + $yaml = $obj | ConvertTo-Yaml -EmitTags + + # Verify tags on all levels + $yaml | Should -Match "title: !!str The Davis Family" + $yaml | Should -Match "name: !!str Lisa Davis" + $yaml | Should -Match "age: !!int 38" + $yaml | Should -Match "name: !!str Mike Davis" + $yaml | Should -Match "age: !!int 40" + $yaml | Should -Match "name: !!str Emma Davis" + $yaml | Should -Match "age: !!int 12" + } + + It "Should preserve metadata after modification and re-serialization" { + $yaml = @" +title: Original Title +family: + mom: + name: Original Mom + age: !!int 50 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([MyClass]) + + # Modify values + $obj.Title = "Modified Title" + $obj.Family.Mom.Age = 51 + + # Add a comment + $obj.Family.Mom.SetPropertyComment('Age', 'Updated age') + + # Serialize + $newYaml = $obj | ConvertTo-Yaml + + # Original tag should still be there + $newYaml | Should -Match "age: !!int 51" + # New comment should be there + $newYaml | Should -Match "# Updated age" + # Modified values should be there + $newYaml | Should -Match "title: Modified Title" + } + + It "Should handle empty arrays vs null arrays correctly" { + $yaml1 = @" +title: Family with no children +family: + mom: + name: Jane + age: 30 + dad: + name: John + age: 32 + children: [] +"@ + + $obj1 = $yaml1 | ConvertFrom-Yaml -As ([MyClass]) + $null -eq $obj1.Family.Children | Should -Be $false + $obj1.Family.Children.Count | Should -Be 0 + + $yaml2 = @" +title: Family with null children +family: + mom: + name: Jane + age: 30 + dad: + name: John + age: 32 + children: null +"@ + + $obj2 = $yaml2 | ConvertFrom-Yaml -As ([MyClass]) + $null -eq $obj2.Family.Children | Should -Be $true + + # Serialize with -OmitNull + $output1 = $obj1 | ConvertTo-Yaml -OmitNull + $output1 | Should -Match "children:" # Empty array is not omitted + + $output2 = $obj2 | ConvertTo-Yaml -OmitNull + $output2 | Should -Not -Match "children:" # Null is omitted + } +} diff --git a/Tests/enhanced-pscustomobject.Tests.ps1 b/Tests/enhanced-pscustomobject.Tests.ps1 new file mode 100644 index 0000000..dab4399 --- /dev/null +++ b/Tests/enhanced-pscustomobject.Tests.ps1 @@ -0,0 +1,932 @@ +BeforeAll { + # Only import if not already loaded to avoid assembly reload issues + if (-not (Get-Module -Name powershell-yaml)) { + Import-Module $PSScriptRoot/../powershell-yaml.psd1 + } +} + +Describe 'Enhanced PSCustomObject Mode Tests' { + + Context 'Basic Enhanced PSCustomObject Creation' { + It 'Should create enhanced PSCustomObject with -As [PSCustomObject]' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -Not -BeNullOrEmpty + $obj.name | Should -Be 'John' + $obj.age | Should -Be 30 + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should create nested enhanced PSCustomObjects' { + $yaml = @" +person: + name: John + age: 30 + address: + city: New York + zip: 10001 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.person.name | Should -Be 'John' + $obj.person.address.city | Should -Be 'New York' + Test-YamlMetadata $obj | Should -Be $true + Test-YamlMetadata $obj.person | Should -Be $true + Test-YamlMetadata $obj.person.address | Should -Be $true + } + + It 'Should handle arrays in enhanced PSCustomObject' { + $yaml = @" +items: + - name: item1 + value: 100 + - name: item2 + value: 200 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.items | Should -HaveCount 2 + $obj.items[0].name | Should -Be 'item1' + $obj.items[1].value | Should -Be 200 + } + + It 'Should return regular hashtable when -As is not specified' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml + $obj | Should -BeOfType [hashtable] + Test-YamlMetadata $obj | Should -Be $false + } + } + + Context 'Property Comment Operations' { + It 'Should set and get property comments' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'User full name' + + $comment = Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' + $comment | Should -Be 'User full name' + } + + It 'Should preserve inline comments from YAML' { + $yaml = @" +name: John # User's full name +age: 30 # Age in years +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Comments should be preserved from source YAML + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be "User's full name" + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + } + + It 'Should preserve block comments from YAML' { + $yaml = @" +# This is the user's name +name: John +# This is the user's age +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Block comments should be preserved + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be "This is the user's name" + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be "This is the user's age" + } + + It 'Should prefer inline comments over block comments' { + $yaml = @" +# Block comment +name: John # Inline comment +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Inline comment should take precedence + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Inline comment' + } + + It 'Should support programmatic comment addition' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Metadata store should exist even if no comments in source + Test-YamlMetadata $obj | Should -Be $true + + # Add comments programmatically + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'User full name' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + + # Verify comments are stored + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'User full name' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + } + + It 'Should return null for non-existent property comment' { + $yaml = @" +name: John +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $comment = Get-YamlPropertyComment -InputObject $obj -PropertyName 'nonexistent' + $comment | Should -BeNullOrEmpty + } + + It 'Should warn when setting comment on non-enhanced object' { + $obj = [PSCustomObject]@{ name = 'John' } + $warnings = $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'test' 3>&1 + $warnings | Should -Match 'does not have YAML metadata' + } + + It 'Should allow comments on nested properties' { + $yaml = @" +database: + host: localhost + port: 5432 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set comment on nested property + $obj.database | Set-YamlPropertyComment -PropertyName 'host' -Comment 'Database server' + $obj.database | Set-YamlPropertyComment -PropertyName 'port' -Comment 'Database port' + + # Retrieve nested comments + Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'host' | Should -Be 'Database server' + Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'port' | Should -Be 'Database port' + } + } + + Context 'Scalar Style Operations' { + It 'Should set scalar style for property' { + $yaml = @" +description: Some text +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Set-YamlPropertyScalarStyle -PropertyName 'description' -Style Literal + + # Verify it doesn't throw + $obj | Should -Not -BeNullOrEmpty + } + + It 'Should accept valid scalar styles' { + $yaml = "text: value" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Plain } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style SingleQuoted } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style DoubleQuoted } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Literal } | Should -Not -Throw + { $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Folded } | Should -Not -Throw + } + } + + Context 'Test-YamlMetadata Function' { + It 'Should return true for enhanced PSCustomObject' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should return false for regular PSCustomObject' { + $obj = [PSCustomObject]@{ name = 'John' } + Test-YamlMetadata $obj | Should -Be $false + } + + It 'Should return false for hashtable' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml + Test-YamlMetadata $obj | Should -Be $false + } + } + + Context 'Complex Nested Structures' { + It 'Should handle deeply nested objects' { + $yaml = @" +level1: + level2: + level3: + value: deep +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.level1.level2.level3.value | Should -Be 'deep' + Test-YamlMetadata $obj.level1.level2.level3 | Should -Be $true + } + + It 'Should handle mixed arrays and objects' { + $yaml = @" +users: + - name: John + roles: + - admin + - user + - name: Jane + roles: + - user +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.users[0].name | Should -Be 'John' + $obj.users[0].roles[0] | Should -Be 'admin' + $obj.users[1].roles | Should -HaveCount 1 + } + } + + Context 'Type Preservation' { + It 'Should preserve boolean types' { + $yaml = @" +enabled: true +disabled: false +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.enabled | Should -BeOfType [bool] + $obj.enabled | Should -Be $true + $obj.disabled | Should -Be $false + } + + It 'Should preserve numeric types' { + $yaml = @" +integer: 42 +long: 9223372036854775807 +decimal: 3.14159 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.integer | Should -BeOfType [int] + $obj.integer | Should -Be 42 + $obj.decimal | Should -Be 3.14159 + } + + It 'Should handle null values' { + $yaml = @" +name: John +middle: null +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.name | Should -Be 'John' + $obj.middle | Should -BeNullOrEmpty + $obj.age | Should -Be 30 + } + + It 'Should preserve BigInteger types' { + $yaml = @" +bignum: 9999999999999999999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj.bignum | Should -Be ([System.Numerics.BigInteger]::Parse("9999999999999999999999999999999999999999999999999")) + } + + It 'Should round-trip BigInteger values correctly' { + $yaml = @" +bignum: 9999999999999999999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj2.bignum | Should -Be $obj.bignum + } + + It 'Should preserve DateTime types' { + $yaml = @" +timestamp: 2024-01-15T10:30:00Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.timestamp | Should -BeOfType [DateTime] + } + + It 'Should round-trip DateTime values correctly' { + $yaml = @" +timestamp: 2024-01-15T10:30:00.0000000Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.timestamp | Should -BeOfType [DateTime] + # Compare as strings because DateTime comparison can be tricky with timezones + $obj2.timestamp.ToString("o") | Should -Be $obj.timestamp.ToString("o") + } + + It 'Should handle BigInteger in arrays' { + $yaml = @" +numbers: + - 9999999999999999999999999999999999999999999999999 + - 8888888888888888888888888888888888888888888888888 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.numbers[0] | Should -BeOfType [System.Numerics.BigInteger] + $obj.numbers[1] | Should -BeOfType [System.Numerics.BigInteger] + } + + It 'Should round-trip BigInteger arrays correctly' { + $yaml = @" +numbers: + - 9999999999999999999999999999999999999999999999999 + - 8888888888888888888888888888888888888888888888888 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $newYaml = ConvertTo-Yaml $obj + $obj2 = ConvertFrom-Yaml $newYaml -As ([PSCustomObject]) + + $obj2.numbers[0] | Should -Be $obj.numbers[0] + $obj2.numbers[1] | Should -Be $obj.numbers[1] + } + } + + Context 'Backward Compatibility' { + It 'Should maintain original behavior without -As parameter' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml + $obj | Should -BeOfType [hashtable] + $obj['name'] | Should -Be 'John' + $obj['age'] | Should -Be 30 + } + + It 'Should work with -Ordered parameter when not using -As' { + $yaml = @" +z: last +a: first +"@ + $obj = ConvertFrom-Yaml $yaml -Ordered + $obj | Should -Not -BeNullOrEmpty + # Ordered hashtable functionality is preserved + } + } + + Context 'Metadata Persistence Through Properties' { + It 'Should maintain metadata on nested objects' { + $yaml = @" +database: + host: localhost + port: 5432 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set comment on nested object + $obj.database | Set-YamlPropertyComment -PropertyName 'host' -Comment 'Database server address' + + $comment = Get-YamlPropertyComment -InputObject $obj.database -PropertyName 'host' + $comment | Should -Be 'Database server address' + } + + It 'Should allow setting metadata on multiple properties' { + $yaml = @" +name: John +age: 30 +email: john@example.com +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Full name' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + $obj | Set-YamlPropertyComment -PropertyName 'email' -Comment 'Contact email' + + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Full name' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'age' | Should -Be 'Age in years' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'email' | Should -Be 'Contact email' + } + + It 'Should handle updating existing comments' { + $yaml = "name: John" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'First comment' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'First comment' + + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Updated comment' + Get-YamlPropertyComment -InputObject $obj -PropertyName 'name' | Should -Be 'Updated comment' + } + } + + Context 'Scalar Style Metadata' { + It 'Should set and preserve scalar styles for different properties' { + $yaml = @" +title: Simple Title +description: | + Multi-line + description +code: > + Folded + code +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Set different styles + $obj | Set-YamlPropertyScalarStyle -PropertyName 'title' -Style DoubleQuoted + $obj | Set-YamlPropertyScalarStyle -PropertyName 'description' -Style Literal + $obj | Set-YamlPropertyScalarStyle -PropertyName 'code' -Style Folded + + # Verify no errors occurred + Test-YamlMetadata $obj | Should -Be $true + } + + It 'Should allow changing scalar style after initial setting' { + $yaml = "text: value" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style Plain + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style DoubleQuoted + $obj | Set-YamlPropertyScalarStyle -PropertyName 'text' -Style SingleQuoted + + Test-YamlMetadata $obj | Should -Be $true + } + } + + Context 'Edge Cases and Error Handling' { + It 'Should handle empty YAML document' { + $yaml = "" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -BeNullOrEmpty + } + + It 'Should handle YAML with only whitespace' { + $yaml = " `n `n " + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj | Should -BeNullOrEmpty + } + + It 'Should handle arrays with null elements' { + $yaml = @" +items: + - value1 + - null + - value3 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.items | Should -HaveCount 3 + $obj.items[0] | Should -Be 'value1' + $obj.items[1] | Should -BeNullOrEmpty + $obj.items[2] | Should -Be 'value3' + } + + It 'Should handle special YAML values' { + $yaml = @" +null_value: null +tilde_null: ~ +true_value: true +false_value: false +yes_value: yes +no_value: no +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.null_value | Should -BeNullOrEmpty + $obj.tilde_null | Should -BeNullOrEmpty + $obj.true_value | Should -Be $true + $obj.false_value | Should -Be $false + } + + It 'Should handle quoted strings that look like other types' { + $yaml = @" +string_number: "123" +string_bool: "true" +string_null: "null" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.string_number | Should -Be '123' + $obj.string_bool | Should -Be 'true' + $obj.string_null | Should -Be 'null' + } + } + + Context 'Integration with Existing Functions' { + It 'Should work with pipeline operations' { + $yaml = @" +users: + - name: Alice + age: 25 + - name: Bob + age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Pipelineoperations should work + $obj.users | ForEach-Object { $_.name } | Should -Contain 'Alice' + $obj.users | Where-Object { $_.age -gt 25 } | Should -HaveCount 1 + } + + It 'Should allow property access like regular PSCustomObject' { + $yaml = "name: John`nage: 30" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Standard property access + $obj.name | Should -Be 'John' + $obj.PSObject.Properties['age'].Value | Should -Be 30 + + # Property enumeration + $properties = $obj.PSObject.Properties.Name + $properties | Should -Contain 'name' + $properties | Should -Contain 'age' + } + + It 'Should support Get-Member on enhanced objects' { + $yaml = "name: John`nage: 30" + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $members = $obj | Get-Member -MemberType NoteProperty + $members | Should -Not -BeNullOrEmpty + $members.Name | Should -Contain 'name' + $members.Name | Should -Contain 'age' + } + } + + Context 'YAML Style Preservation' { + It 'Should preserve flow mapping style' { + $yaml = @" +flow_map: {key1: value1, key2: value2} +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\{.*\}' + } + + It 'Should preserve flow sequence style' { + $yaml = @" +flow_seq: [item1, item2, item3] +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + } + + It 'Should preserve literal string style (|)' { + $yaml = @" +description: | + This is a multi-line + literal string. +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\|' + } + + It 'Should preserve folded string style (>)' { + $yaml = @" +description: > + This is a folded + multi-line string. +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '>' + } + + It 'Should preserve single-quoted strings' { + $yaml = @" +text: 'hello world' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match "'" + } + + It 'Should preserve double-quoted strings' { + $yaml = @" +text: "hello world" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '"' + } + + It 'Should preserve mixed flow and block styles' { + $yaml = @" +server: + name: web-01 + ports: [80, 443] + config: {debug: true, timeout: 30} + items: + - one + - two +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + $result | Should -Match '\{.*\}' + } + + It 'Should preserve styles after value modification' { + $yaml = @" +ports: [80, 443] +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.ports = @(8080, 8443) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '\[.*\]' + } + } + + Context 'YAML Tag Preservation' { + It 'Should preserve tags when values are unchanged' { + $yaml = @" +number: !!int 42 +text: !!str hello +flag: !!bool true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int' + $result | Should -Match '!!str' + $result | Should -Match '!!bool' + } + + It 'Should preserve tags when value changes but type stays same' { + $yaml = @" +number: !!int 42 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = 99 + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int 99' + } + + It 'Should remove tags when type changes' { + $yaml = @" +number: !!int 42 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = "not a number" + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!int' + } + + It 'Should not emit tags when source YAML has no tags' { + $yaml = @" +number: 42 +text: hello +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!' + } + + It 'Should preserve timestamp tags' { + $yaml = @" +created: !!timestamp 2023-01-15T10:30:00Z +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!timestamp' + } + + It 'Should preserve float tags' { + $yaml = @" +value: !!float 3.14 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!float' + } + + It 'Should preserve tags in nested objects' { + $yaml = @" +server: + port: !!int 8080 + host: !!str localhost +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int' + $result | Should -Match '!!str' + } + + It 'Should preserve non-specific tag (!) to prevent type inference' { + $yaml = @" +number: ! 42 +flag: ! true +version: ! 1.2.3 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Values should be treated as strings due to "!" tag + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + $obj.version | Should -BeOfType [string] + $obj.version | Should -Be "1.2.3" + + # Round-trip should preserve the "!" tag + $result = ConvertTo-Yaml $obj + $result | Should -Match 'number: ! 42' + $result | Should -Match 'flag: ! true' + $result | Should -Match 'version: ! 1\.2\.3' + + # Parse again to verify it still prevents type inference + $obj2 = ConvertFrom-Yaml $result -As ([PSCustomObject]) + $obj2.number | Should -BeOfType [string] + $obj2.flag | Should -BeOfType [string] + $obj2.version | Should -BeOfType [string] + } + + It 'Should preserve custom tags for round-trip' { + $yaml = @" +id: !uuid 550e8400-e29b-41d4-a716-446655440000 +config: !include config.yaml +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Values should be strings + $obj.id | Should -BeOfType [string] + $obj.config | Should -BeOfType [string] + + # Round-trip should preserve custom tags + $result = ConvertTo-Yaml $obj + $result | Should -Match '!uuid' + $result | Should -Match '!include' + $result | Should -Match '550e8400-e29b-41d4-a716-446655440000' + $result | Should -Match 'config\.yaml' + } + } + + Context 'Quoted Scalar Type Rules' { + It 'Bare scalars should be type-inferred' { + $yaml = @" +number: 42 +flag: true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [int] + $obj.flag | Should -BeOfType [bool] + } + + It 'Double-quoted scalars should be strings (no tag)' { + $yaml = @" +number: "42" +flag: "true" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + } + + It 'Single-quoted scalars should be strings (no tag)' { + $yaml = @" +number: '42' +flag: 'true' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [string] + $obj.number | Should -Be "42" + $obj.flag | Should -BeOfType [string] + $obj.flag | Should -Be "true" + } + + It 'Tagged quoted scalars should use tag type (tag overrides quotes)' { + $yaml = @" +number: !!int "42" +flag: !!bool "false" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.number | Should -BeOfType [int] + $obj.number | Should -Be 42 + $obj.flag | Should -BeOfType [bool] + $obj.flag | Should -Be $false + } + + It 'Scientific notation should be parsed as decimal' { + $yaml = @" +value: 1.23e-4 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.value | Should -BeOfType [decimal] + } + + It 'Quoted scientific notation should remain string' { + $yaml = @" +value: "1.23e-4" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.value | Should -BeOfType [string] + $obj.value | Should -Be "1.23e-4" + } + + It 'Very large numbers should be BigInteger (not scientific notation)' { + $yaml = @" +bignum: 999999999999999999999999999999999 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.bignum | Should -BeOfType [System.Numerics.BigInteger] + $obj.bignum.ToString() | Should -Not -Match 'e|E' + } + + It 'Literal block scalars should be strings' { + $yaml = @" +text: | + 42 + true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.text | Should -BeOfType [string] + $obj.text | Should -Match "42" + } + + It 'Folded block scalars should be strings' { + $yaml = @" +text: > + 42 + true +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + $obj.text | Should -BeOfType [string] + } + } + + Context 'Tag and Quote Style Combined' { + It 'Should preserve both tag and double quotes' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int "42"' + } + + It 'Should preserve both tag and single quotes' { + $yaml = @" +number: !!int '42' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match "!!int '42'" + } + + It 'Should preserve tag and quotes after value change' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = 99 + $result = ConvertTo-Yaml $obj + + $result | Should -Match '!!int "99"' + } + + It 'Should remove tag but keep quotes when type changes' { + $yaml = @" +number: !!int "42" +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $obj.number = "not a number" + $result = ConvertTo-Yaml $obj + + $result | Should -Not -Match '!!int' + $result | Should -Match '"not a number"' + } + + It 'Should handle mixed tag and quote styles' { + $yaml = @" +a: !!int "42" +b: "hello" +c: !!str 99 +d: 'world' +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $result = ConvertTo-Yaml $obj + + $result | Should -Match 'a: !!int "42"' + $result | Should -Match 'b: "hello"' + $result | Should -Match 'c: !!str' + $result | Should -Match "d: 'world'" + } + } +} diff --git a/Tests/mapping-style.Tests.ps1 b/Tests/mapping-style.Tests.ps1 new file mode 100644 index 0000000..fb55329 --- /dev/null +++ b/Tests/mapping-style.Tests.ps1 @@ -0,0 +1,305 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Tests for mapping style preservation (Flow vs Block) + +BeforeAll { + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + . "$PSScriptRoot/MappingStyleClasses.ps1" +} + +Describe "Mapping Style Preservation" { + Context "Flow style mappings" { + It "Should preserve flow style for nested objects" { + $yaml = @" +name: "TechCorp" +ceo: {name: "swift-narwhal", age: 45, address: {street: "123 Main St", city: "Seattle", zip: "98101"}} +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify data was parsed correctly + $company.Name | Should -Be "TechCorp" + $company.Ceo.Name | Should -Be "swift-narwhal" + $company.Ceo.Age | Should -Be 45 + $company.Ceo.Address.Street | Should -Be "123 Main St" + $company.Ceo.Address.City | Should -Be "Seattle" + $company.Ceo.Address.Zip | Should -Be "98101" + + # Verify flow style was preserved + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip and verify flow style is maintained + $roundTripped = $company | ConvertTo-Yaml + + # Flow style should produce inline format + $roundTripped | Should -Match "ceo:\s*\{.*name:.*swift-narwhal.*\}" + $roundTripped | Should -Match "address:.*\{.*street:.*123 Main St.*\}" + } + + It "Should preserve block style for nested objects" { + $yaml = @" +name: "StartupInc" +ceo: + name: "eager-dolphin" + age: 35 + address: + street: "456 Tech Ave" + city: "San Francisco" + zip: "94105" +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify data was parsed correctly + $company.Ceo.Name | Should -Be "eager-dolphin" + $company.Ceo.Address.City | Should -Be "San Francisco" + + # Verify block style was preserved (or not set, which defaults to block) + $ceoStyle = $company.GetPropertyMappingStyle('Ceo') + $ceoStyle | Should -BeIn @($null, "Block") + + # Round-trip and verify block style is maintained + $roundTripped = $company | ConvertTo-Yaml + + # Block style should produce multi-line format + $roundTripped | Should -Match "ceo:\s*\n\s+name:" + $roundTripped | Should -Match "address:\s*\n\s+street:" + } + + It "Should handle mixed flow and block styles" { + $yaml = @" +name: "MixedCorp" +ceo: + name: "brave-penguin" + age: 50 + address: {street: "789 Business Blvd", city: "Austin", zip: "78701"} +employees: [] +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # CEO is block style, but address is flow style + $company.GetPropertyMappingStyle('Ceo') | Should -BeIn @($null, "Block") + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should be block + $roundTripped | Should -Match "ceo:\s*\n\s+name:" + # Address should be flow + $roundTripped | Should -Match "address:.*\{.*street:.*789 Business Blvd.*\}" + } + + It "Should allow programmatic setting of mapping style" { + $company = [Company]::new() + $company.Name = "StyleTest" + + $ceo = [Person]::new() + $ceo.Name = "clever-otter" + $ceo.Age = 40 + + $address = [Address]::new() + $address.Street = "321 Park Ave" + $address.City = "New York" + $address.Zip = "10001" + + $ceo.Address = $address + $company.Ceo = $ceo + + # Set flow style programmatically + $company.SetPropertyMappingStyle('Ceo', 'Flow') + $ceo.SetPropertyMappingStyle('Address', 'Flow') + + # Serialize + $yaml = $company | ConvertTo-Yaml + + # Should use flow style + $yaml | Should -Match "ceo:.*\{.*name:.*clever-otter.*\}" + $yaml | Should -Match "address:.*\{.*street:.*321 Park Ave.*\}" + } + + It "Should default to block style when no metadata" { + $company = [Company]::new() + $company.Name = "DefaultStyleCorp" + + $ceo = [Person]::new() + $ceo.Name = "calm-falcon" + $ceo.Age = 42 + + $company.Ceo = $ceo + + # Don't set any style - should default to block + $yaml = $company | ConvertTo-Yaml + + # Should use block style (multi-line) + $yaml | Should -Match "ceo:\s*\n\s+name:" + } + } + + Context "Flow style with arrays" { + It "Should handle flow style objects in arrays" { + $yaml = @" +name: "ArrayTest" +ceo: {name: "bright-tiger", age: 55, address: null} +employees: + - {name: "happy-panda", age: 30, address: null} + - {name: "wise-raven", age: 28, address: null} +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify CEO is flow style + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + + # Verify employees array was parsed + $company.Employees.Count | Should -Be 2 + $company.Employees[0].Name | Should -Be "happy-panda" + $company.Employees[1].Name | Should -Be "wise-raven" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should remain flow style + $roundTripped | Should -Match "ceo:.*\{.*name:.*bright-tiger.*\}" + } + } + + Context "Entire document in flow style" { + It "Should preserve flow style for entire deeply nested structure" { + $yaml = @" +{name: "FlowCorp", ceo: {name: "gentle-whale", age: 52, address: {street: "999 Flow St", city: "Portland", zip: "97201"}}, employees: [{name: "quick-fox", age: 33, address: {street: "111 Dev Ave", city: "Portland", zip: "97202"}}, {name: "noble-hawk", age: 29, address: {street: "222 Code Ln", city: "Portland", zip: "97203"}}]} +"@ + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify all data was parsed correctly + $company.Name | Should -Be "FlowCorp" + $company.Ceo.Name | Should -Be "gentle-whale" + $company.Ceo.Age | Should -Be 52 + $company.Ceo.Address.Street | Should -Be "999 Flow St" + $company.Ceo.Address.City | Should -Be "Portland" + $company.Ceo.Address.Zip | Should -Be "97201" + + $company.Employees.Count | Should -Be 2 + $company.Employees[0].Name | Should -Be "quick-fox" + $company.Employees[0].Age | Should -Be 33 + $company.Employees[0].Address.Street | Should -Be "111 Dev Ave" + $company.Employees[1].Name | Should -Be "noble-hawk" + $company.Employees[1].Age | Should -Be 29 + $company.Employees[1].Address.City | Should -Be "Portland" + + # Verify document-level flow style + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify flow style was captured for all nested objects + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip and verify flow style is maintained throughout + $roundTripped = $company | ConvertTo-Yaml + + # CEO and nested address should be flow style + $roundTripped | Should -Match "ceo:\s*\{.*name:.*gentle-whale.*\}" + $roundTripped | Should -Match "address:\s*\{.*street:.*999 Flow St.*\}" + + # Verify the structure is preserved + $roundTripped | Should -Match "name:.*FlowCorp" + $roundTripped | Should -Match "city:.*Portland" + } + + It "Should round-trip single-line flow style document" { + # Single-line YAML document (all in flow style) + $yaml = "{name: CompactCorp, ceo: {name: silent-spider, age: 47, address: {street: 777 Compact Rd, city: Austin, zip: '78704'}}, employees: []}" + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify parsing + $company.Name | Should -Be "CompactCorp" + $company.Ceo.Name | Should -Be "silent-spider" + $company.Ceo.Address.Street | Should -Be "777 Compact Rd" + $company.Ceo.Address.Zip | Should -Be "78704" + + # Verify root document has flow style (this is what makes it truly single-line) + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify flow style preserved for nested objects + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # The round-tripped output should exactly match the original (minus trailing whitespace) + $roundTripped.Trim() | Should -Be $yaml.Trim() -Because "True round-trip should produce identical output" + + # Verify the output is actually a single line (no newlines except at the end) + $lines = $roundTripped -split "`n" + $nonEmptyLines = $lines | Where-Object { $_ -ne "" } + $nonEmptyLines.Count | Should -Be 1 -Because "Flow style document should be a single line" + + # Verify the round-tripped output matches flow style format + $roundTripped | Should -Match "^\{.*name:.*CompactCorp.*ceo:.*\{.*name:.*silent-spider.*\}.*\}" + + # Parse the round-tripped YAML back to verify exact round-trip + $company2 = $roundTripped | ConvertFrom-Yaml -As ([Company]) + + # Verify data matches exactly + $company2.Name | Should -Be $company.Name + $company2.Ceo.Name | Should -Be $company.Ceo.Name + $company2.Ceo.Age | Should -Be $company.Ceo.Age + $company2.Ceo.Address.Street | Should -Be $company.Ceo.Address.Street + $company2.Ceo.Address.City | Should -Be $company.Ceo.Address.City + $company2.Ceo.Address.Zip | Should -Be $company.Ceo.Address.Zip + $company2.Employees.Count | Should -Be $company.Employees.Count + + # Verify flow style is maintained in round-trip (including root document) + $company2.GetDocumentMappingStyle() | Should -Be "Flow" + $company2.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + $company2.Ceo.GetPropertyMappingStyle('Address') | Should -Be "Flow" + + # Second round-trip should produce identical output (true round-trip stability) + $roundTripped2 = $company2 | ConvertTo-Yaml + $roundTripped2 | Should -Be $roundTripped + } + + It "Should preserve flow style with null values" { + $yaml = "{name: 'NullFlowCorp', ceo: {name: 'proud-eagle', age: 41, address: null}, employees: []}" + + $company = $yaml | ConvertFrom-Yaml -As ([Company]) + + # Verify parsing with null + $company.Name | Should -Be "NullFlowCorp" + $company.Ceo.Name | Should -Be "proud-eagle" + $company.Ceo.Address | Should -BeNullOrEmpty + + # Verify document-level flow style preserved + $company.GetDocumentMappingStyle() | Should -Be "Flow" + + # Verify nested flow style preserved + $company.GetPropertyMappingStyle('Ceo') | Should -Be "Flow" + + # Round-trip + $roundTripped = $company | ConvertTo-Yaml + + # CEO should remain flow style even with null address + $roundTripped | Should -Match "ceo:\s*\{.*name:.*proud-eagle.*\}" + } + } +} diff --git a/Tests/metadata.Tests.ps1 b/Tests/metadata.Tests.ps1 new file mode 100644 index 0000000..cf89db5 --- /dev/null +++ b/Tests/metadata.Tests.ps1 @@ -0,0 +1,128 @@ +Import-Module $PSScriptRoot/../powershell-yaml.psd1 -Force + +Describe 'YamlDocumentParser Metadata Preservation Tests' { + BeforeAll { + # The module will load the assemblies automatically + # We just need to ensure YamlDocumentParser type is available + # Get a dummy conversion to ensure assemblies are loaded + $null = ConvertFrom-Yaml "test: value" + } + + Context 'Basic Metadata Parsing' { + It 'Should parse simple YAML document' { + $yaml = @" +name: John +age: 30 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['name'] | Should -Be 'John' + $result.Item1['age'] | Should -Be 30 + $result.Item2 | Should -Not -BeNullOrEmpty + } + + It 'Should parse nested YAML structure' { + $yaml = @" +person: + name: John + age: 30 + address: + city: New York + zip: 10001 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['person']['name'] | Should -Be 'John' + $result.Item1['person']['address']['city'] | Should -Be 'New York' + $result.Item2 | Should -Not -BeNullOrEmpty + } + + It 'Should parse YAML with sequences' { + $yaml = @" +items: + - apple + - banana + - cherry +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['items'] | Should -HaveCount 3 + $result.Item1['items'][0] | Should -Be 'apple' + $result.Item1['items'][1] | Should -Be 'banana' + $result.Item1['items'][2] | Should -Be 'cherry' + } + + It 'Should handle null values' { + $yaml = @" +name: John +middle: null +age: 30 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -Not -BeNullOrEmpty + $result.Item1['name'] | Should -Be 'John' + $result.Item1['middle'] | Should -BeNullOrEmpty + $result.Item1['age'] | Should -Be 30 + } + + It 'Should handle boolean values' { + $yaml = @" +enabled: true +disabled: false +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1['enabled'] | Should -Be $true + $result.Item1['disabled'] | Should -Be $false + } + + It 'Should handle numeric values' { + $yaml = @" +integer: 42 +long: 9223372036854775807 +decimal: 3.14159 +"@ + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1['integer'] | Should -Be 42 + $result.Item1['integer'] | Should -BeOfType [int] + $result.Item1['long'] | Should -Be 9223372036854775807 + $result.Item1['decimal'] | Should -Be 3.14159 + } + + It 'Should return null for empty document' { + $yaml = "" + $result = [YamlDocumentParser]::ParseWithMetadata($yaml) + $result.Item1 | Should -BeNullOrEmpty + $result.Item2 | Should -BeNullOrEmpty + } + } + + Context 'Metadata Store Tests' { + It 'Should create and retrieve property comments' { + $store = New-Object YamlMetadataStore + $store.SetPropertyComment('name', 'User name') + $comment = $store.GetPropertyComment('name') + $comment | Should -Be 'User name' + } + + It 'Should return null for non-existent property comment' { + $store = New-Object YamlMetadataStore + $comment = $store.GetPropertyComment('nonexistent') + $comment | Should -BeNullOrEmpty + } + + It 'Should create and retrieve property tags' { + $store = New-Object YamlMetadataStore + $store.SetPropertyTag('value', 'tag:yaml.org,2002:str') + $tag = $store.GetPropertyTag('value') + $tag | Should -Be 'tag:yaml.org,2002:str' + } + + It 'Should create nested metadata stores' { + $store = New-Object YamlMetadataStore + $nested = $store.GetNestedMetadata('person') + $nested | Should -Not -BeNullOrEmpty + $nested.SetPropertyComment('name', 'Person name') + $nested.GetPropertyComment('name') | Should -Be 'Person name' + } + } +} diff --git a/Tests/powershell-yaml.Tests.ps1 b/Tests/powershell-yaml.Tests.ps1 index d87a284..0a18b4e 100644 --- a/Tests/powershell-yaml.Tests.ps1 +++ b/Tests/powershell-yaml.Tests.ps1 @@ -91,6 +91,150 @@ anArrayKey: [1, 2, 3] Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized } + It "Should serialize BlockStyle correctly (overrides flow style)" { + $obj = [ordered]@{ + aStringKey = "test" + anIntKey = 1 + anArrayKey = @(1, 2, 3) + } + # UseBlockStyle should force block style even when combined with UseFlowStyle + # Block style takes precedence + $expected = @" +aStringKey: test +anIntKey: 1 +anArrayKey: +- 1 +- 2 +- 3 + +"@ + # Test with UseBlockStyle alone (should produce block style - the default) + $serialized = ConvertTo-Yaml -Options UseBlockStyle $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + # Test UseBlockStyle taking precedence over UseFlowStyle + $serialized = ConvertTo-Yaml -Options ([SerializationOptions]::UseFlowStyle -bor [SerializationOptions]::UseBlockStyle) $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + $pso = [pscustomobject]$obj + $serialized = ConvertTo-Yaml -Options UseBlockStyle $pso + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + } + + It "Should serialize SequenceBlockStyle correctly (overrides sequence flow style)" { + $obj = [ordered]@{ + aStringKey = "test" + anIntKey = 1 + anArrayKey = @(1, 2, 3) + } + # UseSequenceBlockStyle should force block style for sequences even when combined with UseSequenceFlowStyle + $expected = @" +aStringKey: test +anIntKey: 1 +anArrayKey: +- 1 +- 2 +- 3 + +"@ + # Test with UseSequenceBlockStyle alone + $serialized = ConvertTo-Yaml -Options UseSequenceBlockStyle $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + # Test UseSequenceBlockStyle taking precedence over UseSequenceFlowStyle + $serialized = ConvertTo-Yaml -Options ([SerializationOptions]::UseSequenceFlowStyle -bor [SerializationOptions]::UseSequenceBlockStyle) $obj + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + + $pso = [pscustomobject]$obj + $serialized = ConvertTo-Yaml -Options UseSequenceBlockStyle $pso + Assert-Equivalent -Options $compareStrictly -Expected $expected -Actual $serialized + } + + It "Should convert block style YAML to flow style (legacy mode)" { + # Parse block style YAML + $blockYaml = @" +name: TestApp +version: 1.2.3 +settings: + debug: true + timeout: 30 + features: + - authentication + - logging + - monitoring + +"@ + $obj = ConvertFrom-Yaml $blockYaml + + # Convert to flow style + $flowYaml = ConvertTo-Yaml -Options UseFlowStyle $obj + + # Should be in flow style (single line with curly braces) + $flowYaml | Should -Match "^\{.*name:.*TestApp.*\}" + $flowYaml | Should -Match "settings:.*\{.*debug:.*true.*\}" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml $flowYaml + $roundTrip.name | Should -Be "TestApp" + $roundTrip.version | Should -Be "1.2.3" + $roundTrip.settings.debug | Should -Be $true + $roundTrip.settings.timeout | Should -Be 30 + $roundTrip.settings.features.Count | Should -Be 3 + } + + It "Should convert flow style YAML to block style (legacy mode)" { + # Parse flow style YAML + $flowYaml = "{name: TestApp, version: 1.2.3, settings: {debug: true, timeout: 30, features: [authentication, logging, monitoring]}}" + $obj = ConvertFrom-Yaml $flowYaml + + # Convert to block style (using UseBlockStyle to override any potential flow settings) + $blockYaml = ConvertTo-Yaml -Options UseBlockStyle $obj + + # Should be in block style (multi-line with proper indentation) + $blockYaml | Should -Match "name:.*TestApp" + $blockYaml | Should -Match "settings:\s*\n\s+\w+:" + $blockYaml | Should -Match "features:\s*\n\s*-" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml $blockYaml + $roundTrip.name | Should -Be "TestApp" + $roundTrip.version | Should -Be "1.2.3" + $roundTrip.settings.debug | Should -Be $true + $roundTrip.settings.timeout | Should -Be 30 + $roundTrip.settings.features.Count | Should -Be 3 + } + + It "Should convert block sequences to flow sequences (legacy mode)" { + # Parse block style with block sequences + $blockYaml = @" +items: +- apple +- banana +- cherry +metadata: + tags: + - fruit + - fresh + +"@ + $obj = ConvertFrom-Yaml $blockYaml + + # Convert sequences to flow style + $flowYaml = ConvertTo-Yaml -Options UseSequenceFlowStyle $obj + + # Sequences should be in flow style [item1, item2] + $flowYaml | Should -Match "items: \[apple, banana, cherry\]" + $flowYaml | Should -Match "tags: \[fruit, fresh\]" + + # Mappings should still be in block style + $flowYaml | Should -Match "metadata:\s*\n\s+tags:" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml $flowYaml + $roundTrip.items.Count | Should -Be 3 + $roundTrip.metadata.tags.Count | Should -Be 2 + } + It "Should serialize JsonCompatible correctly" { $obj = [ordered]@{ aStringKey = "test" diff --git a/Tests/roundtrip.Tests.ps1 b/Tests/roundtrip.Tests.ps1 new file mode 100644 index 0000000..965f472 --- /dev/null +++ b/Tests/roundtrip.Tests.ps1 @@ -0,0 +1,43 @@ +Import-Module $PSScriptRoot/../powershell-yaml.psd1 -Force + +Describe 'Round-Trip Comment Preservation Tests' { + It 'Should preserve block comments through round-trip' { + $yaml = @" +# User's full name +name: John +# User's age +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Modify a value + $obj.age = 31 + + # Convert back to YAML + $newYaml = ConvertTo-Yaml $obj + + # Comments should be preserved + $newYaml | Should -Match "# User's full name" + $newYaml | Should -Match "# User's age" + $newYaml | Should -Match "age: 31" + } + + It 'Should preserve programmatically-set comments through round-trip' { + $yaml = @" +name: John +age: 30 +"@ + $obj = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + + # Add comments programmatically + $obj | Set-YamlPropertyComment -PropertyName 'name' -Comment 'Full name of user' + $obj | Set-YamlPropertyComment -PropertyName 'age' -Comment 'Age in years' + + # Convert to YAML + $newYaml = ConvertTo-Yaml $obj + + # Comments should be in output + $newYaml | Should -Match "# Full name of user" + $newYaml | Should -Match "# Age in years" + } +} diff --git a/Tests/tagged-scalars.Tests.ps1 b/Tests/tagged-scalars.Tests.ps1 new file mode 100644 index 0000000..7c3f2a7 --- /dev/null +++ b/Tests/tagged-scalars.Tests.ps1 @@ -0,0 +1,102 @@ +#!/usr/bin/env pwsh +# Tagged Scalars Tests: Demonstrates round-tripping YAML with explicit type tags + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test class + . "$PSScriptRoot/TaggedScalars.ps1" +} + +Describe "Tagged Scalars: Round-trip with Explicit Type Tags" { + It "Should round-trip YAML with explicit tags on scalars" { + # Note: PowerShell class properties are case-insensitive, so we use + # string-value and int-value instead of Test/test + # Tags with quoted values should convert the value AND preserve the tag+quoting + $yaml = @" +string-value: !!str "I am a string" +int-value: !!int "22" +"@ + + # Deserialize + $obj = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Verify values were parsed correctly - !!int "22" should become int 22 + $obj.StringValue | Should -Be "I am a string" + $obj.IntValue | Should -Be 22 + $obj.StringValue | Should -BeOfType [string] + $obj.IntValue | Should -BeOfType [int] + + # Verify tags were preserved + $obj.GetPropertyTag('StringValue') | Should -Be "tag:yaml.org,2002:str" + $obj.GetPropertyTag('IntValue') | Should -Be "tag:yaml.org,2002:int" + + # Serialize back + $newYaml = $obj | ConvertTo-Yaml + + # Verify tags are in output + $newYaml | Should -Match "string-value: !!str" + $newYaml | Should -Match "int-value: !!int" + + # Verify values are preserved with quoting + # The original had !!int "22" (quoted), so it should serialize back the same way + $newYaml | Should -Match 'string-value: !!str "I am a string"' + $newYaml | Should -Match 'int-value: !!int "22"' + } + + It "Should handle untagged values and infer types" { + # YAML without explicit tags + $yaml = @" +string-value: normal string +int-value: 42 +"@ + + $obj = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Values should be converted to correct types + $obj.StringValue | Should -Be "normal string" + $obj.IntValue | Should -Be 42 + $obj.IntValue | Should -BeOfType [int] + + # No tags initially (parsed without tags) + $obj.GetPropertyTag('IntValue') | Should -BeNullOrEmpty + + # Set tags explicitly + $obj.SetPropertyTag('StringValue', 'tag:yaml.org,2002:str') + $obj.SetPropertyTag('IntValue', 'tag:yaml.org,2002:int') + + # Now serialize with tags + $newYaml = $obj | ConvertTo-Yaml + $newYaml | Should -Match "string-value: !!str" + $newYaml | Should -Match "int-value: !!int 42" + } + + It "Should preserve tags when explicitly set" { + $obj = [TaggedScalarsTest]::new() + $obj.StringValue = "Hello World" + $obj.IntValue = 100 + + # Set explicit tags + $obj.SetPropertyTag('StringValue', 'tag:yaml.org,2002:str') + $obj.SetPropertyTag('IntValue', 'tag:yaml.org,2002:int') + + # Serialize + $yaml = $obj | ConvertTo-Yaml + + # Should have both properties with correct tags + $yaml | Should -Match "string-value: !!str" + $yaml | Should -Match "int-value: !!int" + + # Deserialize back + $obj2 = $yaml | ConvertFrom-Yaml -As ([TaggedScalarsTest]) + + # Values should match + $obj2.StringValue | Should -Be "Hello World" + $obj2.IntValue | Should -Be 100 + + # Tags should be preserved + $obj2.GetPropertyTag('StringValue') | Should -Be "tag:yaml.org,2002:str" + $obj2.GetPropertyTag('IntValue') | Should -Be "tag:yaml.org,2002:int" + } +} diff --git a/Tests/typed-yaml-metadata.Tests.ps1 b/Tests/typed-yaml-metadata.Tests.ps1 new file mode 100644 index 0000000..3bda420 --- /dev/null +++ b/Tests/typed-yaml-metadata.Tests.ps1 @@ -0,0 +1,350 @@ +#!/usr/bin/env pwsh +# Typed YAML Metadata Preservation Tests +# Tests that typed YAML mode preserves metadata just like PSCustomObject mode (comments, flow style, quoting style, etc.) + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/TypedYamlTestClasses.ps1" +} + +Describe "Typed YAML: Comment Preservation" { + It "Should preserve comments on simple properties" { + $yaml = @" +# Server name comment +name: test-server +# Port comment +port: 9000 +# Enabled comment +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify comments are captured (use C# property names, not YAML keys) + $config.GetPropertyComment('Name') | Should -Be 'Server name comment' + $config.GetPropertyComment('Port') | Should -Be 'Port comment' + $config.GetPropertyComment('Enabled') | Should -Be 'Enabled comment' + } + + It "Should preserve inline comments" { + $yaml = @" +name: test-server # This is the server name +port: 9000 # This is the port +enabled: true # Server is enabled +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify inline comments are captured (use C# property names) + $config.GetPropertyComment('Name') | Should -Be 'This is the server name' + $config.GetPropertyComment('Port') | Should -Be 'This is the port' + $config.GetPropertyComment('Enabled') | Should -Be 'Server is enabled' + } + + It "Should preserve comments in nested objects" { + $yaml = @" +# App name +app-name: MyApp +database: + # Database host + host: db.example.com + # Database port + port: 5432 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.GetPropertyComment('AppName') | Should -Be 'App name' + $config.Database.GetPropertyComment('Host') | Should -Be 'Database host' + $config.Database.GetPropertyComment('Port') | Should -Be 'Database port' + } + + It "Should preserve comments through round-trip" { + $yaml = @" +# Server name +name: test-server +# Port number +port: 9000 +enabled: true +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Modify a value + $config1.Port = 8080 + + # Serialize back to YAML + $yaml2 = ConvertTo-Yaml $config1 + + # Verify comments are in output + $yaml2 | Should -Match '# Server name' + $yaml2 | Should -Match '# Port number' + $yaml2 | Should -Match 'port: 8080' + + # Deserialize again + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + # Verify comments survived round-trip + $config2.GetPropertyComment('Name') | Should -Be 'Server name' + $config2.GetPropertyComment('Port') | Should -Be 'Port number' + $config2.Port | Should -Be 8080 + } +} + +Describe "Typed YAML: Scalar Style Preservation" { + It "Should preserve double-quoted strings" { + $yaml = @" +name: "test-server" +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify scalar style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + } + + It "Should preserve single-quoted strings" { + $yaml = @" +name: 'test-server' +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify scalar style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'SingleQuoted' + } + + It "Should preserve literal block scalars" { + $yaml = @" +name: |- + test-server + multiline +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify literal style is captured + $config.GetPropertyScalarStyle('Name') | Should -Match 'Literal|Folded' + } + + It "Should preserve scalar styles through round-trip" { + $yaml = @" +name: "quoted-server" +port: 9000 +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify style was captured + $config1.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + # Round-trip + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + # Style should be preserved or at least the value should be quoted + $yaml2 | Should -Match '"quoted-server"|''quoted-server''' + } +} + +Describe "Typed YAML: Mapping Style Preservation" { + It "Should preserve block mapping style" { + $yaml = @" +app-name: MyApp +database: + host: localhost + port: 5432 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify block style is captured (default) + $config.GetPropertyMappingStyle('Database') | Should -Match 'Block|$null' + } + + It "Should preserve flow mapping style" { + $yaml = @" +app-name: MyApp +database: {host: localhost, port: 5432} +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify flow style is captured + $config.GetPropertyMappingStyle('Database') | Should -Be 'Flow' + } +} + +Describe "Typed YAML: Sequence Style Preservation" { + It "Should preserve block sequence style" { + $yaml = @" +app-name: ArrayTest +tags: + - tag1 + - tag2 + - tag3 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify block sequence style is captured + $config.GetPropertySequenceStyle('Tags') | Should -Match 'Block|$null' + } + + It "Should preserve flow sequence style" { + $yaml = @" +app-name: ArrayTest +tags: [tag1, tag2, tag3] +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify flow sequence style is captured + $config.GetPropertySequenceStyle('Tags') | Should -Be 'Flow' + } + + It "Should preserve sequence styles through round-trip" { + $yaml = @" +app-name: ArrayTest +tags: [tag1, tag2, tag3] +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Round-trip + $yaml2 = ConvertTo-Yaml $config1 + + # Verify flow style is preserved + $yaml2 | Should -Match '\[tag1, tag2, tag3\]' + + # Deserialize again + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + $config2.GetPropertySequenceStyle('Tags') | Should -Be 'Flow' + } +} + +Describe "Typed YAML: Complete Round-Trip with All Metadata" { + It "Should preserve all metadata types through multiple round-trips" { + $yaml = @" +# Application name +app-name: "MyApp" +# Database configuration +database: + # Database host + host: localhost + port: 5432 + database: mydb + use-ssl: true +# Application tags +tags: [production, web, critical] +# Maximum connections +max-connections: 200 +"@ + # First round-trip + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + $yaml2 = ConvertTo-Yaml $config1 + + # Verify comments are in output + $yaml2 | Should -Match '# Application name' + $yaml2 | Should -Match '# Database configuration' + $yaml2 | Should -Match '# Application tags' + + # Verify flow style preserved + $yaml2 | Should -Match '\[production, web, critical\]' + + # Second round-trip + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + $yaml3 = ConvertTo-Yaml $config2 + + # Verify metadata survived second round-trip + $yaml3 | Should -Match '# Application name' + $yaml3 | Should -Match '# Database configuration' + $yaml3 | Should -Match '\[production, web, critical\]' + + # Verify values are still correct + $config2.AppName | Should -Be 'MyApp' + $config2.Database.Host | Should -Be 'localhost' + $config2.Tags.Count | Should -Be 3 + $config2.MaxConnections | Should -Be 200 + } + + It "Should allow programmatic metadata modification" { + $yaml = @" +name: test-server +port: 9000 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Add a comment programmatically + $config.SetPropertyComment('Port', 'Custom port comment') + $config.SetPropertyScalarStyle('Name', 'DoubleQuoted') + + # Serialize + $yaml2 = ConvertTo-Yaml $config + + # Verify programmatic metadata is in output + $yaml2 | Should -Match '# Custom port comment' + $yaml2 | Should -Match '"test-server"' + + # Round-trip and verify + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $config2.GetPropertyComment('Port') | Should -Be 'Custom port comment' + } +} + +Describe "Typed YAML: Nested Object Metadata" { + It "Should preserve metadata in deeply nested structures" { + $yaml = @" +# Root config +app-name: NestedTest +# Database section +database: + # DB host + host: db.example.com + # DB port + port: 5432 + # DB name + database: mydb + use-ssl: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Verify nested comments + $config.GetPropertyComment('AppName') | Should -Be 'Root config' + $config.GetPropertyComment('Database') | Should -Be 'Database section' + $config.Database.GetPropertyComment('Host') | Should -Be 'DB host' + $config.Database.GetPropertyComment('Port') | Should -Be 'DB port' + $config.Database.GetPropertyComment('Database') | Should -Be 'DB name' + + # Round-trip + $yaml2 = ConvertTo-Yaml $config + + # Verify nested comments in output + $yaml2 | Should -Match '# Root config' + $yaml2 | Should -Match '# Database section' + $yaml2 | Should -Match '# DB host' + $yaml2 | Should -Match '# DB port' + } +} + +Describe "Typed YAML: Metadata Parity with PSCustomObject mode" { + It "Should support same metadata operations as PSCustomObject mode PSCustomObject mode" { + $yaml = @" +# Comment test +name: test +port: 9000 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Test all metadata methods (matching PSCustomObject mode API) + $config.GetPropertyComment('Name') | Should -Not -BeNullOrEmpty + $config.SetPropertyComment('Port', 'New comment') + $config.GetPropertyComment('Port') | Should -Be 'New comment' + + $config.SetPropertyScalarStyle('Name', 'DoubleQuoted') + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + $config.SetPropertyMappingStyle('Name', 'Flow') + $config.GetPropertyMappingStyle('Name') | Should -Be 'Flow' + + $config.SetPropertySequenceStyle('Name', 'Flow') + $config.GetPropertySequenceStyle('Name') | Should -Be 'Flow' + + $config.SetPropertyTag('Name', '!custom') + $config.GetPropertyTag('Name') | Should -Be '!custom' + } +} diff --git a/Tests/typed-yaml.Tests.ps1 b/Tests/typed-yaml.Tests.ps1 new file mode 100644 index 0000000..4a7ba51 --- /dev/null +++ b/Tests/typed-yaml.Tests.ps1 @@ -0,0 +1,724 @@ +#!/usr/bin/env pwsh +# Typed YAML Tests: Typed Class Mode with ALC Isolation + +BeforeAll { + # Import the main module (now includes typed cmdlets) + Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + + # Load test classes + . "$PSScriptRoot/TypedYamlTestClasses.ps1" +} + +Describe "Typed YAML: Module Loading and Type Availability" { + It "Should load YamlBase type into Default ALC" { + [PowerShellYaml.YamlBase] | Should -Not -BeNullOrEmpty + } + + It "Should NOT export typed cmdlets (internal use only)" { + Get-Command ConvertFrom-YamlTyped -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Get-Command ConvertTo-YamlTyped -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + } + + It "Should use unified API (ConvertFrom-Yaml with -As parameter)" { + Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + Get-Command ConvertTo-Yaml -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } + + It "Should allow PowerShell classes to inherit from YamlBase" { + $obj = [SimpleConfig]::new() + $obj -is [PowerShellYaml.YamlBase] | Should -BeTrue + } +} + +Describe "Typed YAML: Simple Deserialization" { + It "Should deserialize simple YAML to typed object" { + $yaml = @" +name: test-server +port: 9000 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config | Should -Not -BeNullOrEmpty + $config.Name | Should -Be 'test-server' + $config.Port | Should -Be 9000 + $config.Enabled | Should -BeTrue + } + + It "Should preserve type information" { + $yaml = "name: test`nport: 8080`nenabled: false" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config -is [SimpleConfig] | Should -BeTrue + $config -is [PowerShellYaml.YamlBase] | Should -BeTrue + } + + It "Should use default values for missing properties" { + $yaml = "name: minimal" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should handle boolean values correctly" { + $yaml = @" +name: bool-test +port: 1000 +enabled: false +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $config.Enabled | Should -BeFalse + } +} + +Describe "Typed YAML: Simple Serialization" { + It "Should serialize typed object to YAML" { + $config = [SimpleConfig]::new() + $config.Name = 'serialize-test' + $config.Port = 3000 + $config.Enabled = $true + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Not -BeNullOrEmpty + $yaml | Should -Match 'name: serialize-test' + $yaml | Should -Match 'port: 3000' + $yaml | Should -Match 'enabled: true' + } +} + +Describe "Typed YAML: Round-trip Testing" { + It "Should preserve values through round-trip" { + $yaml = @" +name: roundtrip-test +port: 7777 +enabled: false +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + $config2.Name | Should -Be 'roundtrip-test' + $config2.Port | Should -Be 7777 + $config2.Enabled | Should -BeFalse + } + + It "Should handle modifications in round-trip" { + $yaml = "name: original`nport: 1000" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port = 2000 + $config.Enabled = $false + + $yaml2 = ConvertTo-Yaml $config + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + + $config2.Port | Should -Be 2000 + $config2.Enabled | Should -BeFalse + } +} + +Describe "Typed YAML: Nested Object Support" { + It "Should deserialize nested objects" { + $yaml = @" +app-name: MyApp +database: + host: db.example.com + port: 5432 + database: mydb + use-ssl: true +tags: + - production + - web +max-connections: 200 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.AppName | Should -Be 'MyApp' + $config.Database | Should -Not -BeNullOrEmpty + $config.Database.Host | Should -Be 'db.example.com' + $config.Database.Port | Should -Be 5432 + $config.Database.Database | Should -Be 'mydb' + $config.Database.UseSsl | Should -BeTrue + $config.Tags.Count | Should -Be 2 + $config.MaxConnections | Should -Be 200 + } + + It "Should verify nested object types" { + $yaml = @" +app-name: TypeTest +database: + host: localhost + database: test +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.Database -is [DatabaseConfig] | Should -BeTrue + $config.Database -is [PowerShellYaml.YamlBase] | Should -BeTrue + } + + It "Should serialize nested objects" { + $config = [ComplexConfig]::new() + $config.AppName = 'NestedTest' + $config.Database = [DatabaseConfig]::new() + $config.Database.Host = 'nested.example.com' + $config.Database.Port = 3306 + $config.Database.Database = 'nested_db' + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Match 'app-name: NestedTest' + $yaml | Should -Match 'database:' + $yaml | Should -Match 'host: nested.example.com' + $yaml | Should -Match 'port: 3306' + } + + It "Should round-trip nested objects" { + $yaml1 = @" +app-name: NestedRoundTrip +database: + host: rt.example.com + port: 5433 + database: rt_db + use-ssl: false +"@ + $config1 = ConvertFrom-Yaml -Yaml $yaml1 -As ([ComplexConfig]) + $yaml2 = ConvertTo-Yaml $config1 + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([ComplexConfig]) + + $config2.Database.Host | Should -Be 'rt.example.com' + $config2.Database.Port | Should -Be 5433 + $config2.Database.UseSsl | Should -BeFalse + } +} + +Describe "Typed YAML: Array/Collection Support" { + It "Should deserialize string arrays" { + $yaml = @" +app-name: ArrayTest +tags: + - tag1 + - tag2 + - tag3 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + $config.Tags | Should -Not -BeNullOrEmpty + $config.Tags.Count | Should -Be 3 + $config.Tags[0] | Should -Be 'tag1' + $config.Tags[2] | Should -Be 'tag3' + } + + It "Should serialize string arrays" { + $config = [ComplexConfig]::new() + $config.AppName = 'ArraySerialize' + $config.Tags = @('alpha', 'beta', 'gamma') + + $yaml = ConvertTo-Yaml $config + + $yaml | Should -Match 'tags:' + $yaml | Should -Match '- alpha' + $yaml | Should -Match '- beta' + $yaml | Should -Match '- gamma' + } + + It "Should handle empty arrays" { + $config = [ComplexConfig]::new() + $config.AppName = 'EmptyArray' + $config.Tags = @() + + $yaml = ConvertTo-Yaml $config + $config2 = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Empty arrays may be deserialized as null or empty depending on YAML representation + # Check that it's either null or an empty array + if ($config2.Tags -eq $null) { + # This is acceptable - empty array was serialized as empty/null + $true | Should -BeTrue + } else { + $config2.Tags.Count | Should -Be 0 + } + } +} + +Describe "Typed YAML: Error Handling" { + It "Should throw error for non-YamlBase type" { + $yaml = "name: test" + { ConvertFrom-Yaml -Yaml $yaml -As ([string]) } | Should -Throw + } + + It "Should throw error for null type" { + $yaml = "name: test" + { ConvertFrom-Yaml -Yaml $yaml -As $null } | Should -Throw + } + + It "Should handle invalid YAML gracefully" { + $yaml = "invalid: yaml: structure: bad" + { ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) } | Should -Throw + } +} + +Describe "Typed YAML: Type Conversion" { + It "Should convert string to int" { + $yaml = "name: test`nport: '9999'`nenabled: true" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 9999 + $config.Port | Should -BeOfType [int] + } + + It "Should convert string to bool" { + $yaml = "name: test`nport: 1000`nenabled: 'true'" + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Enabled | Should -BeTrue + $config.Enabled | Should -BeOfType [bool] + } +} + +Describe "Typed YAML: Type Validation" { + # NOTE: Current implementation does NOT validate YAML type tags against class properties. + # Type tags are captured as metadata but deserialization uses property types, not tags. + # This is intentional - we trust the class schema over YAML tags. + + It "Should convert YAML !!int tag to string when property expects string" { + $yaml = @" +name: !!int "42" +port: 8080 +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "42" + $config.Name | Should -BeOfType [string] + # Type tag should be captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:int' + } + + It "Should convert YAML !!str tag to int when property expects int" { + $yaml = @" +name: test +port: !!str "8080" +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Port | Should -Be 8080 + $config.Port | Should -BeOfType [int] + # Type tag should be captured as metadata + $config.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:str' + } + + It "Should convert YAML !!bool tag to string when property expects string" { + $yaml = @" +name: !!bool "false" +port: 8080 +enabled: true +"@ + # Current behavior: Type tag is stored as metadata, but value is converted to property type + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "false" + $config.Name | Should -BeOfType [string] + # Type tag should be captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:bool' + } + + It "Should succeed when YAML tag matches property type" { + $yaml = @" +name: !!str "test-server" +port: !!int "8080" +enabled: !!bool "true" +"@ + # This should succeed because tags match property types + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "test-server" + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should succeed when no explicit tags are provided" { + $yaml = @" +name: test-server +port: 8080 +enabled: true +"@ + # This should succeed - YamlDotNet infers types automatically + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + $config.Name | Should -Be "test-server" + $config.Port | Should -Be 8080 + $config.Enabled | Should -BeTrue + } + + It "Should preserve YAML tags through round-trip" { + $yaml = @" +name: !!str "test-server" +port: !!int "8080" +enabled: true +"@ + # Deserialize + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify tags are captured as metadata + $config.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:str' + $config.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:int' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Tags should be preserved in output (with quotes preserved from original) + $yaml2 | Should -Match 'name: !!str "test-server"' + $yaml2 | Should -Match 'port: !!int "8080"' + + # Round-trip again to verify + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $config2.GetPropertyTag('Name') | Should -Be 'tag:yaml.org,2002:str' + $config2.GetPropertyTag('Port') | Should -Be 'tag:yaml.org,2002:int' + } + + It "Should preserve plain (unquoted) scalar style" { + $yaml = @" +name: test-server +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify plain style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'Plain' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should remain unquoted + $yaml2 | Should -Match 'name: test-server' + $yaml2 | Should -Not -Match 'name: "test-server"' + $yaml2 | Should -Not -Match "name: 'test-server'" + } + + It "Should preserve double-quoted scalar style" { + $yaml = @" +name: "test-server" +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify double-quoted style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should preserve double quotes + $yaml2 | Should -Match 'name: "test-server"' + } + + It "Should preserve single-quoted scalar style" { + $yaml = @" +name: 'test-server' +port: 8080 +enabled: true +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify single-quoted style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'SingleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Should preserve single quotes + $yaml2 | Should -Match "name: 'test-server'" + } + + It "Should preserve mixed quoting styles across properties" { + $yaml = @" +name: plain-value +port: "8080" +enabled: 'true' +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + + # Verify each style is captured + $config.GetPropertyScalarStyle('Name') | Should -Be 'Plain' + $config.GetPropertyScalarStyle('Port') | Should -Be 'DoubleQuoted' + $config.GetPropertyScalarStyle('Enabled') | Should -Be 'SingleQuoted' + + # Serialize back + $yaml2 = ConvertTo-Yaml $config + + # Each style should be preserved + $yaml2 | Should -Match 'name: plain-value' + $yaml2 | Should -Match 'port: "8080"' + $yaml2 | Should -Match "enabled: 'true'" + } + + It "Should round-trip quoting styles multiple times" { + $yaml = @" +name: "double-quoted" +port: 8080 +"@ + # First round-trip + $config1 = ConvertFrom-Yaml -Yaml $yaml -As ([SimpleConfig]) + $yaml2 = ConvertTo-Yaml $config1 + + # Second round-trip + $config2 = ConvertFrom-Yaml -Yaml $yaml2 -As ([SimpleConfig]) + $yaml3 = ConvertTo-Yaml $config2 + + # Third round-trip + $config3 = ConvertFrom-Yaml -Yaml $yaml3 -As ([SimpleConfig]) + + # Style should still be double-quoted + $config3.GetPropertyScalarStyle('Name') | Should -Be 'DoubleQuoted' + $yaml4 = ConvertTo-Yaml $config3 + $yaml4 | Should -Match 'name: "double-quoted"' + } +} + +Describe "Typed YAML: ALC Isolation Verification" { + It "Should load YamlDotNet in isolated ALC (not Default)" -Skip:(-not $IsCoreClr) { + # Get all loaded assemblies + $assemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() + $yamlDotNetAsms = @($assemblies | Where-Object { $_.GetName().Name -eq 'YamlDotNet' }) + + # YamlDotNet should be loaded (at least once) + $yamlDotNetAsms.Count | Should -BeGreaterThan 0 + + # Check that YamlDotNet is NOT in the Default ALC + # With LoadFile approach, it's in an anonymous ALC (named after the file path) + $inDefaultAlc = $false + foreach ($asm in $yamlDotNetAsms) { + $alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($asm) + if ($alc.Name -eq 'Default') { + $inDefaultAlc = $true + break + } + } + $inDefaultAlc | Should -BeFalse -Because "YamlDotNet should NOT be in Default ALC (should be isolated)" + } + + It "Should load PowerShellYaml in Default ALC" { + $assemblies = [System.AppDomain]::CurrentDomain.GetAssemblies() + $mainAsms = @($assemblies | Where-Object { $_.GetName().Name -eq 'PowerShellYaml' }) + + $mainAsms.Count | Should -BeGreaterThan 0 + + if ($IsCoreClr) { + # Check that at least one PowerShellYaml assembly is in Default ALC + $defaultAlcFound = $false + foreach ($asm in $mainAsms) { + $alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($asm) + if ($alc.Name -eq 'Default') { + $defaultAlcFound = $true + break + } + } + $defaultAlcFound | Should -BeTrue -Because "PowerShellYaml should be loaded in Default ALC" + } + } +} + +Describe "Typed YAML: Style Conversion" { + Context "Block to Flow conversion" { + It "Should convert block style YAML to flow style" { + # Parse block style YAML + $blockYaml = @" +app-name: TestApp +database: + host: db.example.com + port: 5432 + database: test_db + use-ssl: true +tags: + - development + - testing +max-connections: 100 +"@ + $config = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + + # Convert to flow style + $flowYaml = ConvertTo-Yaml -Data $config -Options UseFlowStyle + + # Should be in flow style (single line with curly braces) + $flowYaml | Should -Match "^\{.*app-name:.*TestApp.*\}" + $flowYaml | Should -Match "database:.*\{.*host:.*db\.example\.com.*\}" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + $roundTrip.AppName | Should -Be "TestApp" + $roundTrip.Database.Host | Should -Be "db.example.com" + $roundTrip.Database.Port | Should -Be 5432 + $roundTrip.Database.Database | Should -Be "test_db" + $roundTrip.Database.UseSsl | Should -Be $true + $roundTrip.Tags.Count | Should -Be 2 + $roundTrip.MaxConnections | Should -Be 100 + } + } + + Context "Flow to Block conversion" { + It "Should convert flow style YAML to block style" { + # Parse flow style YAML + $flowYaml = "{app-name: TestApp, database: {host: db.example.com, port: 5432, database: test_db, use-ssl: true}, tags: [development, testing], max-connections: 100}" + $config = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + + # Convert to block style (need both flags for mappings and sequences) + $blockYaml = $config | ConvertTo-Yaml -Options (512 + 1024) # UseBlockStyle + UseSequenceBlockStyle + + # Should be in block style (multi-line with proper indentation) + $blockYaml | Should -Match "app-name:.*TestApp" + $blockYaml | Should -Match "database:\s*\n\s+host:" + $blockYaml | Should -Match "tags:\s*\n\s*-" + + # Parse back and verify data integrity + $roundTrip = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + $roundTrip.AppName | Should -Be "TestApp" + $roundTrip.Database.Host | Should -Be "db.example.com" + $roundTrip.Database.Port | Should -Be 5432 + $roundTrip.Database.Database | Should -Be "test_db" + $roundTrip.Database.UseSsl | Should -Be $true + $roundTrip.Tags.Count | Should -Be 2 + $roundTrip.MaxConnections | Should -Be 100 + } + } + + Context "Sequence style conversion" { + It "Should convert block sequences to flow sequences" { + # Create config with arrays + $config = [ComplexConfig]::new() + $config.AppName = "SequenceTest" + $config.Tags = @("tag1", "tag2", "tag3", "tag4") + $config.MaxConnections = 50 + + $db = [DatabaseConfig]::new() + $db.Host = "localhost" + $db.Port = 5432 + $db.Database = "testdb" + $config.Database = $db + + # Default serialization should use block style + $blockYaml = ConvertTo-Yaml -Data $config + $blockYaml | Should -Match "tags:\s*\n\s*-" + + # Convert to flow style for sequences only + $flowSeqYaml = ConvertTo-Yaml -Data $config -Options UseSequenceFlowStyle + $flowSeqYaml | Should -Match "tags: \[tag1, tag2, tag3, tag4\]" + + # Mappings should still be block style + $flowSeqYaml | Should -Match "database:\s*\n\s+host:" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml -Yaml $flowSeqYaml -As ([ComplexConfig]) + $roundTrip.Tags.Count | Should -Be 4 + $roundTrip.Tags[0] | Should -Be "tag1" + } + + It "Should convert flow sequences to block sequences" { + # Parse YAML with flow sequences + $flowYaml = @" +app-name: SequenceTest +database: + host: localhost + port: 5432 + database: testdb + use-ssl: false +tags: [tag1, tag2, tag3, tag4] +max-connections: 50 +"@ + $config = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + + # Convert to block style for sequences + $blockSeqYaml = ConvertTo-Yaml -Data $config -Options UseSequenceBlockStyle + + # Sequences should be in block style + $blockSeqYaml | Should -Match "tags:\s*\n\s*- tag1" + + # Parse back and verify + $roundTrip = ConvertFrom-Yaml -Yaml $blockSeqYaml -As ([ComplexConfig]) + $roundTrip.Tags.Count | Should -Be 4 + $roundTrip.Tags[0] | Should -Be "tag1" + } + } + + Context "Nested object style conversion" { + It "Should handle nested objects with flow style override" { + # Create nested structure + $config = [ComplexConfig]::new() + $config.AppName = "NestedTest" + $config.MaxConnections = 75 + $config.Tags = @("production") + + $db = [DatabaseConfig]::new() + $db.Host = "prod.db.example.com" + $db.Port = 3306 + $db.Database = "prod_db" + $db.UseSsl = $true + $config.Database = $db + + # Serialize with flow style + $flowYaml = ConvertTo-Yaml -Data $config -Options UseFlowStyle + + # Both root and nested objects should be flow style + $flowYaml | Should -Match "^\{" + $flowYaml | Should -Match "database:.*\{.*host:.*prod\.db\.example\.com.*\}" + + # Parse back + $roundTrip = ConvertFrom-Yaml -Yaml $flowYaml -As ([ComplexConfig]) + $roundTrip.Database.Host | Should -Be "prod.db.example.com" + $roundTrip.Database.UseSsl | Should -Be $true + + # Now convert the same object to block style + $blockYaml = ConvertTo-Yaml -Data $roundTrip -Options UseBlockStyle + + # Should be in block style + $blockYaml | Should -Match "database:\s*\n\s+host:" + $blockYaml | Should -Not -Match "^\{" + + # Parse back again + $roundTrip2 = ConvertFrom-Yaml -Yaml $blockYaml -As ([ComplexConfig]) + $roundTrip2.Database.Host | Should -Be "prod.db.example.com" + $roundTrip2.Database.UseSsl | Should -Be $true + } + } +} + +Describe "Typed YAML: Real-world Scenarios" { + It "Should handle complete application configuration" { + $yaml = @" +app-name: ProductionApp +database: + host: prod-db-01.example.com + port: 5432 + database: production + use-ssl: true +tags: + - production + - critical + - monitored +max-connections: 500 +"@ + $config = ConvertFrom-Yaml -Yaml $yaml -As ([ComplexConfig]) + + # Modify for staging + $config.AppName = 'StagingApp' + $config.Database.Host = 'staging-db-01.example.com' + $config.Database.Database = 'staging' + $config.Tags = @('staging', 'test') + $config.MaxConnections = 100 + + # Serialize and verify + $stagingYaml = ConvertTo-Yaml $config + $stagingConfig = ConvertFrom-Yaml -Yaml $stagingYaml -As ([ComplexConfig]) + + $stagingConfig.AppName | Should -Be 'StagingApp' + $stagingConfig.Database.Host | Should -Be 'staging-db-01.example.com' + $stagingConfig.MaxConnections | Should -Be 100 + $stagingConfig.Tags.Count | Should -Be 2 + } +} diff --git a/build.ps1 b/build.ps1 index 6e14449..1b220f1 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,4 +1,5 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -13,15 +14,187 @@ # under the License. # -$here = Split-Path -Parent $MyInvocation.MyCommand.Path +param( + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', -dotnet build --configuration Release $here/src/ + [switch]$Clean, -$destinations = @("netstandard2.0") + [switch]$SkipTests, -foreach ($item in $destinations) { - $src = Join-Path $here "src" "bin" "Release" $item "PowerShellYamlSerializer.dll" - $dst = Join-Path $here "lib" $item "PowerShellYamlSerializer.dll" + [switch]$IncludeSymbols, - Copy-Item -Force $src $dst + [switch]$Verbose +) + +$ErrorActionPreference = 'Stop' + +if ($Verbose) { + $VerbosePreference = 'Continue' +} + +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host " PowerShell-YAML Build Script " -ForegroundColor Cyan +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Configuration: $Configuration" -ForegroundColor Yellow +Write-Host "" + +$root = $PSScriptRoot +$srcDir = Join-Path $root "src" +$libDir = Join-Path $root "lib" + +# ===== +# Clean +# ===== + +if ($Clean) { + Write-Host "=== Cleaning Build Artifacts ===" -ForegroundColor Cyan + + # Clean .NET build outputs + $cleanPaths = @( + "$srcDir/obj", + "$srcDir/bin", + "$srcDir/*/obj", + "$srcDir/*/bin", + "$srcDir/*/*/obj", + "$srcDir/*/*/bin" + ) + + foreach ($pattern in $cleanPaths) { + Get-Item $pattern -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force + } + + # Clean output directory + if (Test-Path "$libDir/netstandard2.0") { + Remove-Item "$libDir/netstandard2.0" -Recurse -Force + } + + Write-Host "Clean complete" -ForegroundColor Green + Write-Host "" +} + +# ============================== +# Main Module (PowerShell 5.1+) +# ============================== + +Write-Host "=== Building Main Module (netstandard2.0) ===" -ForegroundColor Cyan +Write-Host "" + +# Build PowerShellYaml.Module.dll (netstandard2.0) - contains all serialization code +Write-Host "Building PowerShellYaml.Module.dll..." -ForegroundColor Yellow +Push-Location $srcDir +try { + # Use publish to get all dependencies including YamlDotNet.dll + dotnet publish PowerShellYaml.Module/PowerShellYaml.Module.csproj -c $Configuration -f netstandard2.0 + if ($LASTEXITCODE -ne 0) { + throw "PowerShellYaml.Module build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +# Copy module assemblies to lib/netstandard2.0 +Write-Host "Copying module assemblies to lib/netstandard2.0..." -ForegroundColor Yellow +$netstandard2Dir = Join-Path $libDir "netstandard2.0" +if (!(Test-Path $netstandard2Dir)) { + New-Item -Path $netstandard2Dir -ItemType Directory | Out-Null +} +$moduleSource = Join-Path $srcDir "PowerShellYaml.Module/bin/$Configuration/netstandard2.0/publish" +$assembliesToCopy = @( + 'PowerShellYaml.dll', + 'PowerShellYaml.Module.dll', + 'YamlDotNet.dll' +) + +if ($IncludeSymbols) { + $assembliesToCopy += @( + 'PowerShellYaml.pdb', + 'PowerShellYaml.Module.pdb' + ) } + +foreach ($assemblyName in $assembliesToCopy) { + $sourcePath = Join-Path $moduleSource $assemblyName + if (Test-Path $sourcePath) { + Copy-Item -Path $sourcePath -Destination $netstandard2Dir -Force + Write-Host " ✓ $assemblyName" -ForegroundColor Gray + } else { + Write-Warning " ⚠ $assemblyName not found at $sourcePath" + } +} + +Write-Host "" +Write-Host "Module build complete" -ForegroundColor Green +Write-Host "" + +# ======== +# Summary +# ======== + +Write-Host "=== Build Summary ===" -ForegroundColor Cyan +Write-Host "" + +# Check all module outputs in lib/netstandard2.0 +Write-Host "Module Assemblies (lib/netstandard2.0):" -ForegroundColor Yellow +$allRequired = @( + 'PowerShellYaml.dll', + 'PowerShellYaml.Module.dll', + 'YamlDotNet.dll' +) +$buildSuccess = $true +foreach ($dll in $allRequired) { + $path = Join-Path $netstandard2Dir $dll + if (Test-Path $path) { + $size = (Get-Item $path).Length / 1KB + Write-Host " ✓ $dll ($([math]::Round($size, 1)) KB)" -ForegroundColor Green + } else { + Write-Host " ✗ $dll (missing)" -ForegroundColor Red + $buildSuccess = $false + } +} + +Write-Host "" + +if (!$buildSuccess) { + Write-Host "Build completed with errors" -ForegroundColor Red + exit 1 +} + +Write-Host "✓ All assemblies built successfully" -ForegroundColor Green +Write-Host "" + +# ========== +# Run Tests +# ========== + +if (!$SkipTests) { + Write-Host "=== Running Tests ===" -ForegroundColor Cyan + Write-Host "" + + # Run all tests + $testResult = Invoke-Pester ./Tests/*.Tests.ps1 -Output Normal -PassThru + + Write-Host "" + if ($testResult.FailedCount -eq 0) { + Write-Host "✓ All $($testResult.PassedCount) tests passed" -ForegroundColor Green + } else { + Write-Host "✗ $($testResult.FailedCount) tests failed" -ForegroundColor Red + exit 1 + } + Write-Host "" +} + +# ============== +# Final Summary +# ============== + +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host " Build Complete! " -ForegroundColor Cyan +Write-Host "=======================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Module Location:" -ForegroundColor Yellow +Write-Host " $root" -ForegroundColor Gray +Write-Host "" +Write-Host "To use:" -ForegroundColor Yellow +Write-Host " Import-Module $root/powershell-yaml.psd1" -ForegroundColor Cyan diff --git a/examples/advanced-features.ps1 b/examples/advanced-features.ps1 new file mode 100644 index 0000000..6face61 --- /dev/null +++ b/examples/advanced-features.ps1 @@ -0,0 +1,93 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Advanced Features Demo: YamlKey, Nested Objects, Metadata + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load advanced config class +. "$PSScriptRoot/classes/AdvancedConfig.ps1" + +Write-Host "=== Advanced Features Demo ===" -ForegroundColor Cyan +Write-Host "" + +# YAML with case-sensitive keys, comments, and tags +$yaml = @" +# Service configuration +service-name: ApiGateway +api-version: v2.1 + +# Primary server endpoint +primary-endpoint: + HTTP: http://api.example.com + HTTPS: https://api.example.com + port: !!int 443 + +# Backup endpoints for failover +backup-endpoints: + - HTTP: http://backup1.example.com + HTTPS: https://backup1.example.com + port: 443 + - HTTP: http://backup2.example.com + HTTPS: https://backup2.example.com + port: 443 + +max-retry-count: !!int "5" +"@ + +Write-Host "Step 1: Deserialize YAML with advanced features" -ForegroundColor Green +Write-Host "Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = $yaml | ConvertFrom-Yaml -As ([AdvancedConfig]) + +Write-Host "Step 2: Access deserialized data" -ForegroundColor Green +Write-Host " ServiceName: $($config.ServiceName)" -ForegroundColor Cyan +Write-Host " ApiVersion: $($config.ApiVersion)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.HTTP: $($config.PrimaryEndpoint.HttpUrl)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.HTTPS: $($config.PrimaryEndpoint.HttpsUrl)" -ForegroundColor Cyan +Write-Host " PrimaryEndpoint.Port: $($config.PrimaryEndpoint.Port)" -ForegroundColor Cyan +Write-Host " BackupEndpoints.Count: $($config.BackupEndpoints.Count)" -ForegroundColor Cyan +Write-Host " MaxRetries: $($config.MaxRetries)" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 3: Verify metadata preservation" -ForegroundColor Green +Write-Host " Comment on ServiceName: '$($config.GetPropertyComment('ServiceName'))'" -ForegroundColor Cyan +Write-Host " Comment on PrimaryEndpoint: '$($config.GetPropertyComment('PrimaryEndpoint'))'" -ForegroundColor Cyan +Write-Host " Tag on MaxRetries: '$($config.GetPropertyTag('MaxRetries'))'" -ForegroundColor Cyan +Write-Host " Tag on PrimaryEndpoint.Port: '$($config.PrimaryEndpoint.GetPropertyTag('Port'))'" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 4: Modify and serialize back" -ForegroundColor Green +$config.ServiceName = "UpdatedGateway" +$config.MaxRetries = 10 +$config.BackupEndpoints[0].HttpsUrl = "https://new-backup1.example.com" + +$newYaml = $config | ConvertTo-Yaml +Write-Host "Modified YAML:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" + +Write-Host "Step 5: Verify case-sensitive keys preserved" -ForegroundColor Green +if ($newYaml -match 'HTTP:' -and $newYaml -match 'HTTPS:') { + Write-Host " ✓ Case-sensitive keys (HTTP/HTTPS) correctly preserved" -ForegroundColor Green +} +if ($newYaml -match 'max-retry-count:') { + Write-Host " ✓ Custom YAML key (max-retry-count) correctly used" -ForegroundColor Green +} +if ($newYaml -match 'service-name:') { + Write-Host " ✓ Auto-converted key (service-name) correctly used" -ForegroundColor Green +} diff --git a/examples/classes/AdvancedConfig.ps1 b/examples/classes/AdvancedConfig.ps1 new file mode 100644 index 0000000..4bf7095 --- /dev/null +++ b/examples/classes/AdvancedConfig.ps1 @@ -0,0 +1,50 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Advanced configuration class demonstrating: +# - YamlKey attribute for case-sensitive keys +# - Nested YamlBase objects +# - Arrays +# - Automatic property name conversion + +class ServerEndpoint : YamlBase { + # Case-sensitive YAML keys for different protocols + [YamlKey("HTTP")] + [string]$HttpUrl = "" + + [YamlKey("HTTPS")] + [string]$HttpsUrl = "" + + # Normal property with auto-conversion (Port -> port) + [int]$Port = 0 +} + +class AdvancedConfig : YamlBase { + # Standard properties with auto-conversion + [string]$ServiceName = "" + [string]$ApiVersion = "" + + # Nested object + [ServerEndpoint]$PrimaryEndpoint = $null + + # Array of nested objects + [ServerEndpoint[]]$BackupEndpoints = @() + + # Property with custom YAML key + [YamlKey("max-retry-count")] + [int]$MaxRetries = 3 +} diff --git a/examples/classes/CustomConverters.ps1 b/examples/classes/CustomConverters.ps1 new file mode 100644 index 0000000..e288e40 --- /dev/null +++ b/examples/classes/CustomConverters.ps1 @@ -0,0 +1,150 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Example custom type converters using PowerShell classes + +using namespace PowerShellYaml +using namespace System +using namespace System.Collections.Generic + +# Custom type: Semantic Version +class SemanticVersion { + [int]$Major = 0 + [int]$Minor = 0 + [int]$Patch = 0 + [string]$PreRelease = "" + + [string] ToString() { + $ver = "$($this.Major).$($this.Minor).$($this.Patch)" + if ($this.PreRelease) { + $ver += "-$($this.PreRelease)" + } + return $ver + } +} + +# Custom converter for SemanticVersion with !semver tag +class SemVerConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + # Handle !semver tag or when target type is SemanticVersion + return $targetType -eq [SemanticVersion] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + $semver = [SemanticVersion]::new() + + if ($data -is [string]) { + # Parse string format: "1.2.3" or "1.2.3-beta1" + $parts = $data -split '-', 2 + $numbers = $parts[0] -split '\.' + + $semver.Major = [int]$numbers[0] + if ($numbers.Length -gt 1) { $semver.Minor = [int]$numbers[1] } + if ($numbers.Length -gt 2) { $semver.Patch = [int]$numbers[2] } + if ($parts.Length -gt 1) { $semver.PreRelease = $parts[1] } + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format: { major: 1, minor: 2, patch: 3, pre: "beta1" } + if ($data.ContainsKey('major')) { $semver.Major = [int]$data['major'] } + if ($data.ContainsKey('minor')) { $semver.Minor = [int]$data['minor'] } + if ($data.ContainsKey('patch')) { $semver.Patch = [int]$data['patch'] } + if ($data.ContainsKey('pre')) { $semver.PreRelease = [string]$data['pre'] } + } + + return $semver + } + + [object] ConvertToYaml([object]$value) { + $semver = [SemanticVersion]$value + # Return hashtable with Value and Tag + return @{ + Value = $semver.ToString() + Tag = '!semver' + } + } +} + +# Custom DateTime converter that overrides standard timestamp handling +# Uses a specific format instead of ISO8601 +class CustomDateTimeConverter : YamlConverter { + [bool] CanHandle([string]$tag, [Type]$targetType) { + # Handle our custom !datetime tag or when no tag and target is DateTime + return $targetType -eq [DateTime] + } + + [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { + if ($data -is [string]) { + # Parse custom format: "YYYY-MM-DD HH:mm:ss UTC" + if ($data -match '^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) UTC$') { + return [DateTime]::new( + [int]$Matches[1], # Year + [int]$Matches[2], # Month + [int]$Matches[3], # Day + [int]$Matches[4], # Hour + [int]$Matches[5], # Minute + [int]$Matches[6], # Second + [DateTimeKind]::Utc + ) + } + + # Fallback to general parse + return [DateTime]::Parse($data) + } + elseif ($data -is [System.Collections.Generic.Dictionary[string, object]]) { + # Parse dictionary format + $year = [int]$data['year'] + $month = [int]$data['month'] + $day = [int]$data['day'] + $hour = if ($data.ContainsKey('hour')) { [int]$data['hour'] } else { 0 } + $minute = if ($data.ContainsKey('minute')) { [int]$data['minute'] } else { 0 } + $second = if ($data.ContainsKey('second')) { [int]$data['second'] } else { 0 } + + return [DateTime]::new($year, $month, $day, $hour, $minute, $second, [DateTimeKind]::Utc) + } + + throw [FormatException]::new("Invalid datetime format") + } + + [object] ConvertToYaml([object]$value) { + $dt = [DateTime]$value + + # Convert to UTC if not already + if ($dt.Kind -ne [DateTimeKind]::Utc) { + $dt = $dt.ToUniversalTime() + } + + # Return hashtable with Value and Tag + return @{ + Value = $dt.ToString('yyyy-MM-dd HH:mm:ss') + ' UTC' + Tag = '!datetime' + } + } +} + +# Example config class using custom converters +class AppRelease : YamlBase { + [string]$AppName = "" + + [YamlConverter("SemVerConverter")] + [SemanticVersion]$Version = $null + + [YamlConverter("CustomDateTimeConverter")] + [DateTime]$ReleaseDate = [DateTime]::MinValue + + [YamlConverter("CustomDateTimeConverter")] + [DateTime]$BuildDate = [DateTime]::MinValue + + [string[]]$Features = @() +} diff --git a/examples/classes/DemoClasses.ps1 b/examples/classes/DemoClasses.ps1 new file mode 100644 index 0000000..fcbb6b7 --- /dev/null +++ b/examples/classes/DemoClasses.ps1 @@ -0,0 +1,37 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Simple demo classes using the default YamlBase implementations +# No need to implement ToDictionary/FromDictionary - they're handled automatically! +# Property names are automatically converted: PascalCase -> hyphenated-case + +class DatabaseConfig : YamlBase { + [string]$Host = 'localhost' + [int]$Port = 5432 + [string]$Database = '' + [string]$Username = '' + [bool]$UseSsl = $true +} + +class AppConfig : YamlBase { + [string]$AppName = '' + [string]$Version = '' + [string]$Environment = 'development' + [DatabaseConfig]$Database = $null + [int]$MaxConnections = 100 + [string[]]$AllowedOrigins = @() +} diff --git a/examples/classes/DuplicateKeyClasses.ps1 b/examples/classes/DuplicateKeyClasses.ps1 new file mode 100644 index 0000000..71cb107 --- /dev/null +++ b/examples/classes/DuplicateKeyClasses.ps1 @@ -0,0 +1,42 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Example class without YamlKey attributes - will fail with duplicate keys +class ConfigWithoutMapping : YamlBase { + [string]$test = "" +} + +# Example class with explicit YamlKey attributes for duplicate keys +class ConfigWithMapping : YamlBase { + [YamlKey("test")] + [string]$LowercaseValue = "" + + [YamlKey("Test")] + [string]$CapitalizedValue = "" +} + +# Example class demonstrating three case variations +class ThreeVariations : YamlBase { + [YamlKey("test")] + [string]$Lower = "" + + [YamlKey("Test")] + [string]$Capital = "" + + [YamlKey("TEST")] + [string]$Upper = "" +} diff --git a/examples/classes/ServerConfig.ps1 b/examples/classes/ServerConfig.ps1 new file mode 100644 index 0000000..49a1312 --- /dev/null +++ b/examples/classes/ServerConfig.ps1 @@ -0,0 +1,30 @@ +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +using namespace PowerShellYaml + +# Example class demonstrating YamlKey attribute for case-sensitive YAML keys +class ServerConfig : YamlBase { + # Maps to "Host" in YAML (capitalized) + [YamlKey("Host")] + [string]$PrimaryHost = "" + + # Maps to "host" in YAML (lowercase) + [YamlKey("host")] + [string]$BackupHost = "" + + # Uses automatic conversion: Port -> port + [int]$Port = 0 +} diff --git a/examples/custom-converters.ps1 b/examples/custom-converters.ps1 new file mode 100644 index 0000000..2b1295c --- /dev/null +++ b/examples/custom-converters.ps1 @@ -0,0 +1,145 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Custom Type Converters Demo: Extending YAML with custom types + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load custom converters +. "$PSScriptRoot/classes/CustomConverters.ps1" + +Write-Host "=== Custom Type Converters Demo ===" -ForegroundColor Cyan +Write-Host "" + +# Example 1: Custom tag for semantic versioning +Write-Host "Example 1: Custom !semver tag" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml1 = @" +app-name: MyAwesomeApp +version: !semver "2.1.5-beta3" +release-date: !datetime "2024-12-15 14:30:00 UTC" +build-date: !datetime "2024-12-15 10:00:00 UTC" +features: + - New user interface + - Performance improvements + - Bug fixes +"@ + +Write-Host "Input YAML with custom tags:" +Write-Host $yaml1 -ForegroundColor Gray +Write-Host "" + +$release = $yaml1 | ConvertFrom-Yaml -As ([AppRelease]) + +Write-Host "Deserialized values:" +Write-Host " App Name: $($release.AppName)" +Write-Host " Version: $($release.Version) (Type: $($release.Version.GetType().Name))" +Write-Host " - Major: $($release.Version.Major)" +Write-Host " - Minor: $($release.Version.Minor)" +Write-Host " - Patch: $($release.Version.Patch)" +Write-Host " - PreRelease: $($release.Version.PreRelease)" +Write-Host " Release Date: $($release.ReleaseDate) (Type: $($release.ReleaseDate.GetType().Name))" +Write-Host " Build Date: $($release.BuildDate)" +Write-Host " Features: $($release.Features.Count) items" +Write-Host "" + +# Modify and serialize back +$release.Version.Patch = 6 +$release.Version.PreRelease = "rc1" +$release.ReleaseDate = [DateTime]::new(2025, 1, 15, 9, 0, 0, [DateTimeKind]::Utc) + +$newYaml = $release | ConvertTo-Yaml + +Write-Host "Modified and serialized back:" +Write-Host $newYaml -ForegroundColor Green +Write-Host "" + +# Example 2: Different input formats with same converter +Write-Host "Example 2: Flexible input formats" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml2 = @" +app-name: FlexibleApp +version: !semver { major: 3, minor: 0, patch: 0, pre: "alpha1" } +release-date: !datetime { year: 2025, month: 6, day: 1, hour: 12, minute: 0, second: 0 } +build-date: !datetime "2025-05-30 18:45:30 UTC" +features: + - Advanced configuration +"@ + +Write-Host "Input YAML with dictionary format for version:" +Write-Host $yaml2 -ForegroundColor Gray +Write-Host "" + +$release2 = $yaml2 | ConvertFrom-Yaml -As ([AppRelease]) + +Write-Host "Deserialized values:" +Write-Host " Version: $($release2.Version)" +Write-Host " Release Date: $($release2.ReleaseDate)" +Write-Host "" + +# Example 3: Round-trip preservation +Write-Host "Example 3: Round-trip with tag preservation" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$yaml3 = @" +app-name: RoundTripTest +version: !semver "1.0.0" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2023-12-31 20:00:00 UTC" +features: + - Initial release +"@ + +$release3 = $yaml3 | ConvertFrom-Yaml -As ([AppRelease]) +$roundTripped = $release3 | ConvertTo-Yaml + +Write-Host "Original YAML:" +Write-Host $yaml3 -ForegroundColor Gray +Write-Host "" + +Write-Host "Round-tripped YAML:" +Write-Host $roundTripped -ForegroundColor Green +Write-Host "" + +# Example 4: Error handling - invalid format +Write-Host "Example 4: Error handling" -ForegroundColor Yellow +Write-Host "--------------------------------------" +Write-Host "" + +$invalidYaml = @" +app-name: ErrorTest +version: !semver "not-a-valid-version" +release-date: !datetime "2024-01-01 00:00:00 UTC" +build-date: !datetime "2024-01-01 00:00:00 UTC" +features: [] +"@ + +Write-Host "Attempting to parse invalid version format:" +Write-Host $invalidYaml -ForegroundColor Gray +Write-Host "" + +try { + $invalidRelease = $invalidYaml | ConvertFrom-Yaml -As ([AppRelease]) + Write-Host "Parsed successfully (converter handled gracefully)" -ForegroundColor Green + Write-Host " Version: $($invalidRelease.Version)" +} catch { + Write-Host "✓ Error caught as expected:" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} diff --git a/examples/duplicate-key-detection.ps1 b/examples/duplicate-key-detection.ps1 new file mode 100644 index 0000000..7ea2194 --- /dev/null +++ b/examples/duplicate-key-detection.ps1 @@ -0,0 +1,125 @@ +#!/usr/bin/env pwsh +# Example: Duplicate Key Detection - Preventing Data Loss + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load class definitions +. "$PSScriptRoot/classes/DuplicateKeyClasses.ps1" + +Write-Host "=== Duplicate Key Detection Demo ===" -ForegroundColor Cyan +Write-Host "" + +# Example 1: PSCustomObject mode rejects duplicate keys +Write-Host "Example 1: PSCustomObject mode prevents data loss" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$yaml = @" +test: hello +Test: world +"@ + +Write-Host "Input YAML with case-insensitive duplicate keys:" +Write-Host $yaml +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) + Write-Host "ERROR: Should have thrown an error!" -ForegroundColor Red +} catch { + Write-Host "✓ Correctly rejected duplicate keys:" -ForegroundColor Green + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 2: Typed mode without explicit mappings fails +Write-Host "Example 2: Typed mode without YamlKey attributes" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +Write-Host "Class definition:" +Write-Host " class ConfigWithoutMapping : YamlBase {" +Write-Host " [string]`$test = `"`"" +Write-Host " }" +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([ConfigWithoutMapping]) + Write-Host "ERROR: Should have thrown an error!" -ForegroundColor Red +} catch { + Write-Host "✓ Correctly rejected unmapped duplicate keys:" -ForegroundColor Green + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 3: Typed mode WITH explicit mappings succeeds +Write-Host "Example 3: Typed mode with explicit YamlKey mappings" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +Write-Host "Class definition:" +Write-Host " class ConfigWithMapping : YamlBase {" +Write-Host " [YamlKey(`"test`")]" +Write-Host " [string]`$LowercaseValue = `"`"" +Write-Host "" +Write-Host " [YamlKey(`"Test`")]" +Write-Host " [string]`$CapitalizedValue = `"`"" +Write-Host " }" +Write-Host "" + +try { + $obj = $yaml | ConvertFrom-Yaml -As ([ConfigWithMapping]) + Write-Host "✓ Successfully parsed with explicit mappings:" -ForegroundColor Green + Write-Host " LowercaseValue (from 'test'): $($obj.LowercaseValue)" + Write-Host " CapitalizedValue (from 'Test'): $($obj.CapitalizedValue)" +} catch { + Write-Host "ERROR: Should have succeeded!" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Gray +} + +Write-Host "" +Write-Host "" + +# Example 4: Round-trip preservation +Write-Host "Example 4: Round-trip with case-sensitive keys" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$obj.LowercaseValue = "modified lowercase" +$obj.CapitalizedValue = "MODIFIED UPPERCASE" + +$newYaml = $obj | ConvertTo-Yaml + +Write-Host "Serialized YAML (case preserved):" +Write-Host $newYaml +Write-Host "" + +# Deserialize again to verify +$obj2 = $newYaml | ConvertFrom-Yaml -As ([ConfigWithMapping]) +Write-Host "✓ Round-trip successful:" -ForegroundColor Green +Write-Host " LowercaseValue: $($obj2.LowercaseValue)" +Write-Host " CapitalizedValue: $($obj2.CapitalizedValue)" + +Write-Host "" +Write-Host "" + +# Example 5: Multiple case variations +Write-Host "Example 5: Three case variations" -ForegroundColor Yellow +Write-Host "------------------------------------------------" + +$yaml3 = @" +test: lowercase +Test: capitalized +TEST: uppercase +"@ + +Write-Host "Input YAML:" +Write-Host $yaml3 +Write-Host "" + +$obj3 = $yaml3 | ConvertFrom-Yaml -As ([ThreeVariations]) + +Write-Host "✓ Successfully parsed three case variations:" -ForegroundColor Green +Write-Host " Lower (from 'test'): $($obj3.Lower)" +Write-Host " Capital (from 'Test'): $($obj3.Capital)" +Write-Host " Upper (from 'TEST'): $($obj3.Upper)" diff --git a/examples/metadata-demo.ps1 b/examples/metadata-demo.ps1 new file mode 100644 index 0000000..f46aaba --- /dev/null +++ b/examples/metadata-demo.ps1 @@ -0,0 +1,82 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Metadata Preservation Demo: Comments, Tags, and Scalar Styles + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# Load demo classes +. "$PSScriptRoot/classes/DemoClasses.ps1" + +Write-Host "=== Metadata Preservation Demo ===" -ForegroundColor Cyan +Write-Host "" + +# YAML with rich metadata: comments, tags, and different scalar styles +$yaml = @" +# Application configuration +app-name: "MyApp" +version: '1.0.0' +environment: production + +# Database connection settings +database: + host: db.example.com + port: !!int 5432 + database: myapp_db + username: app_user + use-ssl: true + +max-connections: !!int "100" +allowed-origins: + - https://app.example.com + - https://api.example.com +"@ + +Write-Host "Step 1: Deserialize YAML with metadata" -ForegroundColor Green +Write-Host "Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = $yaml | ConvertFrom-Yaml -As ([AppConfig]) + +Write-Host "Step 2: Inspect preserved metadata" -ForegroundColor Green +Write-Host " Comment on 'AppName': '$($config.GetPropertyComment('AppName'))'" -ForegroundColor Cyan +Write-Host " Comment on 'Database': '$($config.GetPropertyComment('Database'))'" -ForegroundColor Cyan +Write-Host " Tag on 'MaxConnections': '$($config.GetPropertyTag('MaxConnections'))'" -ForegroundColor Cyan +Write-Host " Scalar style on 'AppName': '$($config.GetPropertyScalarStyle('AppName'))'" -ForegroundColor Cyan +Write-Host " Scalar style on 'Version': '$($config.GetPropertyScalarStyle('Version'))'" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 3: Modify values while preserving metadata" -ForegroundColor Green +$config.Environment = 'staging' +$config.MaxConnections = 150 +Write-Host " Changed Environment to 'staging'" -ForegroundColor Cyan +Write-Host " Changed MaxConnections to 150" -ForegroundColor Cyan +Write-Host "" + +Write-Host "Step 4: Serialize back with metadata preservation" -ForegroundColor Green +$newYaml = $config | ConvertTo-Yaml +Write-Host "Output YAML:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" + +Write-Host "Step 5: Add new metadata programmatically" -ForegroundColor Green +$config.SetPropertyComment('Environment', 'Deployment environment') +$config.SetPropertyTag('MaxConnections', 'tag:yaml.org,2002:int') +$config.Database.SetPropertyComment('Host', 'Primary database server') + +$newYaml2 = $config | ConvertTo-Yaml +Write-Host "YAML with added metadata:" -ForegroundColor Yellow +Write-Host $newYaml2 diff --git a/examples/typed-yaml-demo.ps1 b/examples/typed-yaml-demo.ps1 new file mode 100644 index 0000000..d994574 --- /dev/null +++ b/examples/typed-yaml-demo.ps1 @@ -0,0 +1,106 @@ +#!/usr/bin/env pwsh +# Typed YAML Demo: Comprehensive Introduction +# This demo showcases PowerShell YAML typed class features +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +Write-Host "=== PowerShell YAML - Typed Class Demo ===" -ForegroundColor Cyan +Write-Host "PowerShell Version: $($PSVersionTable.PSVersion)" -ForegroundColor Yellow +Write-Host "" + +# Import the module +# Write-Host "Step 1: Import PowerShell YAML module" -ForegroundColor Green +# Import-Module ./powershell-yaml.psd1 -Force + +# Verify YamlBase is available +Write-Host " YamlBase type available: $([PowerShellYaml.YamlBase] -ne $null)" -ForegroundColor Cyan +Write-Host "" + +# Load demo classes +Write-Host "Step 2: Load configuration classes" -ForegroundColor Green +. "$PSScriptRoot/classes/DemoClasses.ps1" +Write-Host " Loaded: DatabaseConfig, AppConfig" -ForegroundColor Cyan +Write-Host "" + +# Demonstrate deserialization +Write-Host "Step 3: Deserialize YAML to typed objects" -ForegroundColor Green +$yaml = @" +app-name: MyAwesomeApp +version: 2.0.0 +environment: production +database: + # host is the address for the DB + host: db.example.com + port: !!int 5432 + database: !!str myapp_prod + username: app_user + use-ssl: true +max-connections: 200 +allowed-origins: + - https://app.example.com + - https://admin.example.com +"@ + +Write-Host " Source YAML:" -ForegroundColor Yellow +Write-Host $yaml +Write-Host "" + +$config = ConvertFrom-Yaml -Yaml $yaml -As ([AppConfig]) + +Write-Host " Deserialized object:" -ForegroundColor Yellow +Write-Host " AppName: $($config.AppName)" -ForegroundColor Cyan +Write-Host " Version: $($config.Version)" -ForegroundColor Cyan +Write-Host " Environment: $($config.Environment)" -ForegroundColor Cyan +Write-Host " Database.Host: $($config.Database.Host)" -ForegroundColor Cyan +Write-Host " Database.Port: $($config.Database.Port)" -ForegroundColor Cyan +Write-Host " Database.UseSsl: $($config.Database.UseSsl)" -ForegroundColor Cyan +Write-Host " MaxConnections: $($config.MaxConnections)" -ForegroundColor Cyan +Write-Host " AllowedOrigins: $($config.AllowedOrigins -join ', ')" -ForegroundColor Cyan +Write-Host "" + +# Verify type safety +Write-Host "Step 4: Verify type safety" -ForegroundColor Green +Write-Host " config is [AppConfig]: $($config -is [AppConfig])" -ForegroundColor Cyan +Write-Host " config is [YamlBase]: $($config -is [PowerShellYaml.YamlBase])" -ForegroundColor Cyan +Write-Host " config.Database is [DatabaseConfig]: $($config.Database -is [DatabaseConfig])" -ForegroundColor Cyan +Write-Host "" + +# Modify and serialize +Write-Host "Step 5: Modify configuration and serialize back to YAML" -ForegroundColor Green +$config.Environment = 'staging' +$config.MaxConnections = 150 +$config.AllowedOrigins += 'https://staging.example.com' +$config.Database.Port = 5433 + +$outputYaml = ConvertTo-Yaml $config + +Write-Host " Modified YAML:" -ForegroundColor Yellow +Write-Host $outputYaml +Write-Host "" + +# Round-trip test +Write-Host "Step 6: Round-trip verification" -ForegroundColor Green +$config2 = ConvertFrom-Yaml -Yaml $outputYaml -As ([AppConfig]) + +Write-Host " Environment preserved: $($config2.Environment -eq 'staging')" -ForegroundColor Cyan +Write-Host " MaxConnections preserved: $($config2.MaxConnections -eq 150)" -ForegroundColor Cyan +Write-Host " Database.Port preserved: $($config2.Database.Port -eq 5433)" -ForegroundColor Cyan +Write-Host " AllowedOrigins count: $($config2.AllowedOrigins.Count)" -ForegroundColor Cyan +Write-Host "" + +# Demonstrate nested object serialization +Write-Host "Step 7: Create configuration from scratch" -ForegroundColor Green +$newConfig = [AppConfig]::new() +$newConfig.AppName = 'TestApp' +$newConfig.Version = '1.0.0' +$newConfig.Environment = 'development' +$newConfig.Database = [DatabaseConfig]::new() +$newConfig.Database.Host = 'localhost' +$newConfig.Database.Database = 'test_db' +$newConfig.Database.Username = 'test_user' +$newConfig.AllowedOrigins = @('http://localhost:3000', 'http://localhost:8080') + +$newYaml = ConvertTo-Yaml $newConfig + +Write-Host " Generated YAML from new object:" -ForegroundColor Yellow +Write-Host $newYaml +Write-Host "" diff --git a/examples/yamlkey-attribute.ps1 b/examples/yamlkey-attribute.ps1 new file mode 100644 index 0000000..9bdf59c --- /dev/null +++ b/examples/yamlkey-attribute.ps1 @@ -0,0 +1,54 @@ +#!/usr/bin/env pwsh +# Copyright 2016-2026 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# Example: Using YamlKey attribute for case-sensitive YAML keys + +Import-Module "$PSScriptRoot/../powershell-yaml.psd1" -Force + +# PowerShell class properties are case-insensitive, but YAML keys can be case-sensitive. +# Use the [YamlKey] attribute to map different YAML keys to different properties. + +# Load the class definition +. "$PSScriptRoot/classes/ServerConfig.ps1" + +# YAML with case-sensitive keys +$yaml = @" +Host: primary.example.com +host: backup.example.com +port: 8080 +"@ + +Write-Host "Input YAML:" +Write-Host $yaml +Write-Host "" + +# Deserialize +$config = $yaml | ConvertFrom-Yaml -As ([ServerConfig]) + +Write-Host "Deserialized values:" +Write-Host " PrimaryHost (from 'Host'): $($config.PrimaryHost)" +Write-Host " BackupHost (from 'host'): $($config.BackupHost)" +Write-Host " Port: $($config.Port)" +Write-Host "" + +# Modify and serialize back +$config.PrimaryHost = "new-primary.example.com" +$config.BackupHost = "new-backup.example.com" +$config.Port = 9090 + +$newYaml = $config | ConvertTo-Yaml + +Write-Host "Serialized YAML:" +Write-Host $newYaml diff --git a/lib/netstandard2.0/LICENSE.txt b/lib/netstandard2.0/LICENSE.txt deleted file mode 100644 index d4f2924..0000000 --- a/lib/netstandard2.0/LICENSE.txt +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2008, 2009, 2010, 2011, 2012, 2013, 2014 Antoine Aubry and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/lib/netstandard2.0/PowerShellYaml.Module.dll b/lib/netstandard2.0/PowerShellYaml.Module.dll new file mode 100644 index 0000000..375ddc3 Binary files /dev/null and b/lib/netstandard2.0/PowerShellYaml.Module.dll differ diff --git a/lib/netstandard2.0/PowerShellYaml.dll b/lib/netstandard2.0/PowerShellYaml.dll new file mode 100644 index 0000000..7ef46f8 Binary files /dev/null and b/lib/netstandard2.0/PowerShellYaml.dll differ diff --git a/lib/netstandard2.0/PowerShellYamlSerializer.dll b/lib/netstandard2.0/PowerShellYamlSerializer.dll deleted file mode 100644 index 25b37f7..0000000 Binary files a/lib/netstandard2.0/PowerShellYamlSerializer.dll and /dev/null differ diff --git a/lib/netstandard2.0/YamlDotNet.dll b/lib/netstandard2.0/YamlDotNet.dll old mode 100644 new mode 100755 diff --git a/powershell-yaml.psd1 b/powershell-yaml.psd1 index b0610ca..260cdd8 100644 --- a/powershell-yaml.psd1 +++ b/powershell-yaml.psd1 @@ -1,4 +1,4 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -25,13 +25,45 @@ RootModule = 'powershell-yaml.psm1' # Version number of this module. -ModuleVersion = '0.4.12' +ModuleVersion = '0.5.0' PrivateData = @{ PSData = @{ + Prerelease = 'beta1' LicenseUri = 'https://github.com/cloudbase/powershell-yaml/blob/master/LICENSE' ProjectUri = 'https://github.com/cloudbase/powershell-yaml' ReleaseNotes = @' +# 0.5.0 + +New Features: +* Type-safe YAML deserialization using PowerShell classes inheriting from YamlBase +* Round-trip preservation of comments, tags, and scalar styles +* [YamlKey] attribute to map custom YAML keys to properties +* Duplicate key detection prevents silent data loss +* Enhanced PSCustomObject mode with metadata preservation +* Validation for non-YamlBase nested classes with clear error messages +* Automatic property name conversion (PascalCase -> hyphenated-case) +* Added -Depth flag to ConvertTo-Yaml +* Added -EmitTags to ConvertTo-Yaml +* Added option to emit yaml using explicit block style (override the round-trip style from the document) + +Usage: + # PSCustomObject mode with metadata + $obj = $yaml | ConvertFrom-Yaml -As ([PSCustomObject]) + + # Typed mode with YamlBase classes + class MyConfig : YamlBase { [string]$Name } + $config = $yaml | ConvertFrom-Yaml -As ([MyConfig]) + + # Convert flow style doc to block style doc + $yaml = '{hello: world, goodbye: world}' + ConvertFrom-Yaml $yaml -As ([pscustomobject]) | ConvertTo-Yaml -Options UseBlockStyle + hello: world + goodbye: world + + +See examples/ for detailed usage patterns. + # 0.4.12 Bugfixes: @@ -74,7 +106,7 @@ Author = 'Gabriel Adrian Samfira','Alessandro Pilotti' CompanyName = 'Cloudbase Solutions SRL' # Copyright statement for this module -Copyright = '(c) 2016-2024 Cloudbase Solutions SRL. All rights reserved.' +Copyright = '(c) 2016-2026 Cloudbase Solutions SRL. All rights reserved.' # Description of the functionality provided by this module Description = 'Powershell module for serializing and deserializing YAML' @@ -82,8 +114,34 @@ Description = 'Powershell module for serializing and deserializing YAML' # Minimum version of the Windows PowerShell engine required by this module PowerShellVersion = '5.0' +# Load PowerShellYaml.dll before parsing the module +# This makes YamlBase available for class inheritance in user scripts +RequiredAssemblies = @('lib/netstandard2.0/PowerShellYaml.dll') + # Functions to export from this module -FunctionsToExport = "ConvertTo-Yaml","ConvertFrom-Yaml" +FunctionsToExport = @( + "ConvertTo-Yaml", + "ConvertFrom-Yaml", + "Set-YamlPropertyComment", + "Get-YamlPropertyComment", + "Set-YamlPropertyScalarStyle", + "Test-YamlMetadata" +) + +# Cmdlets to export from this module +# We don't list any here - the .psm1's Export-ModuleMember controls what gets exported. +# ConvertFrom-YamlTyped and ConvertTo-YamlTyped are loaded internally but not exported. +# CmdletsToExport = @() AliasesToExport = "cfy","cty" + +# List of all files packaged with this module +FileList = @( + 'powershell-yaml.psd1', + 'powershell-yaml.psm1', + 'lib/netstandard2.0/YamlDotNet.dll', + 'lib/netstandard2.0/PowerShellYamlSerializer.dll', + 'lib/netstandard2.0/PowerShellYaml.dll', + 'lib/netstandard2.0/PowerShellYaml.Module.dll' +) } diff --git a/powershell-yaml.psm1 b/powershell-yaml.psm1 index dfecf6a..8884968 100644 --- a/powershell-yaml.psm1 +++ b/powershell-yaml.psm1 @@ -1,4 +1,4 @@ -# Copyright 2016-2024 Cloudbase Solutions Srl +# Copyright 2016-2026 Cloudbase Solutions Srl # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain @@ -25,65 +25,113 @@ enum SerializationOptions { OmitNullValues = 64 UseFlowStyle = 128 UseSequenceFlowStyle = 256 + UseBlockStyle = 512 + UseSequenceBlockStyle = 1024 } + $here = Split-Path -Parent $MyInvocation.MyCommand.Path $infinityRegex = [regex]::new('^[-+]?(\.inf|\.Inf|\.INF)$', 'Compiled, CultureInvariant'); -function Invoke-LoadFile { +function Invoke-LoadAssemblyWithDependencies { param( - [string]$assemblyPath + [Parameter(Mandatory)] + [string]$MainAssemblyPath, + + [Parameter(Mandatory)] + [hashtable]$Dependencies, + + [string]$ForceLoadTypeName ) - $powershellYamlDotNetAssemblyPath = Join-Path $assemblyPath 'YamlDotNet.dll' - $serializerAssemblyPath = Join-Path $assemblyPath 'PowerShellYamlSerializer.dll' - $yamlAssembly = [Reflection.Assembly]::LoadFile($powershellYamlDotNetAssemblyPath) - $serializerAssembly = [Reflection.Assembly]::LoadFile($serializerAssemblyPath) + # Load the main assembly via LoadFile (creates anonymous ALC on PS 7+) + $mainAssembly = [Reflection.Assembly]::LoadFile($MainAssemblyPath) if ($PSVersionTable['PSEdition'] -eq 'Core') { - # Register the AssemblyResolve event to load dependencies manually. This seems to be needed only on - # PowerShell Core. + # On PowerShell Core, use AssemblyResolve to manually load dependencies + # This is needed because LoadFile doesn't automatically resolve dependencies $resolver = { param ($snd, $e) - # This event only needs to run once when the Invoke-LoadFile function is called. - # If it's called again, the variables defined in this functions will not be available, - # so we can safely ignore the event. - if (-not $serializerAssemblyPath -or -not $powershellYamlDotNetAssemblyPath) { + + # Only respond if we have the necessary paths (from outer scope) + if (-not $MainAssemblyPath -or -not $Dependencies) { return $null } - # Load YamlDotNet if it's requested by PowerShellYamlSerializer. Ignore other requests as they might - # originate from other assemblies that are not part of this module and which might have different - # versions of the module that they need to load. - if ($e.Name -match '^YamlDotNet,*' -and $e.RequestingAssembly.Location -eq $serializerAssemblyPath) { - return [System.Reflection.Assembly]::LoadFile($powershellYamlDotNetAssemblyPath) + + # Only respond to requests from our main assembly + if ($e.RequestingAssembly.Location -eq $MainAssemblyPath) { + # Check each dependency + foreach ($dep in $Dependencies.GetEnumerator()) { + if ($e.Name -match "^$($dep.Key),") { + # Dependency can be either a path (string) or an Assembly object + if ($dep.Value -is [string]) { + return [System.Reflection.Assembly]::LoadFile($dep.Value) + } else { + return $dep.Value + } + } + } } return $null } + [System.AppDomain]::CurrentDomain.add_AssemblyResolve($resolver) - # Load the StringQuotingEmitter from PowerShellYamlSerializer to force the resolver handler to fire once. - # This is an ugly hack I am not happy with. - $serializerAssembly.GetType('StringQuotingEmitter') | Out-Null - # Remove the resolver handler after it has been used. + # Force dependency resolution by accessing a type + if ($ForceLoadTypeName) { + $mainAssembly.GetType($ForceLoadTypeName) | Out-Null + } else { + $mainAssembly.GetTypes() | Out-Null + } + [System.AppDomain]::CurrentDomain.remove_AssemblyResolve($resolver) } - return @{ 'yaml' = $yamlAssembly; 'quoted' = $serializerAssembly } + return $mainAssembly } -function Invoke-LoadAssembly { - $libDir = Join-Path $here 'lib' - $assemblies = @{ - 'netstandard2.0' = Join-Path $libDir 'netstandard2.0'; - } +# ============================================================================ +# Assembly Path Configuration +# ============================================================================ - return (Invoke-LoadFile -assemblyPath $assemblies['netstandard2.0']) -} +$libDir = Join-Path $here 'lib/netstandard2.0' + +# YAML module assemblies +$yamlDotNetPath = Join-Path $libDir 'YamlDotNet.dll' + +# Typed YAML module assemblies (now contains ALL serialization code) +$typedModulePath = Join-Path $libDir 'PowerShellYaml.Module.dll' +$typedYamlBasePath = Join-Path $libDir 'PowerShellYaml.dll' + +# Load YamlDotNet first (isolated via LoadFile) +$yamlDotNetAssembly = [Reflection.Assembly]::LoadFile($yamlDotNetPath) + +# Load PowerShellYaml.dll (must be in Default ALC for class inheritance) +$yamlBaseAsm = [System.Reflection.Assembly]::LoadFrom($typedYamlBasePath) -$assemblies = Invoke-LoadAssembly +# Load PowerShellYaml.Module.dll early for BuilderUtils access +# Dependencies: YamlDotNet (isolated) and PowerShellYaml (from Default ALC) +$script:typedModuleAssembly = Invoke-LoadAssemblyWithDependencies ` + -MainAssemblyPath $typedModulePath ` + -Dependencies @{ + 'YamlDotNet' = $yamlDotNetPath + 'PowerShellYaml' = $yamlBaseAsm + } -$yamlDotNetAssembly = $assemblies['yaml'] -$stringQuotedAssembly = $assemblies['quoted'] +# Store types for use throughout the module +$script:BuilderUtils = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.BuilderUtils') +$script:TypedYamlConverter = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.TypedYamlConverter') +$script:YamlDocumentParser = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.YamlDocumentParser') +$script:PSObjectMetadataExtensions = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.PSObjectMetadataExtensions') +$YamlMetadataStore = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.YamlMetadataStore') +$MetadataAwareSerializer = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.MetadataAwareSerializer') + +# Create type accelerators for test scripts +$TypeAcceleratorsClass = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') +$TypeAcceleratorsClass::Add('YamlDocumentParser', $script:YamlDocumentParser) +$TypeAcceleratorsClass::Add('PSObjectMetadataExtensions', $script:PSObjectMetadataExtensions) +$TypeAcceleratorsClass::Add('YamlMetadataStore', $YamlMetadataStore) +$TypeAcceleratorsClass::Add('MetadataAwareSerializer', $MetadataAwareSerializer) function Get-YamlDocuments { [CmdletBinding()] @@ -273,7 +321,7 @@ function Convert-YamlDocumentToPSObject { [CmdletBinding()] param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [System.Object]$Node, + [System.Object]$Node, [switch]$Ordered ) process { @@ -392,7 +440,21 @@ function ConvertFrom-Yaml { [string]$Yaml, [switch]$AllDocuments = $false, [switch]$Ordered, - [switch]$UseMergingParser = $false + [switch]$UseMergingParser = $false, + [Parameter(Mandatory = $false)] + [ValidateScript({ + if ($null -eq $_) { + throw "The -As parameter cannot be null" + } + if ($_ -eq [PSCustomObject]) { + return $true + } + if ($_.IsSubclassOf([PowerShellYaml.YamlBase])) { + return $true + } + throw "The -As parameter must be either [PSCustomObject] or a type that inherits from [PowerShellYaml.YamlBase]. Got: $($_.FullName)" + })] + [type]$As ) begin { @@ -406,14 +468,32 @@ function ConvertFrom-Yaml { if ($d -eq '') { return } + + # Route based on -As parameter (validation already done by ValidateScript) + if ($PSBoundParameters.ContainsKey('As')) { + if ($As.IsSubclassOf([PowerShellYaml.YamlBase])) { + if ($script:TypedYamlConverter) { + return $script:TypedYamlConverter::FromYaml($d, $As) + } else { + throw "Typed YAML module not loaded" + } + } else { + # Use YamlDocumentParser to preserve metadata + $result = $script:YamlDocumentParser::ParseWithMetadata($d) + if ($null -eq $result.Item1) { + return $null + } + # Create enhanced PSCustomObject from parsed data and metadata + return $script:PSObjectMetadataExtensions::CreateEnhancedPSCustomObject($result.Item1, $result.Item2) + } + } + + # Mode 1: Original hashtable mode (no -As parameter) $documents = Get-YamlDocuments -Yaml $d -UseMergingParser:$UseMergingParser if (!$documents.Count) { return } - if ($documents.Count -eq 1) { - return Convert-YamlDocumentToPSObject $documents[0].RootNode -Ordered:$Ordered - } - if (!$AllDocuments) { + if (($documents.Count -eq 1) -or !$AllDocuments) { return Convert-YamlDocumentToPSObject $documents[0].RootNode -Ordered:$Ordered } $ret = @() @@ -426,9 +506,10 @@ function ConvertFrom-Yaml { function Get-Serializer { param( - [Parameter(Mandatory = $true)][SerializationOptions]$Options + [Parameter(Mandatory = $true)][SerializationOptions]$Options, + [int]$MaxDepth = 100 ) - + $builder = $yamlDotNetAssembly.GetType('YamlDotNet.Serialization.SerializerBuilder')::new() $JsonCompatible = $Options.HasFlag([SerializationOptions]::JsonCompatible) @@ -452,12 +533,16 @@ function Get-Serializer { $builder = $builder.WithIndentedSequences() } + # Set a high recursion limit - our custom visitors handle depth limiting and circular references + $builder = $builder.WithMaximumRecursion(1000) + $omitNull = $Options.HasFlag([SerializationOptions]::OmitNullValues) $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) $useSequenceFlowStyle = $Options.HasFlag([SerializationOptions]::UseSequenceFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) + $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) - $stringQuoted = $stringQuotedAssembly.GetType('BuilderUtils') - $builder = $stringQuoted::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $JsonCompatible) + $builder = $script:BuilderUtils::BuildSerializer($builder, $omitNull, $useFlowStyle, $useSequenceFlowStyle, $useBlockStyle, $useSequenceBlockStyle, $JsonCompatible, $MaxDepth) return $builder.Build() } @@ -478,7 +563,15 @@ function ConvertTo-Yaml { [switch]$KeepArray, - [switch]$Force + [switch]$Force, + + # Typed YAML parameters (for YamlBase objects) + [switch]$OmitNull, + + [switch]$EmitTags, + + # Maximum recursion depth (default: 50) + [int]$Depth = 100 ) begin { $d = [System.Collections.Generic.List[object]](New-Object 'System.Collections.Generic.List[object]') @@ -495,8 +588,62 @@ function ConvertTo-Yaml { if ($d.Count -eq 1 -and !($KeepArray)) { $d = $d[0] } - $norm = Convert-PSObjectToGenericObject $d - if ($OutFile) { + + # Mode 3: Typed class mode - call C# helper directly + if ($d -is [PowerShellYaml.YamlBase]) { + if ($script:TypedYamlConverter) { + # Extract flow/block style options if using Options parameter + $useFlowStyle = $false + $useBlockStyle = $false + $useSequenceFlowStyle = $false + $useSequenceBlockStyle = $false + $indentedSequences = $false + if ($PSCmdlet.ParameterSetName -eq 'Options') { + $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) + $useSequenceFlowStyle = $Options.HasFlag([SerializationOptions]::UseSequenceFlowStyle) + $useSequenceBlockStyle = $Options.HasFlag([SerializationOptions]::UseSequenceBlockStyle) + $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) + } + $yaml = $script:TypedYamlConverter::ToYaml($d, $OmitNull.IsPresent, $EmitTags.IsPresent, $useFlowStyle, $useBlockStyle, $useSequenceFlowStyle, $useSequenceBlockStyle, $indentedSequences, $Depth) + } else { + throw "Typed YAML module not loaded" + } + } elseif ($script:PSObjectMetadataExtensions::IsEnhancedPSCustomObject($d)) { + # Use metadata-aware serializer + $MetadataAwareSerializer = $script:typedModuleAssembly.GetType('PowerShellYaml.Module.MetadataAwareSerializer') + # Extract style options if using Options parameter + $indentedSequences = $false + $useFlowStyle = $false + $useBlockStyle = $false + if ($PSCmdlet.ParameterSetName -eq 'Options') { + $indentedSequences = $Options.HasFlag([SerializationOptions]::WithIndentedSequences) + $useFlowStyle = $Options.HasFlag([SerializationOptions]::UseFlowStyle) + $useBlockStyle = $Options.HasFlag([SerializationOptions]::UseBlockStyle) + } + $yaml = $MetadataAwareSerializer::Serialize($d, $indentedSequences, $EmitTags.IsPresent, $Depth, $useFlowStyle, $useBlockStyle) + } else { + $wrt = New-Object 'System.IO.StringWriter' + $norm = Convert-PSObjectToGenericObject $d + if ($PSCmdlet.ParameterSetName -eq 'NoOptions') { + $Options = 0 + if ($JsonCompatible) { + # No indent options :~( + $Options = [SerializationOptions]::JsonCompatible + } + } + try { + $serializer = Get-Serializer -Options $Options -MaxDepth $Depth + $serializer.Serialize($wrt, $norm) + $yaml = $wrt.ToString() + } finally { + if ($null -ne $wrt) { + $wrt.Dispose() + } + } + } + + if ($OutFile) { $parent = Split-Path $OutFile if (!(Test-Path $parent)) { throw 'Parent folder for specified path does not exist' @@ -504,40 +651,164 @@ function ConvertTo-Yaml { if ((Test-Path $OutFile) -and !$Force) { throw 'Target file already exists. Use -Force to overwrite.' } + [System.IO.File]::WriteAllText($OutFile, $yaml) + return } + return $yaml + } +} - if ($PSCmdlet.ParameterSetName -eq 'NoOptions') { - $Options = 0 - if ($JsonCompatible) { - # No indent options :~( - $Options = [SerializationOptions]::JsonCompatible - } +<# +.SYNOPSIS + Sets a YAML comment for a property on an enhanced PSCustomObject. +.DESCRIPTION + Adds or updates a comment that will be written above the property when + converting back to YAML. The object must be created with ConvertFrom-Yaml -As [PSCustomObject]. +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to add a comment to. +.PARAMETER Comment + The comment text (without # prefix). +.EXAMPLE + $config = ConvertFrom-Yaml $yaml -As ([PSCustomObject]) + $config | Set-YamlPropertyComment -PropertyName 'Server' -Comment 'Production server address' +#> +function Set-YamlPropertyComment { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, + + [Parameter(Mandatory)] + [string]$PropertyName, + + [Parameter(Mandatory)] + [string]$Comment + ) + + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + $metadata.SetPropertyComment($PropertyName, $Comment) + } else { + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" } + } +} + +<# +.SYNOPSIS + Gets a YAML comment for a property on an enhanced PSCustomObject. +.DESCRIPTION + Retrieves the comment associated with a property that was preserved during + YAML parsing or set with Set-YamlPropertyComment. +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to get the comment for. +.EXAMPLE + $comment = Get-YamlPropertyComment -InputObject $config -PropertyName 'Server' +#> +function Get-YamlPropertyComment { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, + + [Parameter(Mandatory)] + [string]$PropertyName + ) - if ($OutFile) { - $wrt = New-Object 'System.IO.StreamWriter' $OutFile + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + return $metadata.GetPropertyComment($PropertyName) } else { - $wrt = New-Object 'System.IO.StringWriter' + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" + return $null } + } +} - try { - $serializer = Get-Serializer $Options - $serializer.Serialize($wrt, $norm) +<# +.SYNOPSIS + Sets the scalar style for a property on an enhanced PSCustomObject. +.DESCRIPTION + Controls how a property value will be formatted when converting to YAML + (e.g., plain, single-quoted, double-quoted, literal, folded). +.PARAMETER InputObject + The enhanced PSCustomObject with YAML metadata. +.PARAMETER PropertyName + The name of the property to set the style for. +.PARAMETER Style + The scalar style to use (Plain, SingleQuoted, DoubleQuoted, Literal, Folded). +.EXAMPLE + $config | Set-YamlPropertyScalarStyle -PropertyName 'Description' -Style Literal +#> +function Set-YamlPropertyScalarStyle { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject, - if ($OutFile) { - return - } else { - return $wrt.ToString() - } - } finally { - if ($null -ne $wrt) { - $wrt.Dispose() - } + [Parameter(Mandatory)] + [string]$PropertyName, + + [Parameter(Mandatory)] + [ValidateSet('Plain', 'SingleQuoted', 'DoubleQuoted', 'Literal', 'Folded')] + [string]$Style + ) + + process { + $metadata = $script:PSObjectMetadataExtensions::GetMetadata($InputObject) + if ($metadata) { + $scalarStyle = [YamlDotNet.Core.ScalarStyle]::$Style + $metadata.SetPropertyScalarStyle($PropertyName, $scalarStyle) + } else { + Write-Warning "Object does not have YAML metadata. Use ConvertFrom-Yaml with -As [PSCustomObject]" } } } +<# +.SYNOPSIS + Tests if a PSCustomObject has YAML metadata attached. +.DESCRIPTION + Returns $true if the object was created with ConvertFrom-Yaml -As [PSCustomObject] + and has metadata support, $false otherwise. +.PARAMETER InputObject + The PSCustomObject to test. +.EXAMPLE + if (Test-YamlMetadata $config) { + $config | Set-YamlPropertyComment -PropertyName 'Name' -Comment 'User name' + } +#> +function Test-YamlMetadata { + [CmdletBinding()] + param( + [Parameter(Mandatory, ValueFromPipeline)] + [PSCustomObject]$InputObject + ) + + process { + return $script:PSObjectMetadataExtensions::IsEnhancedPSCustomObject($InputObject) + } +} + +# Typed YAML Module already loaded at module initialization (see lines 107-123) + New-Alias -Name cfy -Value ConvertFrom-Yaml New-Alias -Name cty -Value ConvertTo-Yaml -Export-ModuleMember -Function ConvertFrom-Yaml, ConvertTo-Yaml -Alias cfy, cty +# Export only the public API +# Typed cmdlets (ConvertFrom-YamlTyped, ConvertTo-YamlTyped) are loaded but not exported. +# The manifest (.psd1) controls the final export list. +Export-ModuleMember -Function @( + 'ConvertFrom-Yaml', + 'ConvertTo-Yaml', + 'Set-YamlPropertyComment', + 'Get-YamlPropertyComment', + 'Set-YamlPropertyScalarStyle', + 'Test-YamlMetadata' +) -Alias @('cfy', 'cty') diff --git a/powershell-yaml.sln b/powershell-yaml.sln index f2d0133..4601421 100644 --- a/powershell-yaml.sln +++ b/powershell-yaml.sln @@ -2,7 +2,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerShellYamlSerializer", "src\PowerShellYamlSerializer.csproj", "{661F072A-C59E-7ABB-EB12-C69FF3B9328F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellYaml", "src\PowerShellYaml\PowerShellYaml.csproj", "{661F072A-C59E-7ABB-EB12-C69FF3B9328F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PowerShellYaml.Module", "src\PowerShellYaml.Module\PowerShellYaml.Module.csproj", "{8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -14,6 +16,10 @@ Global {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Debug|Any CPU.Build.0 = Debug|Any CPU {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Release|Any CPU.ActiveCfg = Release|Any CPU {661F072A-C59E-7ABB-EB12-C69FF3B9328F}.Release|Any CPU.Build.0 = Release|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B2C1D5E-3F4A-4B9D-8C6E-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/PowerShellYaml.Module/LegacySerializers.cs b/src/PowerShellYaml.Module/LegacySerializers.cs new file mode 100644 index 0000000..0a92170 --- /dev/null +++ b/src/PowerShellYaml.Module/LegacySerializers.cs @@ -0,0 +1,1129 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +#nullable disable + +namespace PowerShellYaml.Module; + +using System; +using System.IO; +using System.Numerics; +using System.Text.RegularExpressions; +using System.Collections; +using System.Management.Automation; +using System.Collections.Generic; +using YamlDotNet.Core; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.EventEmitters; +using YamlDotNet.Core.Events; +using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.Serialization.ObjectGraphVisitors; +using YamlDotNet.Serialization.ObjectGraphTraversalStrategies; +using YamlDotNet.Serialization.ObjectFactories; +using YamlDotNet.RepresentationModel; + + +/// +/// Shared depth tracker for type converters +/// Thread-static to ensure thread safety +/// +internal static class SharedDepthTracker { + [ThreadStatic] + private static int currentDepth; + + public static int CurrentDepth => currentDepth; + + public static void Increment() => currentDepth++; + public static void Decrement() => currentDepth--; +} + +internal static class PSObjectHelper { + /// + /// Unwraps a PSObject to its BaseObject if the BaseObject is not a PSCustomObject. + /// + /// The object to potentially unwrap + /// The type of the unwrapped object + /// The unwrapped object if it was a PSObject wrapping a non-PSCustomObject, otherwise the original object + public static object UnwrapIfNeeded(object obj, out Type unwrappedType) { + if (obj is PSObject psObj && psObj.BaseObject != null) { + var baseType = psObj.BaseObject.GetType(); + if (baseType != typeof(System.Management.Automation.PSCustomObject)) { + unwrappedType = baseType; + return psObj.BaseObject; + } + } + unwrappedType = obj?.GetType(); + return obj; + } +} + +public class BigIntegerTypeConverter : IYamlTypeConverter { + public bool Accepts(Type type) { + return typeof(BigInteger).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var value = parser.Consume().Value; + var bigNr = BigInteger.Parse(value); + return bigNr; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var bigNr = (BigInteger)value; + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigNr.ToString(), ScalarStyle.Plain, true, false)); + } +} + +public class IDictionaryTypeConverter : IYamlTypeConverter { + + private bool omitNullValues; + private bool useFlowStyle; + private readonly int maxDepth; + + public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false, int maxDepth = 100) { + this.omitNullValues = omitNullValues; + this.useFlowStyle = useFlowStyle; + this.maxDepth = maxDepth; + } + + public bool Accepts(Type type) { + return typeof(IDictionary).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { + var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; + return deserializedObject; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var hObj = (IDictionary)value; + var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; + + SharedDepthTracker.Increment(); + try { + // Check if we've exceeded the depth limit + if (SharedDepthTracker.CurrentDepth > maxDepth) { + // Emit empty object as we're too deep + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } + + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + + foreach (DictionaryEntry entry in hObj) { + if(entry.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(entry.Key, entry.Key.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + continue; + } + + serializer(entry.Key, entry.Key.GetType()); + + var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); + } + } finally { + SharedDepthTracker.Decrement(); + } + + emitter.Emit(new MappingEnd()); + } +} + +public class PSObjectTypeConverter : IYamlTypeConverter { + + private readonly bool omitNullValues; + private readonly bool useFlowStyle; + private readonly int maxDepth; + + public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false, int maxDepth = 100) { + this.omitNullValues = omitNullValues; + this.useFlowStyle = useFlowStyle; + this.maxDepth = maxDepth; + } + + public bool Accepts(Type type) { + return typeof(PSObject).IsAssignableFrom(type); + } + + public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) + { + // We don't really need to do any custom deserialization. + var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; + return deserializedObject; + } + + public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { + var psObj = (PSObject)value; + if (psObj.BaseObject != null && + !typeof(IDictionary).IsAssignableFrom(psObj.BaseObject.GetType()) && + !typeof(PSCustomObject).IsAssignableFrom(psObj.BaseObject.GetType())) { + serializer(psObj.BaseObject, psObj.BaseObject.GetType()); + return; + } + var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; + + SharedDepthTracker.Increment(); + try { + // Check if we've exceeded the depth limit + if (SharedDepthTracker.CurrentDepth > maxDepth) { + // Emit empty object as we're too deep + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } + + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); + + foreach (var prop in psObj.Properties) { + if (prop.Value == null) { + if (this.omitNullValues) { + continue; + } + serializer(prop.Name, prop.Name.GetType()); + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + } else { + serializer(prop.Name, prop.Name.GetType()); + + var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); + serializer(unwrapped, unwrappedType); + } + } + + emitter.Emit(new MappingEnd()); + } finally { + SharedDepthTracker.Decrement(); + } + } +} + +public class StringQuotingEmitter: ChainedEventEmitter { + // Patterns from https://yaml.org/spec/1.2/spec.html#id2804356 + private static Regex quotedRegex = new Regex(@"^(\~|null|true|false|on|off|yes|no|y|n|[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?|[-+]?(\.inf))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public StringQuotingEmitter(IEventEmitter next): base(next) {} + + public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) { + var typeCode = eventInfo.Source.Value != null + ? Type.GetTypeCode(eventInfo.Source.Type) + : TypeCode.Empty; + + switch (typeCode) { + case TypeCode.Char: + if (Char.IsDigit((char)eventInfo.Source.Value)) { + eventInfo.Style = ScalarStyle.DoubleQuoted; + } + break; + case TypeCode.String: + var val = eventInfo.Source.Value.ToString(); + if (quotedRegex.IsMatch(val)) + { + eventInfo.Style = ScalarStyle.DoubleQuoted; + } else if (val.IndexOf('\n') > -1) { + eventInfo.Style = ScalarStyle.Literal; + } + break; + } + + base.Emit(eventInfo, emitter); + } +} + +public class FlowStyleAllEmitter(IEventEmitter next) : ChainedEventEmitter(next) { + public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter) { + eventInfo.Style = MappingStyle.Flow; + base.Emit(eventInfo, emitter); + } + + public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ + eventInfo.Style = SequenceStyle.Flow; + base.Emit(eventInfo, emitter); + } +} + +public class FlowStyleSequenceEmitter(IEventEmitter next) : ChainedEventEmitter(next) { + public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ + eventInfo.Style = SequenceStyle.Flow; + base.Emit(eventInfo, emitter); + } +} + +/// +/// Custom traversal strategy that limits recursion depth +/// Note: This only affects objects that don't have custom type converters. +/// Hashtables and PSCustomObjects use type converters that bypass this strategy. +/// +public class DepthLimitingTraversalStrategy( + ITypeInspector typeInspector, + ITypeResolver typeResolver, + int maxRecursion, + INamingConvention namingConvention, + IObjectFactory objectFactory) : FullObjectGraphTraversalStrategy(typeInspector, typeResolver, 1000, namingConvention, objectFactory) +{ + private readonly int _maxDepth = maxRecursion; + + protected override void Traverse( + IPropertyDescriptor propertyDescriptor, + object value, + IObjectDescriptor valueDescriptor, + IObjectGraphVisitor visitor, + TContext context, + Stack path, + ObjectSerializer serializer) + { + int maxDepth = _maxDepth; + if (maxDepth == 0) + { + maxDepth = 1; + } + + // Check if we should skip this property due to depth limit + // Use path.Count as fallback for .NET objects that don't go through type converters + // path.Count starts at 1 for root properties, so subtract 1 to get 0-based depth + int effectiveDepth = Math.Max(SharedDepthTracker.CurrentDepth, path.Count - 1); + + // Skip if we're beyond the depth limit + // Note: depth 0 = root object properties, depth 1 = nested properties, etc. + if(effectiveDepth > maxDepth) + { + // Return here and do not traverse. Max depth reached. + return; + } + + // Call base implementation to do the actual traversal + base.Traverse(propertyDescriptor, value, valueDescriptor, visitor, context, path, serializer); + } +} + +public class BuilderUtils { + public static SerializerBuilder BuildSerializer( + SerializerBuilder builder, + bool omitNullValues = false, + bool useFlowStyle = false, + bool useSequenceFlowStyle = false, + bool useBlockStyle = false, + bool useSequenceBlockStyle = false, + bool jsonCompatible = false, + int maxDepth = 100) { + + if (jsonCompatible) { + useFlowStyle = true; + useSequenceFlowStyle = true; + } + + // Block style takes precedence over flow style if both are set + if (useBlockStyle) { + useFlowStyle = false; + } + if (useSequenceBlockStyle) { + useSequenceFlowStyle = false; + } + + // Use custom traversal strategy for depth limiting + // Note: This only affects objects without custom type converters + builder = builder.WithObjectGraphTraversalStrategyFactory((typeInspector, typeResolver, typeConverters, maximumRecursion) => + new DepthLimitingTraversalStrategy( + typeInspector, + typeResolver, + maxDepth, + NullNamingConvention.Instance, + new DefaultObjectFactory() + ) + ); + + builder = builder + .WithEventEmitter(next => new StringQuotingEmitter(next)) + .WithTypeConverter(new BigIntegerTypeConverter()) + .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle, maxDepth)) + .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle, maxDepth)); + + if (useFlowStyle) { + builder = builder.WithEventEmitter(next => new FlowStyleAllEmitter(next)); + } + if (useSequenceFlowStyle) { + builder = builder.WithEventEmitter(next => new FlowStyleSequenceEmitter(next)); + } + + return builder; + } +} + +/// +/// Metadata storage for individual YAML properties +/// +public class YamlPropertyMetadata { + public string Comment { get; set; } + public string Tag { get; set; } + public string Anchor { get; set; } + public string Alias { get; set; } + public ScalarStyle? ScalarStyle { get; set; } + public MappingStyle? MappingStyle { get; set; } + public SequenceStyle? SequenceStyle { get; set; } +} + +/// +/// Metadata store for an object/document +/// +public class YamlMetadataStore { + private readonly Dictionary _propertyMetadata = new Dictionary(); + private readonly Dictionary _nestedObjectMetadata = new Dictionary(); + + public string DocumentComment { get; set; } + public MappingStyle? DocumentMappingStyle { get; set; } + + public void SetPropertyComment(string propertyName, string comment) { + GetOrCreatePropertyMetadata(propertyName).Comment = comment; + } + + public string GetPropertyComment(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.Comment + : null; + } + + public void SetPropertyStyle(string propertyName, MappingStyle style) { + GetOrCreatePropertyMetadata(propertyName).MappingStyle = style; + } + + public MappingStyle? GetPropertyStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.MappingStyle + : null; + } + + public void SetPropertyScalarStyle(string propertyName, ScalarStyle style) { + GetOrCreatePropertyMetadata(propertyName).ScalarStyle = style; + } + + public ScalarStyle? GetPropertyScalarStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.ScalarStyle + : null; + } + + public void SetPropertyMappingStyle(string propertyName, MappingStyle style) { + GetOrCreatePropertyMetadata(propertyName).MappingStyle = style; + } + + public MappingStyle? GetPropertyMappingStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.MappingStyle + : null; + } + + public void SetPropertySequenceStyle(string propertyName, SequenceStyle style) { + GetOrCreatePropertyMetadata(propertyName).SequenceStyle = style; + } + + public SequenceStyle? GetPropertySequenceStyle(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.SequenceStyle + : null; + } + + public void SetPropertyTag(string propertyName, string tag) { + GetOrCreatePropertyMetadata(propertyName).Tag = tag; + } + + public string GetPropertyTag(string propertyName) { + return _propertyMetadata.TryGetValue(propertyName, out var metadata) + ? metadata.Tag + : null; + } + + public YamlMetadataStore GetNestedMetadata(string propertyName) { + if (!_nestedObjectMetadata.TryGetValue(propertyName, out var metadata)) { + metadata = new YamlMetadataStore(); + _nestedObjectMetadata[propertyName] = metadata; + } + return metadata; + } + + private YamlPropertyMetadata GetOrCreatePropertyMetadata(string propertyName) { + if (!_propertyMetadata.TryGetValue(propertyName, out var metadata)) { + metadata = new YamlPropertyMetadata(); + _propertyMetadata[propertyName] = metadata; + } + return metadata; + } +} + +/// +/// Parser that preserves YAML metadata (comments, tags, styles) while parsing +/// Uses low-level Parser API with Scanner(skipComments: false) to capture comment tokens +/// +public static class YamlDocumentParser { + public static (object data, YamlMetadataStore metadata) ParseWithMetadata(string yaml, bool allowDuplicateKeys = false) { + if (string.IsNullOrEmpty(yaml)) { + return (null, null); + } + + var stringReader = new StringReader(yaml); + // Use Scanner with skipComments=false to enable comment parsing + var scanner = new Scanner(stringReader, skipComments: false); + var parser = new Parser(scanner); + var metadata = new YamlMetadataStore(); + + // Consume stream start + parser.Consume(); + + // Check for document start + if (!parser.Accept(out var _)) { + return (null, null); + } + + parser.Consume(); + + // Check if document has content + if (parser.Accept(out var _)) { + parser.Consume(); + parser.Consume(); + return (null, null); + } + + // Parse the root value + string pendingComment = null; + + // Capture the root document mapping style if it's a mapping + if (parser.Accept(out var rootMapping)) { + metadata.DocumentMappingStyle = rootMapping.Style; + } + + var data = ParseValue(parser, metadata, "", ref pendingComment, allowDuplicateKeys); + + // Consume document end and stream end + parser.Consume(); + parser.Consume(); + + return (data, metadata); + } + + private static object ParseValue(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + // Capture any comments before the value + while (parser.Accept(out var commentEvent)) { + parser.Consume(); + // Store the comment - it will be associated with the next key/value + pendingComment = commentEvent.Value; + } + + if (parser.Accept(out var _)) { + return ParseScalar(parser.Consume()); + } + else if (parser.Accept(out var _)) { + return ParseMapping(parser, metadata, path, ref pendingComment, allowDuplicateKeys); + } + else if (parser.Accept(out var _)) { + return ParseSequence(parser, metadata, path, ref pendingComment, allowDuplicateKeys); + } + else if (parser.Accept(out var _)) { + // Handle alias - for now just consume it + // TODO: Implement anchor/alias preservation in a future version + parser.Consume(); + return null; + } + + return null; + } + + private static object ParseMapping(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + parser.Consume(); + var dict = new Dictionary(); + // Track keys case-insensitively to detect duplicates + var seenKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + // Use any pending comment from before MappingStart + string pendingBlockComment = pendingComment; + pendingComment = null; + + while (!parser.Accept(out var _)) { + // Use pending block comment from previous iteration if available + string blockComment = pendingBlockComment; + pendingBlockComment = null; + + // Capture any additional block comments before this key + while (parser.Accept(out var _)) { + var commentEvent = parser.Consume(); + if (!commentEvent.IsInline && blockComment == null) { + blockComment = commentEvent.Value; + } + } + + // Parse key + if (!parser.Accept(out var _)) { + // No more keys, break + break; + } + + var keyScalar = parser.Consume(); + var key = keyScalar.Value; + + // Check for duplicate keys (case-insensitive) + bool isDuplicate = !seenKeys.Add(key); + if (isDuplicate && !allowDuplicateKeys) { + throw new InvalidOperationException( + $"Duplicate key '{key}' found in YAML mapping at path '{path}'. " + + "YAML keys are case-insensitive and duplicates are not allowed to prevent data loss. " + + "For typed objects, use [YamlKey] attribute to map different case variations to separate properties."); + } + + // Peek at the value to get tag and style before consuming + string valueTag = null; + ScalarStyle? scalarStyle = null; + MappingStyle? mappingStyle = null; + SequenceStyle? sequenceStyle = null; + + if (parser.Accept(out var scalarPeek)) { + if (!scalarPeek.Tag.IsEmpty) { + valueTag = scalarPeek.Tag.Value; + } + scalarStyle = scalarPeek.Style; + } + else if (parser.Accept(out var mappingPeek)) { + mappingStyle = mappingPeek.Style; + if (!mappingPeek.Tag.IsEmpty) { + valueTag = mappingPeek.Tag.Value; + } + } + else if (parser.Accept(out var sequencePeek)) { + sequenceStyle = sequencePeek.Style; + if (!sequencePeek.Tag.IsEmpty) { + valueTag = sequencePeek.Tag.Value; + } + } + + // Parse value recursively + var childPath = string.IsNullOrEmpty(path) ? key : $"{path}.{key}"; + var childMetadata = metadata.GetNestedMetadata(key); + string childComment = null; + var value = ParseValue(parser, childMetadata, childPath, ref childComment, allowDuplicateKeys); + + // If child returned a pending comment and we don't have one yet, use it for next sibling + if (!string.IsNullOrEmpty(childComment) && string.IsNullOrEmpty(pendingBlockComment)) { + pendingBlockComment = childComment; + } + + // Store tag if present + if (!string.IsNullOrEmpty(valueTag)) { + metadata.SetPropertyTag(key, valueTag); + } + + // Store styles if present + if (scalarStyle.HasValue) { + metadata.SetPropertyScalarStyle(key, scalarStyle.Value); + } + if (mappingStyle.HasValue) { + metadata.SetPropertyMappingStyle(key, mappingStyle.Value); + } + if (sequenceStyle.HasValue) { + metadata.SetPropertySequenceStyle(key, sequenceStyle.Value); + } + + // Capture comments after value + // Inline comments (IsInline=true) belong to current key + // Block comments (IsInline=false) belong to next key + string inlineComment = null; + while (parser.Accept(out var _)) { + var commentEvent = parser.Consume(); + if (commentEvent.IsInline) { + inlineComment = commentEvent.Value; + } else { + // This block comment belongs to the next key + pendingBlockComment = commentEvent.Value; + } + } + + // Store comment - prefer inline comment over block comment + if (!string.IsNullOrEmpty(inlineComment)) { + metadata.SetPropertyComment(key, inlineComment.Trim()); + } else if (!string.IsNullOrEmpty(blockComment)) { + metadata.SetPropertyComment(key, blockComment.Trim()); + } + + dict[key] = value; + } + + parser.Consume(); + + // Pass any pending block comment up to parent level + pendingComment = pendingBlockComment; + + return dict; + } + + private static object ParseSequence(IParser parser, YamlMetadataStore metadata, string path, ref string pendingComment, bool allowDuplicateKeys) { + parser.Consume(); + var list = new List(); + int index = 0; + + while (!parser.Accept(out var _)) { + // Capture comment before sequence item + string itemComment = null; + while (parser.Accept(out var commentEvent)) { + parser.Consume(); + itemComment = commentEvent.Value; + } + + var childPath = $"{path}[{index}]"; + var childMetadata = metadata.GetNestedMetadata($"[{index}]"); + var value = ParseValue(parser, childMetadata, childPath, ref itemComment, allowDuplicateKeys); + + list.Add(value); + index++; + } + + parser.Consume(); + + // Note: sequences don't have pending comments to pass up + // (comments are associated with items, not with the sequence itself) + + return list; + } + + private static object ParseScalar(Scalar scalar) { + // Use existing type conversion logic + var value = scalar.Value; + var tag = scalar.Tag; + var style = scalar.Style; + + // Check for null values first (only for plain style) + if (style == ScalarStyle.Plain && (value == "" || value == "~" || value == "null" || value == "Null" || value == "NULL")) { + return null; + } + + // Handle YAML tags for explicit type conversion (tags override everything) + if (!tag.IsEmpty) { + var tagValue = tag.Value; + + switch (tagValue) { + case "tag:yaml.org,2002:int": + // Parse as BigInteger first (handles all integer sizes) + // Use Float | Integer NumberStyles to match legacy behavior + if (System.Numerics.BigInteger.TryParse(value, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var bigIntValue)) { + // Try to fit into smaller int types + if (bigIntValue >= int.MinValue && bigIntValue <= int.MaxValue) { + return (int)bigIntValue; + } + if (bigIntValue >= long.MinValue && bigIntValue <= long.MaxValue) { + return (long)bigIntValue; + } + return bigIntValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as an integer (tag: {tagValue})"); + } + case "tag:yaml.org,2002:float": + // Parse as decimal (preferred over double for precision) + if (decimal.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var decimalValue)) { + return decimalValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a float (tag: {tagValue})"); + } + case "tag:yaml.org,2002:timestamp": + // Parse as DateTime + if (DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var dateTimeValue)) { + return dateTimeValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a timestamp (tag: {tagValue})"); + } + case "tag:yaml.org,2002:bool": + if (bool.TryParse(value, out var boolValue)) { + return boolValue; + } else { + throw new FormatException($"Value '{value}' cannot be parsed as a boolean (tag: {tagValue})"); + } + case "tag:yaml.org,2002:str": + return value; + case "tag:yaml.org,2002:null": + return null; + case "!": + return value; + } + } + + // No tag - check if quoted (quoted scalars are always strings) + if (style == ScalarStyle.SingleQuoted || style == ScalarStyle.DoubleQuoted || + style == ScalarStyle.Literal || style == ScalarStyle.Folded) { + return value; + } + + // Try to parse as boolean + if (bool.TryParse(value, out var inferredBool)) { + return inferredBool; + } + + // Try to parse as integer (check for very large numbers) + // Use Float | Integer NumberStyles to match legacy behavior + if (System.Numerics.BigInteger.TryParse(value, System.Globalization.NumberStyles.Float | System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var inferredBigInt)) { + // Try to fit into smaller int types + if (inferredBigInt >= int.MinValue && inferredBigInt <= int.MaxValue) { + return (int)inferredBigInt; + } + if (inferredBigInt >= long.MinValue && inferredBigInt <= long.MaxValue) { + return (long)inferredBigInt; + } + return inferredBigInt; + } + + // Try to parse as decimal + if (decimal.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var inferredDecimal)) { + return inferredDecimal; + } + + // Try to parse as DateTime (ISO8601 formats) + if (DateTime.TryParse(value, null, System.Globalization.DateTimeStyles.RoundtripKind, out var inferredDateTime)) { + // Only treat as DateTime if it looks like ISO8601 + if (value.Contains("T") || value.Contains("-")) { + return inferredDateTime; + } + } + + // Return as string + return value; + } +} + +/// +/// Extension methods for creating and managing PSCustomObjects with YAML metadata +/// +public static class PSObjectMetadataExtensions { + private const string MetadataPropertyName = "__psyaml_metadata"; + private const string TypeMarker = "PSYaml.EnhancedPSCustomObject"; + + /// + /// Creates an enhanced PSCustomObject from dictionary data and metadata + /// + public static PSObject CreateEnhancedPSCustomObject(IDictionary data, YamlMetadataStore metadata) { + var pso = new PSObject(); + + // Add all properties from dictionary + foreach (DictionaryEntry entry in data) { + var value = entry.Value; + var key = entry.Key.ToString(); + + // Recursively enhance nested objects + if (value is IDictionary nestedDict) { + var nestedMetadata = metadata.GetNestedMetadata(key); + value = CreateEnhancedPSCustomObject(nestedDict, nestedMetadata); + } + else if (value is IList nestedList) { + // Enhance array elements if needed + value = EnhanceList(nestedList, metadata.GetNestedMetadata(key)); + } + + pso.Properties.Add(new PSNoteProperty(key, value)); + } + + // Attach metadata store as hidden property + var metaProp = new PSNoteProperty(MetadataPropertyName, metadata); + pso.Properties.Add(metaProp); + + // Add type marker for identification + pso.TypeNames.Insert(0, TypeMarker); + + return pso; + } + + /// + /// Enhances list items by recursively converting dictionaries to enhanced PSCustomObjects + /// + private static IList EnhanceList(IList list, YamlMetadataStore metadata) { + var enhanced = new List(); + for (int i = 0; i < list.Count; i++) { + var item = list[i]; + if (item is IDictionary dict) { + enhanced.Add(CreateEnhancedPSCustomObject(dict, metadata.GetNestedMetadata($"[{i}]"))); + } else { + enhanced.Add(item); + } + } + return enhanced; + } + + /// + /// Checks if a PSObject is an enhanced PSCustomObject with YAML metadata + /// + public static bool IsEnhancedPSCustomObject(PSObject obj) { + return obj != null && obj.TypeNames.Contains(TypeMarker); + } + + /// + /// Retrieves the YAML metadata from an enhanced PSCustomObject + /// + public static YamlMetadataStore GetMetadata(PSObject obj) { + if (obj == null) { + return null; + } + var metaProp = obj.Properties[MetadataPropertyName]; + return metaProp?.Value as YamlMetadataStore; + } +} + +/// +/// Serializes enhanced PSCustomObjects with metadata (comments, styles) to YAML +/// +public static class MetadataAwareSerializer { + /// + /// Checks if a YAML tag should be emitted for the current value + /// + private static bool ShouldEmitTag(string tag, Type type) { + if (string.IsNullOrEmpty(tag)) { + return false; + } + + // The non-specific tag "!" should always be emitted to prevent type inference + if (tag == "!") { + return true; + } + + // Custom tags (starting with "!") should always be emitted for round-trip preservation + if (tag.StartsWith("!") && tag.Length > 1) { + return true; + } + + if (type == null) { + return false; + } + + // Map standard YAML tags to .NET types - only emit if they match + return tag switch + { + "tag:yaml.org,2002:str" => type == typeof(string), + "tag:yaml.org,2002:int" => type == typeof(int) || type == typeof(long) || type == typeof(BigInteger), + "tag:yaml.org,2002:float" => type == typeof(float) || type == typeof(double) || type == typeof(decimal), + "tag:yaml.org,2002:bool" => type == typeof(bool), + "tag:yaml.org,2002:null" => type == null, + "tag:yaml.org,2002:timestamp" => type == typeof(DateTime), + _ => false,// Unknown standard tag - don't emit + }; + } + + private static string GetTagFromType(object value) { + return value switch + { + int => "tag:yaml.org,2002:int", + long => "tag:yaml.org,2002:int", + System.Numerics.BigInteger => "tag:yaml.org,2002:int", + float => "tag:yaml.org,2002:float", + double => "tag:yaml.org,2002:float", + decimal => "tag:yaml.org,2002:float", + bool => "tag:yaml.org,2002:bool", + string => "tag:yaml.org,2002:str", + DateTime => "tag:yaml.org,2002:timestamp", + _ => string.Empty + }; + } + + public static string Serialize(PSObject obj, bool indentedSequences = false, bool emitTags = false, int maxDepth = 100, bool useFlowStyle = false, bool useBlockStyle = false) { + var metadata = PSObjectMetadataExtensions.GetMetadata(obj); + if (metadata == null) { + throw new InvalidOperationException("Object does not have YAML metadata"); + } + + var stringWriter = new StringWriter(); + + // Create emitter with or without indented sequences + IEmitter emitter; + if (indentedSequences) { + // Use EmitterSettings to enable indented sequences + var emitterSettings = new EmitterSettings( + bestIndent: 2, + bestWidth: int.MaxValue, + isCanonical: false, + maxSimpleKeyLength: 1024, + skipAnchorName: false, + indentSequences: true + ); + emitter = new Emitter(stringWriter, emitterSettings); + } else { + emitter = new Emitter(stringWriter); + } + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + + // Determine mapping style: explicit options override metadata + MappingStyle mappingStyle; + if (useFlowStyle) { + mappingStyle = MappingStyle.Flow; + } else if (useBlockStyle) { + mappingStyle = MappingStyle.Block; + } else { + // Use the original mapping style from metadata, or default to Block + mappingStyle = metadata.DocumentMappingStyle ?? MappingStyle.Block; + } + + SerializePSObject(obj, metadata, emitter, mappingStyle, emitTags, 0, maxDepth); + + emitter.Emit(new DocumentEnd(true)); + emitter.Emit(new StreamEnd()); + + return stringWriter.ToString(); + } + + private static void SerializePSObject(PSObject obj, YamlMetadataStore metadata, IEmitter emitter, MappingStyle style = MappingStyle.Block, bool emitTags = false, int currentDepth = 0, int maxDepth = 100) { + if (currentDepth >= maxDepth) { + // Emit empty object as default value for the truncated type + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, MappingStyle.Flow)); + emitter.Emit(new MappingEnd()); + return; + } + emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, style)); + + foreach (var prop in obj.Properties) { + // Skip internal metadata property + if (prop.Name == "__psyaml_metadata") continue; + + // Emit comment if present + var comment = metadata.GetPropertyComment(prop.Name); + if (!string.IsNullOrEmpty(comment)) { + emitter.Emit(new Comment(comment, false)); + } + + // Emit key + emitter.Emit(new Scalar(prop.Name)); + + // Get stored tag and scalar style for this property + var storedTag = metadata.GetPropertyTag(prop.Name); + var scalarStyle = metadata.GetPropertyScalarStyle(prop.Name) ?? ScalarStyle.Any; + + // Emit value + if (prop.Value == null) { + emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); + } + else if (prop.Value is PSObject nestedPSObj && PSObjectMetadataExtensions.IsEnhancedPSCustomObject(nestedPSObj)) { + // Recursively serialize nested enhanced objects + var nestedMetadata = metadata.GetNestedMetadata(prop.Name); + var nestedMappingStyle = metadata.GetPropertyMappingStyle(prop.Name) ?? MappingStyle.Block; + SerializePSObject(nestedPSObj, nestedMetadata, emitter, nestedMappingStyle, emitTags, currentDepth + 1, maxDepth); + } + else if (prop.Value is IList list) { + var sequenceStyle = metadata.GetPropertySequenceStyle(prop.Name) ?? SequenceStyle.Block; + SerializeList(list, metadata.GetNestedMetadata(prop.Name), emitter, sequenceStyle, emitTags, currentDepth + 1, maxDepth); + } + else if (prop.Value is bool boolValue) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(bool))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; // Implicit if no tag is being emitted + emitter.Emit(new Scalar(AnchorName.Empty, tag, boolValue ? "true" : "false", scalarStyle, isImplicit, false)); + } + else if (prop.Value is System.Numerics.BigInteger bigInt) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(BigInteger))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; + emitter.Emit(new Scalar(AnchorName.Empty, tag, bigInt.ToString(), scalarStyle, isImplicit, false)); + } + else if (prop.Value is DateTime dateTime) { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, typeof(DateTime))); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; + emitter.Emit(new Scalar(AnchorName.Empty, tag, dateTime.ToString("o"), scalarStyle, isImplicit, false)); + } + else { + // If emitTags is enabled and no stored tag, infer tag from type + var tagToUse = storedTag; + if (emitTags && string.IsNullOrEmpty(storedTag)) { + tagToUse = GetTagFromType(prop.Value); + } + var valueType = prop.Value.GetType(); + var shouldEmitTag = !string.IsNullOrEmpty(tagToUse) && (emitTags || ShouldEmitTag(tagToUse, valueType)); + var tag = shouldEmitTag ? tagToUse : TagName.Empty; + var isImplicit = !shouldEmitTag; // Implicit if no tag is being emitted + emitter.Emit(new Scalar(AnchorName.Empty, tag, prop.Value.ToString(), scalarStyle, isImplicit, false)); + } + } + + emitter.Emit(new MappingEnd()); + } + + private static void SerializeList(IList list, YamlMetadataStore metadata, IEmitter emitter, SequenceStyle style = SequenceStyle.Block, bool emitTags = false, int currentDepth = 0, int maxDepth = 100) { + if (currentDepth >= maxDepth) { + // Emit empty array as default value for the truncated type + emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, true, SequenceStyle.Flow)); + emitter.Emit(new SequenceEnd()); + return; + } + emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, true, style)); + + for (int i = 0; i < list.Count; i++) { + var item = list[i]; + + if (item == null) { + emitter.Emit(new Scalar("null")); + } + else if (item is PSObject nestedPSObj && PSObjectMetadataExtensions.IsEnhancedPSCustomObject(nestedPSObj)) { + var itemMetadata = metadata.GetNestedMetadata($"[{i}]"); + SerializePSObject(nestedPSObj, itemMetadata, emitter, MappingStyle.Block, emitTags, currentDepth + 1, maxDepth); + } + else if (item is bool boolValue) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, boolValue ? "true" : "false", ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(boolValue ? "true" : "false")); + } + } + else if (item is System.Numerics.BigInteger bigInt) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, bigInt.ToString(), ScalarStyle.Plain, false, false)); + } else { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigInt.ToString(), ScalarStyle.Plain, true, false)); + } + } + else if (item is DateTime dateTime) { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + emitter.Emit(new Scalar(AnchorName.Empty, tag, dateTime.ToString("o"), ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, dateTime.ToString("o"), ScalarStyle.Any, true, false)); + } + } + else { + // If emitTags is enabled, infer tag from type + if (emitTags) { + var tag = GetTagFromType(item); + if (!string.IsNullOrEmpty(tag)) { + emitter.Emit(new Scalar(AnchorName.Empty, tag, item.ToString(), ScalarStyle.Any, false, false)); + } else { + emitter.Emit(new Scalar(item.ToString())); + } + } else { + emitter.Emit(new Scalar(item.ToString())); + } + } + } + + emitter.Emit(new SequenceEnd()); + } +} diff --git a/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj b/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj new file mode 100644 index 0000000..74d5b27 --- /dev/null +++ b/src/PowerShellYaml.Module/PowerShellYaml.Module.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + PowerShellYaml.Module + latest + enable + + + + + + + + + + + + + + + + + diff --git a/src/PowerShellYaml.Module/TypedYamlConverter.cs b/src/PowerShellYaml.Module/TypedYamlConverter.cs new file mode 100644 index 0000000..97715ae --- /dev/null +++ b/src/PowerShellYaml.Module/TypedYamlConverter.cs @@ -0,0 +1,656 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +namespace PowerShellYaml.Module; + +/// +/// Public API for typed YAML conversion. +/// Provides static methods for serializing and deserializing YamlBase objects. +/// +public static class TypedYamlConverter +{ + #region Deserialization (YAML to Object) + + /// + /// Deserialize YAML string to a typed object inheriting from YamlBase. + /// + public static object? FromYaml(string yaml, Type targetType) + { + // Validate that the type inherits from YamlBase + if (targetType is null || !typeof(YamlBase).IsAssignableFrom(targetType)) + { + throw new ArgumentException($"Type must inherit from PowerShellYaml.YamlBase", nameof(targetType)); + } + + // Parse YAML with metadata preservation (allow duplicate keys for typed mode) + var (data, metadata) = YamlDocumentParser.ParseWithMetadata(yaml, allowDuplicateKeys: true); + + // Convert to Dictionary for FromDictionary + var dict = ConvertToStringKeyDictionary(data); + + // Validate that case-insensitive duplicate YAML keys have explicit mappings in the type + ValidateDuplicateKeysHaveExplicitMappings(dict, targetType); + + // Create an instance of the user's PowerShell class + var instance = (YamlBase?)Activator.CreateInstance(targetType); + if (instance is null) + { + throw new InvalidOperationException($"Failed to create instance of type {targetType.FullName}"); + } + + // Copy top-level metadata BEFORE calling FromDictionary + // This ensures custom converters can access tags during deserialization + if (metadata != null) + { + CopyTopLevelMetadataToYamlBase(instance, metadata); + + // Copy document-level mapping style if present + if (metadata.DocumentMappingStyle.HasValue) + { + instance.SetDocumentMappingStyle(metadata.DocumentMappingStyle.Value.ToString()); + } + } + + // Populate the instance using the abstract method + instance.FromDictionary(dict); + + // Copy nested object metadata AFTER FromDictionary has created the nested objects + if (metadata != null) + { + CopyNestedMetadataToYamlBase(instance, metadata, ""); + } + + return instance; + } + + public static Dictionary ConvertToStringKeyDictionary(object? obj) + { + if (obj is null) + return new Dictionary(); + + if (obj is IDictionary dict) + { + var result = new Dictionary(); + foreach (DictionaryEntry entry in dict) + { + var key = entry.Key?.ToString() ?? string.Empty; + var value = ConvertValue(entry.Value); + result[key] = value; + } + return result; + } + + throw new InvalidOperationException("YAML root must be a mapping"); + } + + private static object? ConvertValue(object? value) + { + if (value is null) + return null; + + // Recursively convert nested dictionaries + if (value is IDictionary dict) + { + var result = new Dictionary(); + foreach (DictionaryEntry entry in dict) + { + var key = entry.Key?.ToString() ?? string.Empty; + result[key] = ConvertValue(entry.Value); + } + return result; + } + + // Convert lists + if (value is IList list) + { + var result = new List(); + foreach (var item in list) + { + result.Add(ConvertValue(item)); + } + return result; + } + + return value; + } + + /// + /// Copy top-level property metadata (tags, comments, styles) to YamlBase instance. + /// This must be called BEFORE FromDictionary so custom converters can access tags. + /// + public static void CopyTopLevelMetadataToYamlBase(YamlBase instance, YamlMetadataStore metadataStore) + { + var properties = instance.GetType().GetProperties(); + + foreach (var prop in properties) + { + var propName = prop.Name; + var yamlKey = GetYamlKeyForProperty(prop); + + // Copy all metadata for this property + var comment = metadataStore.GetPropertyComment(yamlKey); + if (!string.IsNullOrEmpty(comment)) + { + instance.SetPropertyComment(propName, comment); + } + + var scalarStyle = metadataStore.GetPropertyScalarStyle(yamlKey); + if (scalarStyle.HasValue) + { + instance.SetPropertyScalarStyle(propName, scalarStyle.Value.ToString()); + } + + var mappingStyle = metadataStore.GetPropertyMappingStyle(yamlKey); + if (mappingStyle.HasValue) + { + instance.SetPropertyMappingStyle(propName, mappingStyle.Value.ToString()); + } + + var sequenceStyle = metadataStore.GetPropertySequenceStyle(yamlKey); + if (sequenceStyle.HasValue) + { + instance.SetPropertySequenceStyle(propName, sequenceStyle.Value.ToString()); + } + + var tag = metadataStore.GetPropertyTag(yamlKey); + if (!string.IsNullOrEmpty(tag)) + { + instance.SetPropertyTag(propName, tag); + } + } + } + + /// + /// Copy nested object metadata to YamlBase instance. + /// This must be called AFTER FromDictionary so nested objects exist. + /// + public static void CopyNestedMetadataToYamlBase(YamlBase instance, YamlMetadataStore metadataStore, string pathPrefix) + { + var properties = instance.GetType().GetProperties(); + + foreach (var prop in properties) + { + var yamlKey = GetYamlKeyForProperty(prop); + var propValue = prop.GetValue(instance); + + // Handle nested YamlBase objects + if (propValue is YamlBase nestedYamlBase) + { + var nestedMetadata = metadataStore.GetNestedMetadata(yamlKey); + if (nestedMetadata != null) + { + // Copy top-level metadata for the nested object first + CopyTopLevelMetadataToYamlBase(nestedYamlBase, nestedMetadata); + // Then recursively copy its nested metadata + CopyNestedMetadataToYamlBase(nestedYamlBase, nestedMetadata, $"{pathPrefix}{yamlKey}."); + } + } + // Handle arrays of YamlBase objects + else if (propValue is Array array) + { + var arrayPropertyMetadata = metadataStore.GetNestedMetadata(yamlKey); + if (arrayPropertyMetadata != null) + { + for (int i = 0; i < array.Length; i++) + { + if (array.GetValue(i) is YamlBase arrayItem) + { + var itemMetadata = arrayPropertyMetadata.GetNestedMetadata($"[{i}]"); + if (itemMetadata != null) + { + CopyTopLevelMetadataToYamlBase(arrayItem, itemMetadata); + CopyNestedMetadataToYamlBase(arrayItem, itemMetadata, $"{pathPrefix}{yamlKey}[{i}]."); + } + } + } + } + } + } + } + + /// + /// Validate that case-insensitive duplicate YAML keys have explicit [YamlKey] mappings. + /// This prevents silent data loss when YAML contains keys like "test" and "Test". + /// + internal static void ValidateDuplicateKeysHaveExplicitMappings(Dictionary dict, Type targetType) + { + // First, check if the YAML dictionary has case-insensitive duplicate keys + var yamlKeysGrouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var yamlKey in dict.Keys) + { + if (!yamlKeysGrouped.ContainsKey(yamlKey)) + { + yamlKeysGrouped[yamlKey] = new List(); + } + yamlKeysGrouped[yamlKey].Add(yamlKey); + } + + // Find groups with multiple case variations + foreach (var kvp in yamlKeysGrouped) + { + if (kvp.Value.Count > 1) + { + // We have case-insensitive duplicates (e.g., "test" and "Test") + // Now verify that ALL of these variations are explicitly mapped using [YamlKey] + var properties = targetType.GetProperties(); + var explicitlyMapped = new HashSet(); + + foreach (var prop in properties) + { + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + // This property has an explicit YamlKey attribute + explicitlyMapped.Add(yamlKeyAttr.Key); + } + } + + // Check if all case variations are explicitly mapped + var unmappedKeys = new List(); + foreach (var yamlKey in kvp.Value) + { + if (!explicitlyMapped.Contains(yamlKey)) + { + unmappedKeys.Add(yamlKey); + } + } + + if (unmappedKeys.Count > 0) + { + throw new InvalidOperationException( + $"YAML contains case-insensitive duplicate keys: {string.Join(", ", kvp.Value)}. " + + $"To prevent data loss, all variations must be explicitly mapped using [YamlKey] attributes. " + + $"Unmapped keys: {string.Join(", ", unmappedKeys)}"); + } + } + } + } + + #endregion + + #region Serialization (Object to YAML) + + /// + /// Serialize a YamlBase object to YAML string. + /// + public static string ToYaml(YamlBase obj, bool omitNull = false, bool emitTags = false, bool useFlowStyle = false, bool useBlockStyle = false, bool useSequenceFlowStyle = false, bool useSequenceBlockStyle = false, bool indentedSequences = false, int maxDepth = 100) + { + if (obj is null) + { + throw new ArgumentNullException(nameof(obj)); + } + + // Convert boolean flags to nullable style overrides + MappingStyle? mappingStyleOverride = null; + if (useBlockStyle) + { + mappingStyleOverride = MappingStyle.Block; + } + else if (useFlowStyle) + { + mappingStyleOverride = MappingStyle.Flow; + } + + SequenceStyle? sequenceStyleOverride = null; + if (useSequenceBlockStyle) + { + sequenceStyleOverride = SequenceStyle.Block; + } + else if (useSequenceFlowStyle) + { + sequenceStyleOverride = SequenceStyle.Flow; + } + + return SerializeWithMetadata(obj, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, indentedSequences, maxDepth); + } + + public static string SerializeWithMetadata(YamlBase obj, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, bool indentedSequences = false, int maxDepth = 100) + { + var stringWriter = new StringWriter(); + + // Create emitter with or without indented sequences + IEmitter emitter; + if (indentedSequences) + { + // EmitterSettings constructor signature (from YamlDotNet): + // EmitterSettings(int bestIndent, int bestWidth, bool isCanonical, int maxSimpleKeyLength, bool skipAnchorName, bool indentSequences, string? newLine, bool forceIndentLess) + // Use default values for most parameters, only set indentSequences to true + var emitterSettings = new EmitterSettings( + bestIndent: 2, + bestWidth: int.MaxValue, + isCanonical: false, + maxSimpleKeyLength: 1024, + skipAnchorName: false, + indentSequences: true + ); + emitter = new Emitter(stringWriter, emitterSettings); + } + else + { + emitter = new Emitter(stringWriter); + } + + emitter.Emit(new StreamStart()); + emitter.Emit(new DocumentStart()); + + SerializeObject(obj, emitter, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, 0, maxDepth); + + emitter.Emit(new DocumentEnd(true)); // true = implicit (no "..." marker) + emitter.Emit(new StreamEnd()); + + return stringWriter.ToString(); + } + + private static void SerializeObject(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) + { + if (currentDepth >= maxDepth) + { + // Emit a placeholder string to indicate max depth reached + emitter.Emit(new Scalar("...")); + return; + } + + // Determine mapping style with precedence: + // 1. mappingStyleOverride (if set by user) + // 2. Object metadata (document-level mapping style) + // 3. Default to block + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else + { + // Check if the object has a document-level mapping style + var documentStyleStr = obj.GetDocumentMappingStyle(); + mappingStyle = documentStyleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + SerializeObjectWithStyle(obj, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride, currentDepth, maxDepth); + } + + private static void SerializeObjectWithStyle(YamlBase obj, IEmitter emitter, bool emitTags, bool omitNull, MappingStyle mappingStyle, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) + { + var dict = obj.ToDictionary(); + var metadata = obj.GetAllMetadata(); + var type = obj.GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + emitter.Emit(new MappingStart(null, null, false, mappingStyle)); + + // Iterate through properties to preserve order and get correct YAML keys + foreach (var prop in properties) + { + if (prop.Name == "EqualityContract") continue; + + var propName = prop.Name; + var yamlKey = GetYamlKeyForProperty(prop); + + // Get value from dictionary + if (!dict.TryGetValue(yamlKey, out var value)) + { + continue; + } + + // Skip null values if OmitNull is enabled + if (omitNull && value is null) + { + continue; + } + + // Emit comment if present + if (metadata.TryGetValue(propName, out var propMetadata)) + { + if (propMetadata.TryGetValue("comment", out var commentObj) && commentObj is string comment && !string.IsNullOrEmpty(comment)) + { + emitter.Emit(new Comment(comment, false)); + } + } + + // Emit key + emitter.Emit(new Scalar(yamlKey)); + + // Emit value with metadata + EmitValue(value, emitter, obj, propName, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth, maxDepth); + } + + emitter.Emit(new MappingEnd()); + } + + private static void EmitValue(object? value, IEmitter emitter, YamlBase? parentObj, string? propertyName, bool emitTags, bool omitNull, MappingStyle? mappingStyleOverride, SequenceStyle? sequenceStyleOverride, int currentDepth, int maxDepth) + { + if (value is null) + { + var nullTag = emitTags ? new TagName("tag:yaml.org,2002:null") : TagName.Empty; + emitter.Emit(new Scalar(AnchorName.Empty, nullTag, "null", ScalarStyle.Plain, !emitTags, false)); + return; + } + + // Check depth limit for nested structures + if (currentDepth >= maxDepth) + { + emitter.Emit(new Scalar("...")); + return; + } + + // Handle nested YamlBase objects + if (value is YamlBase nestedYaml) + { + // Determine mapping style with precedence: + // 1. mappingStyleOverride (if set by user) + // 2. Parent's property metadata (property-level mapping style) + // 3. Default to block + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertyMappingStyle(propertyName); + mappingStyle = styleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + else + { + mappingStyle = MappingStyle.Block; + } + + SerializeObjectWithStyle(nestedYaml, emitter, emitTags, omitNull, mappingStyle, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); + return; + } + + // Handle dictionaries (from nested objects) + if (value is Dictionary dict) + { + // Determine mapping style with same precedence as nested YamlBase + MappingStyle mappingStyle; + if (mappingStyleOverride.HasValue) + { + mappingStyle = mappingStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertyMappingStyle(propertyName); + mappingStyle = styleStr == "Flow" ? MappingStyle.Flow : MappingStyle.Block; + } + else + { + mappingStyle = MappingStyle.Block; + } + + emitter.Emit(new MappingStart(null, null, false, mappingStyle)); + foreach (var kvp in dict) + { + // Skip null values in dictionaries if OmitNull is enabled + if (omitNull && kvp.Value is null) + { + continue; + } + + emitter.Emit(new Scalar(kvp.Key)); + EmitValue(kvp.Value, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); + } + emitter.Emit(new MappingEnd()); + return; + } + + // Handle lists/arrays + if (value is IList list) + { + // Determine sequence style with precedence: + // 1. sequenceStyleOverride (if set by user) + // 2. Parent's property metadata (property-level sequence style) + // 3. Default to block + SequenceStyle seqStyle; + if (sequenceStyleOverride.HasValue) + { + seqStyle = sequenceStyleOverride.Value; + } + else if (parentObj != null && propertyName != null) + { + var styleStr = parentObj.GetPropertySequenceStyle(propertyName); + seqStyle = styleStr == "Flow" ? SequenceStyle.Flow : SequenceStyle.Block; + } + else + { + seqStyle = SequenceStyle.Block; + } + + emitter.Emit(new SequenceStart(null, null, false, seqStyle)); + foreach (var item in list) + { + // Note: We don't skip null items in arrays - preserve array structure + EmitValue(item, emitter, null, null, emitTags, omitNull, mappingStyleOverride, sequenceStyleOverride, currentDepth + 1, maxDepth); + } + emitter.Emit(new SequenceEnd()); + return; + } + + // Emit scalar value with style and tag from metadata + var scalarStyle = ScalarStyle.Any; + var tag = TagName.Empty; + + if (parentObj != null && propertyName != null) + { + // Get scalar style from metadata + var styleStr = parentObj.GetPropertyScalarStyle(propertyName); + if (!string.IsNullOrEmpty(styleStr)) + { + scalarStyle = styleStr switch + { + "DoubleQuoted" => ScalarStyle.DoubleQuoted, + "SingleQuoted" => ScalarStyle.SingleQuoted, + "Literal" => ScalarStyle.Literal, + "Folded" => ScalarStyle.Folded, + "Plain" => ScalarStyle.Plain, + _ => ScalarStyle.Any + }; + } + + // Get tag from metadata (e.g., tag:yaml.org,2002:int) + var tagStr = parentObj.GetPropertyTag(propertyName); + if (!string.IsNullOrEmpty(tagStr)) + { + tag = new TagName(tagStr!); // tagStr is guaranteed non-null by the check above + } + } + + // If emitTags is enabled and no tag from metadata, infer tag from .NET type + if (emitTags && tag.IsEmpty) + { + tag = GetTagFromType(value); + } + + var valueStr = value.ToString() ?? string.Empty; + + // If we have an explicit tag from metadata or emitTags, force it to be emitted + // by setting both implicit flags to false + var isPlainImplicit = tag.IsEmpty; + var isQuotedImplicit = tag.IsEmpty; + + emitter.Emit(new Scalar(AnchorName.Empty, tag, valueStr, scalarStyle, isPlainImplicit, isQuotedImplicit)); + } + + private static TagName GetTagFromType(object value) + { + return value switch + { + int => new TagName("tag:yaml.org,2002:int"), + long => new TagName("tag:yaml.org,2002:int"), + System.Numerics.BigInteger => new TagName("tag:yaml.org,2002:int"), + float => new TagName("tag:yaml.org,2002:float"), + double => new TagName("tag:yaml.org,2002:float"), + decimal => new TagName("tag:yaml.org,2002:float"), + bool => new TagName("tag:yaml.org,2002:bool"), + string => new TagName("tag:yaml.org,2002:str"), + DateTime => new TagName("tag:yaml.org,2002:timestamp"), + _ => TagName.Empty + }; + } + + #endregion + + #region Helper Methods + + /// + /// Get the YAML key for a property, checking for YamlKeyAttribute first. + /// + private static string GetYamlKeyForProperty(PropertyInfo prop) + { + // Check if property has YamlKeyAttribute + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + return yamlKeyAttr.Key; + } + + // Fall back to automatic conversion + return ConvertPropertyNameToYamlKey(prop.Name); + } + + private static string ConvertPropertyNameToYamlKey(string propertyName) + { + // Convert PascalCase to hyphenated-case + // Example: AppName -> app-name, DatabaseHost -> database-host + var result = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) + { + char c = propertyName[i]; + if (char.IsUpper(c)) + { + if (i > 0) + { + result.Append('-'); + } + result.Append(char.ToLower(c)); + } + else + { + result.Append(c); + } + } + return result.ToString(); + } + + #endregion +} diff --git a/src/PowerShellYaml/PowerShellYaml.csproj b/src/PowerShellYaml/PowerShellYaml.csproj new file mode 100644 index 0000000..7ce0a56 --- /dev/null +++ b/src/PowerShellYaml/PowerShellYaml.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + PowerShellYaml + latest + enable + + + + diff --git a/src/PowerShellYaml/YamlBase.cs b/src/PowerShellYaml/YamlBase.cs new file mode 100644 index 0000000..291b9d4 --- /dev/null +++ b/src/PowerShellYaml/YamlBase.cs @@ -0,0 +1,678 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace PowerShellYaml; + +/// +/// Interface for custom YAML type converters. +/// Implement this interface to provide custom serialization/deserialization for specific YAML tags and types. +/// PowerShell users can create classes with ConvertFromYaml and ConvertToYaml methods that will be called via reflection. +/// +public interface IYamlTypeConverter +{ + /// + /// Determines whether this converter can handle the given tag and target type. + /// + /// The YAML tag (e.g., "!timestamp", "tag:yaml.org,2002:int"). Null if no tag present. + /// The .NET type the value should be converted to. + /// True if this converter can handle the conversion, false otherwise. + bool CanHandle(string? tag, Type targetType); + + /// + /// Deserialize a YAML value to the target type. + /// + /// The raw value from YAML (string, Dictionary, List, etc.) + /// The YAML tag if present, null otherwise. + /// The .NET type to convert to. + /// The deserialized object. + object? Unmarshal(object? data, string? tag, Type targetType); + + /// + /// Serialize an object to YAML representation. + /// + /// The object to serialize. + /// Output parameter: the YAML tag to use (e.g., "!timestamp"). + /// The serialized representation (string, Dictionary, List, etc.). + object? Marshal(object? value, out string? tag); +} + +/// +/// Base class for PowerShell-based custom type converters. +/// Inherit from this class in PowerShell and override the abstract methods to create custom converters. +/// +/// +/// class SemVerConverter : YamlConverter { +/// [bool] CanHandle([string]$tag, [Type]$targetType) { +/// return ($tag -eq '!semver' -or $tag -eq $null) -and $targetType -eq [SemanticVersion] +/// } +/// +/// [object] ConvertFromYaml([object]$data, [string]$tag, [Type]$targetType) { +/// # Parse $data and return object +/// return [SemanticVersion]::new() +/// } +/// +/// [hashtable] ConvertToYaml([object]$value) { +/// return @{ Value = $value.ToString(); Tag = '!semver' } +/// } +/// } +/// +public abstract class YamlConverter : IYamlTypeConverter +{ + /// + /// Determines whether this converter can handle the given tag and target type. + /// Override this method to implement custom tag/type checking logic. + /// Default implementation returns true for all inputs. + /// + public virtual bool CanHandle(string? tag, Type targetType) + { + return true; + } + + /// + /// Deserialize a YAML value to the target type. + /// This is an abstract method that must be overridden in PowerShell. + /// + public abstract object? ConvertFromYaml(object? data, string? tag, Type targetType); + + /// + /// Serialize an object to YAML representation. + /// This is an abstract method that must be overridden in PowerShell. + /// Return a hashtable with 'Value' and 'Tag' keys, or just the value directly. + /// + public abstract object? ConvertToYaml(object? value); + + // IYamlTypeConverter interface implementation + object? IYamlTypeConverter.Unmarshal(object? data, string? tag, Type targetType) + { + return ConvertFromYaml(data, tag, targetType); + } + + object? IYamlTypeConverter.Marshal(object? value, out string? tag) + { + var result = ConvertToYaml(value); + + // Handle Dictionary + if (result is Dictionary dict) + { + tag = dict.TryGetValue("Tag", out var tagValue) ? tagValue as string : null; + return dict.TryGetValue("Value", out var valueResult) ? valueResult : null; + } + + // Handle PowerShell Hashtable + if (result is IDictionary hashtable) + { + tag = hashtable.Contains("Tag") ? hashtable["Tag"] as string : null; + return hashtable.Contains("Value") ? hashtable["Value"] : null; + } + + // Fallback: treat result as the value, no tag + tag = null; + return result; + } +} + +/// +/// Attribute to specify a custom YAML type converter for a property. +/// Use this to control how a property is serialized/deserialized to/from YAML. +/// +/// +/// class TimestampConverter : IYamlTypeConverter { +/// // Implementation... +/// } +/// +/// class MyClass : YamlBase { +/// [YamlConverter(typeof(TimestampConverter))] +/// [DateTime]$CreatedAt +/// } +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class YamlConverterAttribute : Attribute +{ + public Type? ConverterType { get; } + public string? ConverterTypeName { get; } + + // Constructor for C# usage with Type + public YamlConverterAttribute(Type converterType) + { + if (!typeof(IYamlTypeConverter).IsAssignableFrom(converterType)) + { + throw new ArgumentException( + $"Type '{converterType.FullName}' must implement IYamlTypeConverter interface.", + nameof(converterType)); + } + ConverterType = converterType; + } + + // Constructor for PowerShell usage with type name string (avoids ALC issues) + public YamlConverterAttribute(string converterTypeName) + { + ConverterTypeName = converterTypeName; + } +} + +/// +/// Attribute to specify the YAML key name for a property. +/// Use this when you need to map a YAML key to a property with a different name. +/// This is especially useful for case-sensitive YAML keys since PowerShell class properties are case-insensitive. +/// +/// +/// class MyClass : YamlBase { +/// [YamlKey("Test")] +/// [string]$CapitalizedTest +/// +/// [YamlKey("test")] +/// [int]$LowercaseTest +/// } +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public class YamlKeyAttribute(string key) : Attribute +{ + public string Key { get; } = key; +} + +/// +/// Base class for typed YAML objects that users can inherit from in PowerShell. +/// This class is loaded into the Default ALC, making it available for PowerShell class inheritance. +/// Includes metadata storage for YAML comments, styles, tags, etc. +/// +public abstract class YamlBase +{ + // Metadata storage using only ALC-safe types (Dictionary from mscorlib) + // Stores: property name -> metadata dictionary (e.g., "comment" -> "comment text") + private readonly Dictionary> _metadata = + new Dictionary>(); + + /// + /// Convert this object to a dictionary for YAML serialization. + /// Override this method to provide custom serialization logic. + /// Default implementation uses reflection to serialize all public properties. + /// + public virtual Dictionary ToDictionary() + { + var dict = new Dictionary(); + var type = GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Skip metadata property + if (prop.Name == "_metadata" || prop.Name == "EqualityContract") + continue; + + var value = prop.GetValue(this); + var key = GetYamlKeyForProperty(prop); + + // Check for custom converter attribute using GetCustomAttributesData to avoid ALC issues + var converterType = GetConverterTypeFromProperty(prop); + if (converterType != null && value != null) + { + try + { + var converter = (IYamlTypeConverter?)Activator.CreateInstance(converterType); + if (converter == null) + { + throw new InvalidOperationException( + $"Failed to create instance of converter type '{converterType.FullName}'"); + } + + // Use custom converter to marshal the value + string? tag; + var marshaledValue = converter.Marshal(value, out tag); + + // Store the tag in metadata if provided + if (!string.IsNullOrEmpty(tag)) + { + SetPropertyTag(prop.Name, tag); + } + + dict[key] = marshaledValue; + continue; + } + catch (Exception ex) when (!(ex is InvalidOperationException)) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' failed to serialize property '{prop.Name}' " + + $"with value '{value}'. See inner exception for details.", ex); + } + } + + // Keep YamlBase references for metadata preservation + // Arrays are kept as-is (they'll be handled by serializer) + dict[key] = value; + } + + return dict; + } + + /// + /// Populate this object from a dictionary after YAML deserialization. + /// Override this method to provide custom deserialization logic. + /// Default implementation uses reflection to populate all public properties. + /// + public virtual void FromDictionary(Dictionary data) + { + var type = GetType(); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Skip metadata property + if (prop.Name == "_metadata" || prop.Name == "EqualityContract") + continue; + + if (!prop.CanWrite) + continue; + + var yamlKey = GetYamlKeyForProperty(prop); + if (!data.ContainsKey(yamlKey)) + continue; + + var value = data[yamlKey]; + if (value == null) + { + prop.SetValue(this, null); + continue; + } + + // Check for custom converter attribute using GetCustomAttributesData to avoid ALC issues + var converterType = GetConverterTypeFromProperty(prop); + if (converterType != null) + { + try + { + var converter = (IYamlTypeConverter?)Activator.CreateInstance(converterType); + if (converter == null) + { + throw new InvalidOperationException( + $"Failed to create instance of converter type '{converterType.FullName}'"); + } + + // Get the tag from metadata if available + string? tag = null; + if (_metadata.TryGetValue(prop.Name, out var propMeta)) + { + if (propMeta.TryGetValue("tag", out var tagValue)) + { + tag = tagValue as string; + } + } + + // Check if converter can handle this + if (!converter.CanHandle(tag, prop.PropertyType)) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' registered for property '{prop.Name}' " + + $"cannot handle tag '{tag ?? "(none)"}' with target type '{prop.PropertyType.Name}'. " + + $"The converter's CanHandle() method returned false."); + } + + // Use custom converter + var convertedValue = converter.Unmarshal(value, tag, prop.PropertyType); + prop.SetValue(this, convertedValue); + continue; + } + catch (Exception ex) when (ex is not InvalidOperationException) + { + throw new InvalidOperationException( + $"Custom converter '{converterType.Name}' failed to deserialize property '{prop.Name}' " + + $"with value '{value}'. See inner exception for details.", ex); + } + } + + // Check if value is a nested object (dictionary) + if (value is Dictionary nestedDict) + { + // Handle nested YamlBase objects + if (typeof(YamlBase).IsAssignableFrom(prop.PropertyType)) + { + var nestedInstance = (YamlBase?)Activator.CreateInstance(prop.PropertyType); + if (nestedInstance != null) + { + nestedInstance.FromDictionary(nestedDict); + prop.SetValue(this, nestedInstance); + } + continue; + } + // Detect non-YamlBase class types that can't be deserialized + else if (prop.PropertyType.IsClass && + prop.PropertyType != typeof(string) && + !prop.PropertyType.IsPrimitive) + { + throw new InvalidOperationException( + $"Property '{prop.Name}' of type '{prop.PropertyType.Name}' in class '{type.Name}' must inherit from YamlBase " + + $"for nested object deserialization. Custom classes that don't inherit from YamlBase cannot be automatically deserialized from YAML. " + + $"Either make '{prop.PropertyType.Name}' inherit from YamlBase, or use a primitive type or PSCustomObject."); + } + } + + // Handle arrays + if (prop.PropertyType.IsArray && value is IList list) + { + var elementType = prop.PropertyType.GetElementType(); + if (elementType != null) + { + // Handle arrays of YamlBase objects + if (typeof(YamlBase).IsAssignableFrom(elementType)) + { + var array = Array.CreateInstance(elementType, list.Count); + for (int i = 0; i < list.Count; i++) + { + if (list[i] is Dictionary itemDict) + { + var item = (YamlBase?)Activator.CreateInstance(elementType); + if (item != null) + { + item.FromDictionary(itemDict); + array.SetValue(item, i); + } + } + } + prop.SetValue(this, array); + continue; + } + // Detect arrays of non-YamlBase class types + else if (elementType.IsClass && + elementType != typeof(string) && + !elementType.IsPrimitive && + list.Count > 0 && + list[0] is Dictionary) + { + throw new InvalidOperationException( + $"Property '{prop.Name}' is an array of type '{elementType.Name}[]' in class '{type.Name}', " + + $"but '{elementType.Name}' does not inherit from YamlBase. " + + $"Custom class arrays must use types that inherit from YamlBase for deserialization. " + + $"Either make '{elementType.Name}' inherit from YamlBase, or use a primitive type array."); + } + // Handle arrays of primitive types (string[], int[], etc.) + else + { + var array = Array.CreateInstance(elementType, list.Count); + for (int i = 0; i < list.Count; i++) + { + var item = list[i]; + if (item != null) + { + try + { + var convertedItem = Convert.ChangeType(item, elementType); + array.SetValue(convertedItem, i); + } + catch + { + array.SetValue(item, i); + } + } + } + prop.SetValue(this, array); + continue; + } + } + } + + // Handle direct value assignment with type conversion + try + { + var convertedValue = Convert.ChangeType(value, prop.PropertyType); + prop.SetValue(this, convertedValue); + } + catch + { + // If conversion fails, try direct assignment + prop.SetValue(this, value); + } + } + } + + /// + /// Get the converter type from a property's YamlConverterAttribute without instantiating the attribute. + /// Uses GetCustomAttributesData to avoid ALC issues with PowerShell classes. + /// + private static Type? GetConverterTypeFromProperty(PropertyInfo prop) + { + foreach (var attrData in prop.GetCustomAttributesData()) + { + if (attrData.AttributeType == typeof(YamlConverterAttribute)) + { + // Get the constructor argument + if (attrData.ConstructorArguments.Count > 0) + { + var arg = attrData.ConstructorArguments[0]; + + // If it's a Type, validate and return it + if (arg.Value is Type type) + { + ValidateConverterType(type); + return type; + } + + // If it's a string type name, resolve it from the declaring type's assembly + if (arg.Value is string typeName) + { + return ResolveConverterType(typeName, prop.DeclaringType); + } + } + } + } + return null; + } + + /// + /// Resolve a converter type by name, searching only in the declaring type's assembly. + /// This ensures that user-defined converters in the same script/module always take precedence. + /// + private static Type ResolveConverterType(string typeName, Type? declaringType) + { + if (declaringType?.Assembly == null) + { + throw new InvalidOperationException( + $"Cannot resolve converter type '{typeName}' - declaring type has no assembly"); + } + + // For PowerShell classes: all classes in the same script/module share the same + // dynamic "PowerShell Class Assembly", so this will find converters defined alongside the class + Type? resolvedType; + try + { + resolvedType = declaringType.Assembly.GetType(typeName); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Error resolving converter type '{typeName}' in assembly '{declaringType.Assembly.FullName}'", + ex); + } + + if (resolvedType == null) + { + throw new InvalidOperationException( + $"Converter type '{typeName}' not found. " + + $"Make sure it's defined in the same file/module as your class."); + } + + ValidateConverterType(resolvedType); + return resolvedType; + } + + /// + /// Validate that a type implements IYamlTypeConverter interface. + /// + private static void ValidateConverterType(Type type) + { + if (!typeof(IYamlTypeConverter).IsAssignableFrom(type)) + { + throw new ArgumentException( + $"Type '{type.FullName}' must implement IYamlTypeConverter interface", + nameof(type)); + } + } + + /// + /// Get the YAML key for a property, checking for YamlKeyAttribute first. + /// + private static string GetYamlKeyForProperty(PropertyInfo prop) + { + // Check if property has YamlKeyAttribute + var yamlKeyAttr = prop.GetCustomAttribute(); + if (yamlKeyAttr != null) + { + return yamlKeyAttr.Key; + } + + // Fall back to automatic conversion + return ConvertPropertyNameToYamlKey(prop.Name); + } + + private static string ConvertPropertyNameToYamlKey(string propertyName) + { + // Convert PascalCase to hyphenated-case + // Example: AppName -> app-name, DatabaseHost -> database-host + var result = new System.Text.StringBuilder(); + for (int i = 0; i < propertyName.Length; i++) + { + char c = propertyName[i]; + if (char.IsUpper(c)) + { + if (i > 0) + { + result.Append('-'); + } + result.Append(char.ToLower(c)); + } + else + { + result.Append(c); + } + } + return result.ToString(); + } + + // Metadata access methods - use string keys to avoid ALC type identity issues + + public void SetPropertyComment(string propertyName, string? comment) + { + GetOrCreateMetadata(propertyName)["comment"] = comment; + } + + public string? GetPropertyComment(string propertyName) + { + return GetMetadataValue(propertyName, "comment") as string; + } + + public void SetPropertyScalarStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["scalarStyle"] = style; + } + + public string? GetPropertyScalarStyle(string propertyName) + { + return GetMetadataValue(propertyName, "scalarStyle") as string; + } + + public void SetPropertyMappingStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["mappingStyle"] = style; + } + + public string? GetPropertyMappingStyle(string propertyName) + { + return GetMetadataValue(propertyName, "mappingStyle") as string; + } + + /// + /// Set the mapping style for this document/object itself (not a property). + /// Used to preserve flow vs block style at the root level. + /// + public void SetDocumentMappingStyle(string? style) + { + GetOrCreateMetadata("")["mappingStyle"] = style; + } + + /// + /// Get the mapping style for this document/object itself (not a property). + /// + public string? GetDocumentMappingStyle() + { + return GetMetadataValue("", "mappingStyle") as string; + } + + public void SetPropertySequenceStyle(string propertyName, string? style) + { + GetOrCreateMetadata(propertyName)["sequenceStyle"] = style; + } + + public string? GetPropertySequenceStyle(string propertyName) + { + return GetMetadataValue(propertyName, "sequenceStyle") as string; + } + + public void SetPropertyTag(string propertyName, string? tag) + { + GetOrCreateMetadata(propertyName)["tag"] = tag; + } + + public string? GetPropertyTag(string propertyName) + { + return GetMetadataValue(propertyName, "tag") as string; + } + + /// + /// Get the full metadata dictionary for a property (for internal use by cmdlets) + /// + public Dictionary? GetPropertyMetadata(string propertyName) + { + return _metadata.TryGetValue(propertyName, out var meta) ? meta : null; + } + + /// + /// Set the full metadata dictionary for a property (for internal use by cmdlets) + /// + public void SetPropertyMetadata(string propertyName, Dictionary metadata) + { + _metadata[propertyName] = metadata; + } + + /// + /// Get all metadata for serialization + /// + public Dictionary> GetAllMetadata() + { + return _metadata; + } + + private Dictionary GetOrCreateMetadata(string propertyName) + { + if (!_metadata.TryGetValue(propertyName, out var meta)) + { + meta = new Dictionary(); + _metadata[propertyName] = meta; + } + return meta; + } + + private object? GetMetadataValue(string propertyName, string key) + { + if (_metadata.TryGetValue(propertyName, out var meta)) + { + return meta.TryGetValue(key, out var value) ? value : null; + } + return null; + } +} diff --git a/src/PowerShellYaml/YamlPropertyAttribute.cs b/src/PowerShellYaml/YamlPropertyAttribute.cs new file mode 100644 index 0000000..602c73c --- /dev/null +++ b/src/PowerShellYaml/YamlPropertyAttribute.cs @@ -0,0 +1,40 @@ +// Copyright 2016-2026 Cloudbase Solutions Srl +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. +// + +using System; + +namespace PowerShellYaml; + +/// +/// Attribute for configuring how a property is serialized to/from YAML. +/// +[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] +public class YamlPropertyAttribute : Attribute +{ + /// + /// YAML property name alias (e.g., "server-name" for ServerName property). + /// + public string? Alias { get; set; } + + /// + /// Whether this property is required when deserializing. + /// + public bool Required { get; set; } + + /// + /// Comment to include when serializing to YAML. + /// + public string? Comment { get; set; } +} diff --git a/src/PowerShellYamlSerializer.cs b/src/PowerShellYamlSerializer.cs deleted file mode 100644 index b0f9433..0000000 --- a/src/PowerShellYamlSerializer.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Numerics; -using System.Text.RegularExpressions; -using System.Collections; -using System.Management.Automation; -using System.Collections.Generic; -using YamlDotNet.Core; -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.EventEmitters; -using YamlDotNet.Core.Events; -using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Serialization.ObjectGraphVisitors; - -public sealed class NullValueGraphVisitor : ChainedObjectGraphVisitor -{ - public NullValueGraphVisitor(IObjectGraphVisitor nextVisitor) - : base(nextVisitor) - { - } - - public override bool EnterMapping(IPropertyDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } - - public override bool EnterMapping(IObjectDescriptor key, IObjectDescriptor value, IEmitter context, ObjectSerializer serializer) { - if (value.Value == null) { - return false; - } - return base.EnterMapping(key, value, context, serializer); - } -} - -internal static class PSObjectHelper { - /// - /// Unwraps a PSObject to its BaseObject if the BaseObject is not a PSCustomObject. - /// - /// The object to potentially unwrap - /// The type of the unwrapped object - /// The unwrapped object if it was a PSObject wrapping a non-PSCustomObject, otherwise the original object - public static object UnwrapIfNeeded(object obj, out Type unwrappedType) { - if (obj is PSObject psObj && psObj.BaseObject != null) { - var baseType = psObj.BaseObject.GetType(); - if (baseType != typeof(System.Management.Automation.PSCustomObject)) { - unwrappedType = baseType; - return psObj.BaseObject; - } - } - unwrappedType = obj?.GetType(); - return obj; - } -} - -public class BigIntegerTypeConverter : IYamlTypeConverter { - public bool Accepts(Type type) { - return typeof(BigInteger).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { - var value = parser.Consume().Value; - var bigNr = BigInteger.Parse(value); - return bigNr; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var bigNr = (BigInteger)value; - emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, bigNr.ToString(), ScalarStyle.Plain, true, false)); - } -} - -public class IDictionaryTypeConverter : IYamlTypeConverter { - - private bool omitNullValues; - private bool useFlowStyle; - - public IDictionaryTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { - this.omitNullValues = omitNullValues; - this.useFlowStyle = useFlowStyle; - } - - public bool Accepts(Type type) { - return typeof(IDictionary).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) { - var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; - return deserializedObject; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var hObj = (IDictionary)value; - var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (DictionaryEntry entry in hObj) { - if(entry.Value == null) { - if (this.omitNullValues) { - continue; - } - serializer(entry.Key, entry.Key.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - continue; - } - serializer(entry.Key, entry.Key.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(entry.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); - } - emitter.Emit(new MappingEnd()); - } -} - -public class PSObjectTypeConverter : IYamlTypeConverter { - - private bool omitNullValues; - private bool useFlowStyle; - - public PSObjectTypeConverter(bool omitNullValues = false, bool useFlowStyle = false) { - this.omitNullValues = omitNullValues; - this.useFlowStyle = useFlowStyle; - } - - public bool Accepts(Type type) { - return typeof(PSObject).IsAssignableFrom(type); - } - - public object ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer) - { - // We don't really need to do any custom deserialization. - var deserializedObject = rootDeserializer(typeof(IDictionary)) as IDictionary; - return deserializedObject; - } - - public void WriteYaml(IEmitter emitter, object value, Type type, ObjectSerializer serializer) { - var psObj = (PSObject)value; - if (psObj.BaseObject != null && - !typeof(IDictionary).IsAssignableFrom(psObj.BaseObject.GetType()) && - !typeof(PSCustomObject).IsAssignableFrom(psObj.BaseObject.GetType())) { - serializer(psObj.BaseObject, psObj.BaseObject.GetType()); - return; - } - var mappingStyle = this.useFlowStyle ? MappingStyle.Flow : MappingStyle.Block; - emitter.Emit(new MappingStart(AnchorName.Empty, TagName.Empty, true, mappingStyle)); - foreach (var prop in psObj.Properties) { - if (prop.Value == null) { - if (this.omitNullValues) { - continue; - } - serializer(prop.Name, prop.Name.GetType()); - emitter.Emit(new Scalar(AnchorName.Empty, "tag:yaml.org,2002:null", "", ScalarStyle.Plain, true, false)); - } else { - serializer(prop.Name, prop.Name.GetType()); - var unwrapped = PSObjectHelper.UnwrapIfNeeded(prop.Value, out var unwrappedType); - serializer(unwrapped, unwrappedType); - } - } - emitter.Emit(new MappingEnd()); - } -} - -public class StringQuotingEmitter: ChainedEventEmitter { - // Patterns from https://yaml.org/spec/1.2/spec.html#id2804356 - private static Regex quotedRegex = new Regex(@"^(\~|null|true|false|on|off|yes|no|y|n|[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?|[-+]?(\.inf))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public StringQuotingEmitter(IEventEmitter next): base(next) {} - - public override void Emit(ScalarEventInfo eventInfo, IEmitter emitter) { - var typeCode = eventInfo.Source.Value != null - ? Type.GetTypeCode(eventInfo.Source.Type) - : TypeCode.Empty; - - switch (typeCode) { - case TypeCode.Char: - if (Char.IsDigit((char)eventInfo.Source.Value)) { - eventInfo.Style = ScalarStyle.DoubleQuoted; - } - break; - case TypeCode.String: - var val = eventInfo.Source.Value.ToString(); - if (quotedRegex.IsMatch(val)) - { - eventInfo.Style = ScalarStyle.DoubleQuoted; - } else if (val.IndexOf('\n') > -1) { - eventInfo.Style = ScalarStyle.Literal; - } - break; - } - - base.Emit(eventInfo, emitter); - } -} - -public class FlowStyleAllEmitter: ChainedEventEmitter { - public FlowStyleAllEmitter(IEventEmitter next): base(next) {} - - public override void Emit(MappingStartEventInfo eventInfo, IEmitter emitter) { - eventInfo.Style = MappingStyle.Flow; - base.Emit(eventInfo, emitter); - } - - public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ - eventInfo.Style = SequenceStyle.Flow; - base.Emit(eventInfo, emitter); - } -} - -public class FlowStyleSequenceEmitter: ChainedEventEmitter { - public FlowStyleSequenceEmitter(IEventEmitter next): base(next) {} - - public override void Emit(SequenceStartEventInfo eventInfo, IEmitter emitter){ - eventInfo.Style = SequenceStyle.Flow; - base.Emit(eventInfo, emitter); - } -} - -public class BuilderUtils { - public static SerializerBuilder BuildSerializer( - SerializerBuilder builder, - bool omitNullValues = false, - bool useFlowStyle = false, - bool useSequenceFlowStyle = false, - bool jsonCompatible = false) { - - if (jsonCompatible) { - useFlowStyle = true; - useSequenceFlowStyle = true; - } - - builder = builder - .WithEventEmitter(next => new StringQuotingEmitter(next)) - .WithTypeConverter(new BigIntegerTypeConverter()) - .WithTypeConverter(new IDictionaryTypeConverter(omitNullValues, useFlowStyle)) - .WithTypeConverter(new PSObjectTypeConverter(omitNullValues, useFlowStyle)); - if (omitNullValues) { - builder = builder - .WithEmissionPhaseObjectGraphVisitor(args => new NullValueGraphVisitor(args.InnerVisitor)); - } - if (useFlowStyle) { - builder = builder.WithEventEmitter(next => new FlowStyleAllEmitter(next)); - } - if (useSequenceFlowStyle) { - builder = builder.WithEventEmitter(next => new FlowStyleSequenceEmitter(next)); - } - - return builder; - } -} diff --git a/src/PowerShellYamlSerializer.csproj b/src/PowerShellYamlSerializer.csproj deleted file mode 100644 index a38a57d..0000000 --- a/src/PowerShellYamlSerializer.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - netstandard2.0 - - - - - - - - -