From d3dc159883a557db64d0c64252472f0764dcb631 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:49:38 +0200 Subject: [PATCH 1/3] Add Merge-Toml function to combine TOML documents Adds Merge-Toml (src/functions/public/Merge-Toml.ps1) and the private recursive merge helper Merge-TomlTableObject (src/functions/private/Merge-TomlTableObject.ps1). Supports BaseObject /OverrideObject strings, -Path, and -LiteralPath parameter sets, with LastWins (default), FirstWins, and ErrorOnConflict merge strategies. Nested tables are deep-merged recursively and arrays of tables are always concatenated regardless of strategy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../private/Merge-TomlTableObject.ps1 | 101 ++++++++++++++++ src/functions/public/Merge-Toml.ps1 | 111 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/functions/private/Merge-TomlTableObject.ps1 create mode 100644 src/functions/public/Merge-Toml.ps1 diff --git a/src/functions/private/Merge-TomlTableObject.ps1 b/src/functions/private/Merge-TomlTableObject.ps1 new file mode 100644 index 0000000..1629974 --- /dev/null +++ b/src/functions/private/Merge-TomlTableObject.ps1 @@ -0,0 +1,101 @@ +function Merge-TomlTableObject { + <# + .SYNOPSIS + Recursively merges two TOML table dictionaries. + + .DESCRIPTION + Walks every key in the override dictionary and combines it with the base + dictionary. Keys missing from the base are added as-is. Nested tables + (OrderedDictionary) are merged recursively. Arrays of tables (ArrayList of + OrderedDictionary) are concatenated, base entries first. Any other + overlapping key — scalar or inline array — is resolved with the given + merge strategy: LastWins keeps the override value, FirstWins keeps the + base value, and ErrorOnConflict throws. + + .EXAMPLE + $base = [ordered]@{ a = 1; server = [ordered]@{ host = 'localhost' } } + $override = [ordered]@{ b = 2; server = [ordered]@{ port = 80 } } + Merge-TomlTableObject -Base $base -Override $override -Strategy 'LastWins' + # Returns: OrderedDictionary { a = 1, server = { host = 'localhost', port = 80 }, b = 2 } + + Deep-merges a nested table while preserving keys unique to each side. + + .EXAMPLE + $base = [ordered]@{ key = 'base' } + $override = [ordered]@{ key = 'override' } + Merge-TomlTableObject -Base $base -Override $override -Strategy 'ErrorOnConflict' + # Throws because 'key' is defined on both sides. + + Demonstrates conflict detection for duplicate scalar keys. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [System.Collections.Specialized.OrderedDictionary] + #> + [OutputType([System.Collections.Specialized.OrderedDictionary])] + [CmdletBinding()] + param( + # The base table. Its keys are preserved unless overridden. + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $Base, + + # The override table applied on top of the base. + [Parameter(Mandatory)] + [System.Collections.Specialized.OrderedDictionary] $Override, + + # The strategy used to resolve scalar key conflicts. + [Parameter(Mandatory)] + [ValidateSet('LastWins', 'FirstWins', 'ErrorOnConflict')] + [string] $Strategy + ) + + $result = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::Ordinal) + foreach ($key in $Base.Keys) { + $result[$key] = $Base[$key] + } + + foreach ($key in $Override.Keys) { + if (-not $result.Contains($key)) { + Write-Verbose "Adding override-only key '$key'." + $result[$key] = $Override[$key] + continue + } + + $baseValue = $result[$key] + $overrideValue = $Override[$key] + + if ($baseValue -is [System.Collections.Specialized.OrderedDictionary] -and + $overrideValue -is [System.Collections.Specialized.OrderedDictionary]) { + Write-Verbose "Deep-merging nested table for key '$key'." + $result[$key] = Merge-TomlTableObject -Base $baseValue -Override $overrideValue -Strategy $Strategy + continue + } + + if ($baseValue -is [System.Collections.ArrayList] -and $overrideValue -is [System.Collections.ArrayList]) { + Write-Verbose "Concatenating array-of-tables for key '$key'." + $combined = [System.Collections.ArrayList]::new($baseValue) + $null = $combined.AddRange($overrideValue) + $result[$key] = $combined + continue + } + + switch ($Strategy) { + 'LastWins' { + Write-Verbose "Resolving scalar conflict for key '$key' using LastWins." + $result[$key] = $overrideValue + } + 'FirstWins' { + Write-Verbose "Resolving scalar conflict for key '$key' using FirstWins." + } + 'ErrorOnConflict' { + throw [System.InvalidOperationException]::new( + "The key '$key' is defined in both documents and the merge strategy is 'ErrorOnConflict'." + ) + } + } + } + + return $result +} diff --git a/src/functions/public/Merge-Toml.ps1 b/src/functions/public/Merge-Toml.ps1 new file mode 100644 index 0000000..1818dd7 --- /dev/null +++ b/src/functions/public/Merge-Toml.ps1 @@ -0,0 +1,111 @@ +function Merge-Toml { + <# + .SYNOPSIS + Merges two or more TOML documents into one. + + .DESCRIPTION + Parses TOML documents with ConvertFrom-Toml, deep-merges the resulting + ordered dictionaries with the private Merge-TomlTableObject helper, and + serializes the combined result back to TOML text with ConvertTo-Toml. + + Nested tables are always merged recursively. Arrays of tables are always + concatenated, base entries first, then override entries. Scalar key + conflicts (including inline arrays, which are treated as scalars) are + resolved with -Strategy: + - LastWins (default): the override value replaces the base value + - FirstWins: the base value is kept and the override value is ignored + - ErrorOnConflict: a duplicate scalar key throws + + When -Path or -LiteralPath is given with more than two files, documents + are merged left to right: the first file is the base, and each + subsequent file is merged on top of the accumulated result in order. + + .EXAMPLE + Merge-Toml -BaseObject 'a = 1' -OverrideObject 'b = 2' + # Returns: "a = 1`nb = 2" + + Merges two TOML strings with no overlapping keys. + + .EXAMPLE + Merge-Toml -Path 'defaults.toml', 'local.toml' -Strategy 'FirstWins' + + Merges two files in order, keeping the base value whenever both files + define the same scalar key. + + .INPUTS + None. Parameters only. + + .OUTPUTS + [string] + + .NOTES + Throws when a file cannot be found or read, when either document is not + valid TOML, or when -Strategy 'ErrorOnConflict' encounters a duplicate + scalar key. + + .LINK + https://psmodule.io/Toml/Functions/Merge-Toml + #> + [OutputType([string])] + [CmdletBinding(DefaultParameterSetName = 'Default')] + param( + # The base TOML document, provided as a string. + [Parameter(Mandatory, ParameterSetName = 'Default')] + [ValidateNotNullOrEmpty()] + [string] $BaseObject, + + # The override TOML document applied on top of the base document. + [Parameter(Mandatory, ParameterSetName = 'Default')] + [ValidateNotNullOrEmpty()] + [string] $OverrideObject, + + # File paths to merge in order. The first path is the base document, + # and each subsequent path is merged on top of the accumulated result. + [Parameter(Mandatory, ParameterSetName = 'Path')] + [ValidateCount(2, [int]::MaxValue)] + [string[]] $Path, + + # Literal file paths to merge in order, without wildcard expansion. + # The first path is the base document, and each subsequent path is + # merged on top of the accumulated result. + [Parameter(Mandatory, ParameterSetName = 'LiteralPath')] + [ValidateCount(2, [int]::MaxValue)] + [string[]] $LiteralPath, + + # The strategy used to resolve scalar key conflicts between documents. + [Parameter()] + [ValidateSet('LastWins', 'FirstWins', 'ErrorOnConflict')] + [string] $Strategy = 'LastWins' + ) + + process { + $documents = switch ($PSCmdlet.ParameterSetName) { + 'Default' { + @($BaseObject, $OverrideObject) + } + 'Path' { + Write-Verbose "Reading $($Path.Count) file(s) for merge." + foreach ($p in $Path) { + $resolvedPath = Resolve-Path -Path $p -ErrorAction Stop + [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + } + } + 'LiteralPath' { + Write-Verbose "Reading $($LiteralPath.Count) file(s) for merge." + foreach ($p in $LiteralPath) { + $resolvedPath = Resolve-Path -LiteralPath $p -ErrorAction Stop + [System.IO.File]::ReadAllText($resolvedPath.ProviderPath) + } + } + } + + $merged = (ConvertFrom-Toml -InputObject $documents[0]).Data + for ($i = 1; $i -lt $documents.Count; $i++) { + $override = (ConvertFrom-Toml -InputObject $documents[$i]).Data + Write-Verbose "Merging document $($i + 1) of $($documents.Count) using strategy '$Strategy'." + $merged = Merge-TomlTableObject -Base $merged -Override $override -Strategy $Strategy + } + + return ConvertTo-Toml -InputObject $merged + } +} From e07913cd5c3ec326b09f7cc1d96edc248c266f64 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:49:38 +0200 Subject: [PATCH 2/3] Add Pester coverage for Merge-Toml Covers all three parameter sets (BaseObject/OverrideObject, -Path, -LiteralPath), all three merge strategies, nested table deep-merge, array-of-tables concatenation, output type/parseability, and idempotency under FirstWins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/Toml.Tests.ps1 | 143 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/tests/Toml.Tests.ps1 b/tests/Toml.Tests.ps1 index 1cbbcc3..2a0fd2d 100644 --- a/tests/Toml.Tests.ps1 +++ b/tests/Toml.Tests.ps1 @@ -15,6 +15,7 @@ Describe 'Toml' { $commands | Should -Contain 'ConvertTo-Toml' $commands | Should -Contain 'Import-Toml' $commands | Should -Contain 'Export-Toml' + $commands | Should -Contain 'Merge-Toml' } } @@ -801,4 +802,146 @@ Describe 'Toml' { } } } + + Describe 'Merge-Toml' { + BeforeAll { + $baseDoc = @' +title = "base" +shared = "base-value" +[server] +host = "localhost" +port = 8080 +[[items]] +name = "base-item" +'@ + + $overrideDoc = @' +extra = "override-only" +shared = "override-value" +[server] +port = 9090 +timeout = 30 +[[items]] +name = "override-item" +'@ + } + + Context 'Return type' { + It 'returns a string' { + $result = Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc + $result | Should -BeOfType [string] + } + + It 'produces output parseable by ConvertFrom-Toml' { + $result = Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc + { ConvertFrom-Toml -InputObject $result } | Should -Not -Throw + } + } + + Context 'Key preservation' { + It 'keeps base-only keys' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc) + $result.Data['title'] | Should -Be 'base' + } + + It 'adds override-only keys' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc) + $result.Data['extra'] | Should -Be 'override-only' + } + } + + Context 'Strategy: LastWins' { + It 'uses the override value on scalar conflict' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc -Strategy 'LastWins') + $result.Data['shared'] | Should -Be 'override-value' + } + + It 'is the default strategy' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc) + $result.Data['shared'] | Should -Be 'override-value' + } + } + + Context 'Strategy: FirstWins' { + It 'keeps the base value on scalar conflict' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc -Strategy 'FirstWins') + $result.Data['shared'] | Should -Be 'base-value' + } + } + + Context 'Strategy: ErrorOnConflict' { + It 'throws on any duplicate scalar key' { + { Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc -Strategy 'ErrorOnConflict' } | Should -Throw + } + + It 'does not throw when there are no scalar conflicts' { + $noConflict = 'onlyhere = "value"' + { Merge-Toml -BaseObject $baseDoc -OverrideObject $noConflict -Strategy 'ErrorOnConflict' } | Should -Not -Throw + } + } + + Context 'Nested tables' { + It 'deep-merges keys from both sides' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc) + $result.Data['server']['host'] | Should -Be 'localhost' + $result.Data['server']['timeout'] | Should -Be 30 + } + + It 'applies the strategy to nested scalar conflicts' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc -Strategy 'FirstWins') + $result.Data['server']['port'] | Should -Be 8080 + } + } + + Context 'Arrays of tables' { + It 'concatenates base entries before override entries' { + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $baseDoc -OverrideObject $overrideDoc) + $result.Data['items'].Count | Should -Be 2 + $result.Data['items'][0]['name'] | Should -Be 'base-item' + $result.Data['items'][1]['name'] | Should -Be 'override-item' + } + } + + Context 'File input via -Path' { + It 'merges two files in order' { + $basePath = Join-Path $TestDrive 'merge-base.toml' + $overridePath = Join-Path $TestDrive 'merge-override.toml' + Set-Content -Path $basePath -Value $baseDoc -NoNewline + Set-Content -Path $overridePath -Value $overrideDoc -NoNewline + + $result = ConvertFrom-Toml -InputObject (Merge-Toml -Path $basePath, $overridePath) + $result.Data['title'] | Should -Be 'base' + $result.Data['shared'] | Should -Be 'override-value' + } + } + + Context 'File input via -LiteralPath' { + It 'merges two files in order' { + $basePath = Join-Path $TestDrive 'merge-literal-base.toml' + $overridePath = Join-Path $TestDrive 'merge-literal-override.toml' + Set-Content -Path $basePath -Value $baseDoc -NoNewline + Set-Content -Path $overridePath -Value $overrideDoc -NoNewline + + $result = ConvertFrom-Toml -InputObject (Merge-Toml -LiteralPath $basePath, $overridePath) + $result.Data['title'] | Should -Be 'base' + $result.Data['shared'] | Should -Be 'override-value' + } + } + + Context 'Idempotency' { + It 'merging identical documents under FirstWins returns the base document unchanged' { + # Uses a document without arrays of tables, since AoT entries are always + # concatenated regardless of strategy and would not be idempotent. + $scalarOnlyDoc = @' +title = "base" +[server] +host = "localhost" +port = 8080 +'@ + $result = ConvertFrom-Toml -InputObject (Merge-Toml -BaseObject $scalarOnlyDoc -OverrideObject $scalarOnlyDoc -Strategy 'FirstWins') + $expected = ConvertFrom-Toml -InputObject $scalarOnlyDoc + ConvertTo-Toml -InputObject $result.Data | Should -Be (ConvertTo-Toml -InputObject $expected.Data) + } + } + } } From 79a2a5ed88dda9bc512acfa36f4707d0fb585e12 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sun, 26 Jul 2026 09:49:38 +0200 Subject: [PATCH 3/3] Add Merge-Toml usage example to README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 967fed8..8910eea 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,28 @@ $doc.Data['version'] = 2 Export-Toml -InputObject $doc -Path './config.toml' ``` +### Merge two TOML documents + +```powershell +$defaults = @' +[server] +host = "localhost" +port = 8080 +'@ + +$overrides = @' +[server] +port = 9090 +'@ + +Merge-Toml -BaseObject $defaults -OverrideObject $overrides +# [server] +# host = "localhost" +# port = 9090 +``` + +`Merge-Toml` also accepts `-Path`/`-LiteralPath` for merging files, and a `-Strategy` of `LastWins` (default), `FirstWins`, or `ErrorOnConflict` for resolving scalar key conflicts. Nested tables are always deep-merged and arrays of tables are always concatenated. + ## TOML type mapping | TOML type | PowerShell type | @@ -102,6 +124,7 @@ Export-Toml -InputObject $doc -Path './config.toml' | `ConvertTo-Toml` | Serialize object → TOML text | | `Import-Toml` | Read TOML file → `TomlDocument` | | `Export-Toml` | Write object or `TomlDocument` to file | +| `Merge-Toml` | Merge two TOML documents into one | ## Implementation notes