Private/ConvertTo-OpmXmlString.ps1

<#
.SYNOPSIS
  Serialize a PacKit application fragment to its exact on-disk XML string.
 
.DESCRIPTION
  Reproduces, byte-for-byte, the output of the C++ PacKit writer for a single
  externalized application fragment:
    - declaration: <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
    - CRLF line endings, 2-space indent per depth
    - <FRAGMENT Version="23.8"> root with one inline <ITEM> (the app)
    - item properties as XML attributes in the exact schema order
    - every defined attribute emitted even when empty
    - COLLECTION elements always rendered as an open+close pair (even empty)
    - leaf ITEMs self-closed (<ITEM .../>)
    - attribute values escaped via Format-OpmXmlAttributeValue (NOT .NET defaults)
 
  The structure is built into a small element tree, then rendered, so the byte
  format lives in exactly one place (Write-OpmElementNode) and the attribute
  order lives in exactly one place (Get-OpmSchema).
#>


# Resolve a dotted property path (e.g. 'DetectionRule.Format') against an object.
function Get-OpmValueByPath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)] [string] $Path
    )
    $current = $Object
    foreach ($part in $Path.Split('.')) {
        if ($null -eq $current) { return $null }
        $current = $current.$part
    }
    return $current
}

# Format a raw value as its on-disk attribute text according to its schema Type.
function Get-OpmAttributeText {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter()] [AllowNull()] $Value,
        [Parameter(Mandatory)] [string] $Type
    )
    switch ($Type) {
        'Bool' {
            if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } }
            # tolerate string forms
            if ("$Value" -eq 'true') { return 'true' } else { return 'false' }
        }
        'TimeT' {
            if ($null -eq $Value -or "$Value" -eq '') { return '0' }
            return [string]([long]$Value)
        }
        'Double' {
            if ($null -eq $Value -or "$Value" -eq '') { $Value = 0.0 }
            return ([double]$Value).ToString('F6', [System.Globalization.CultureInfo]::InvariantCulture)
        }
        default {
            # String, Guid
            if ($null -eq $Value) { return '' }
            return [string]$Value
        }
    }
}

# Build the ordered attribute list (Name/Value pairs) for one item from a schema set.
function Get-OpmItemAttributes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Object,
        [Parameter(Mandatory)] [object[]] $Descriptors
    )
    $attrs = @(foreach ($d in $Descriptors) {
            $raw = Get-OpmValueByPath -Object $Object -Path $d.Path
            @{ Name = $d.Name; Value = (Get-OpmAttributeText -Value $raw -Type $d.Type) }
        })
    return , $attrs
}

# Build the element tree for a fragment file: <FRAGMENT><ITEM ...>...</ITEM></FRAGMENT>.
function ConvertTo-OpmElementTree {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Fragment
    )
    $schema = Get-OpmSchema

    # Packages collection
    $packageItems = @(foreach ($pkg in @($Fragment.Packages)) {
            $scanItems = @(foreach ($scan in @($pkg.WinGetScanResults)) {
                    @{
                        Name       = 'ITEM'
                        Attributes = (Get-OpmItemAttributes -Object $scan -Descriptors $schema.WinGetScanResult)
                        Children   = @()
                        ForceOpen  = $false
                    }
                })
            $scanCollection = @{
                Name       = 'COLLECTION'
                Attributes = @(@{ Name = 'Name'; Value = 'WinGetScanResults' })
                Children   = $scanItems
                ForceOpen  = $true
            }
            @{
                Name       = 'ITEM'
                Attributes = (Get-OpmItemAttributes -Object $pkg -Descriptors $schema.Package)
                Children   = @($scanCollection)
                ForceOpen  = $false
            }
        })
    $packagesCollection = @{
        Name       = 'COLLECTION'
        Attributes = @(@{ Name = 'Name'; Value = 'Packages' })
        Children   = $packageItems
        ForceOpen  = $true
    }

    # IntuneAssignments collection
    $assignmentItems = @(foreach ($asg in @($Fragment.IntuneAssignments)) {
            @{
                Name       = 'ITEM'
                Attributes = (Get-OpmItemAttributes -Object $asg -Descriptors $schema.Assignment)
                Children   = @()
                ForceOpen  = $false
            }
        })
    $assignmentsCollection = @{
        Name       = 'COLLECTION'
        Attributes = @(@{ Name = 'Name'; Value = 'IntuneAssignments' })
        Children   = $assignmentItems
        ForceOpen  = $true
    }

    $appItem = @{
        Name       = 'ITEM'
        Attributes = (Get-OpmItemAttributes -Object $Fragment -Descriptors $schema.App)
        Children   = @($packagesCollection, $assignmentsCollection)
        ForceOpen  = $false
    }

    return @{
        Name       = 'FRAGMENT'
        Attributes = @(@{ Name = 'Version'; Value = $schema.FragmentVersion })
        Children   = @($appItem)
        ForceOpen  = $false
    }
}

# Recursively render an element node into the StringBuilder (CRLF, 2-space indent).
function Write-OpmElementNode {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable] $Node,
        [Parameter(Mandatory)] [int] $Depth,
        [Parameter(Mandatory)] [System.Text.StringBuilder] $Builder
    )
    $eol = [string][char]13 + [string][char]10
    $indent = ' ' * $Depth

    $open = $indent + '<' + $Node.Name
    foreach ($attr in @($Node.Attributes)) {
        $open += ' ' + $attr.Name + '="' + (Format-OpmXmlAttributeValue -Value ([string]$attr.Value)) + '"'
    }

    $children = @($Node.Children)
    $hasChildren = ($children.Count -gt 0)
    $forceOpen = [bool]$Node.ForceOpen

    if ($hasChildren -or $forceOpen) {
        [void]$Builder.Append($open).Append('>').Append($eol)
        foreach ($child in $children) {
            Write-OpmElementNode -Node $child -Depth ($Depth + 1) -Builder $Builder
        }
        [void]$Builder.Append($indent).Append('</').Append($Node.Name).Append('>').Append($eol)
    }
    else {
        [void]$Builder.Append($open).Append('/>').Append($eol)
    }
}

function ConvertTo-OpmXmlString {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [psobject] $Fragment
    )
    process {
        $eol = [string][char]13 + [string][char]10
        $sb = [System.Text.StringBuilder]::new()
        [void]$sb.Append('<?xml version="1.0" encoding="UTF-8" standalone="yes"?>').Append($eol)
        $tree = ConvertTo-OpmElementTree -Fragment $Fragment
        Write-OpmElementNode -Node $tree -Depth 0 -Builder $sb
        return $sb.ToString()
    }
}