Modules/AzureDevOpsDsc.Common/Api/Functions/Private/Helper/ConvertTo-NormalizedPolicySettingValue.ps1

<#
.SYNOPSIS
Reduces a branch policy setting value to a comparable string.
 
.DESCRIPTION
Get-AzDoBranchPolicy has to compare a value from the configuration's PolicySettings hashtable
against the same key on the API's policy.settings object, and the two sides disagree on shape
even when nothing has actually changed:
 
- Numbers can come back as a different .NET type than they were sent as (an int written as `1`
  can be read back as a double), so a raw `-ne` reports drift on every Test().
- Nested values (a build validation policy's `queueOnSourceUpdateOnly`, a required-reviewers
  policy's `requiredReviewerIds` / `filenamePatterns` arrays, a status check's genre/name pair)
  come back as PSCustomObject/array trees rather than hashtables/arrays, so they cannot be
  compared with `-eq` at all.
 
This flattens any value - scalar, hashtable/PSCustomObject or array/list, at any depth - into a
canonical string (keys sorted and lower-cased, numbers rendered with an invariant culture) so two
semantically-equal values normalize to the same string regardless of which side produced them.
This is for comparison only; what gets written back to the API is always the configuration's own
PolicySettings value (see the "Compare normalized, store what the user wrote" convention in
CLAUDE.md).
 
.PARAMETER Value
The value to normalize - a scalar, a hashtable, a PSCustomObject, or an array/list of any of
those. $null normalizes to an empty string, so an absent key and an explicit null compare equal.
 
.EXAMPLE
ConvertTo-NormalizedPolicySettingValue -Value 2
Returns '2'. ConvertTo-NormalizedPolicySettingValue -Value ([double]2) also returns '2', so an
int sent and a double read back compare equal.
 
.EXAMPLE
ConvertTo-NormalizedPolicySettingValue -Value @{ requiredReviewerIds = @('b'; 'a') }
Returns '{requiredreviewerids=[a,b]}' - keys are sorted and lower-cased for the comparison.
#>

Function ConvertTo-NormalizedPolicySettingValue
{
    [CmdletBinding()]
    [OutputType([System.String])]
    param
    (
        [Parameter(Mandatory = $false, ValueFromPipeline = $true)]
        [AllowNull()]
        [Object]$Value
    )

    Process
    {
        if ($null -eq $Value)
        {
            return ''
        }

        if ($Value -is [System.String])
        {
            return $Value
        }

        if ($Value -is [System.Boolean])
        {
            return $Value.ToString().ToLowerInvariant()
        }

        # Numbers: the configuration and the API can disagree on the underlying .NET type for the
        # same logical value (an int of 1 read back as a double of 1.0) - render both on a single
        # invariant numeric representation so the type difference is not read as drift.
        if ($Value -is [System.Byte] -or $Value -is [System.Int16] -or $Value -is [System.Int32] -or
            $Value -is [System.Int64] -or $Value -is [System.UInt16] -or $Value -is [System.UInt32] -or
            $Value -is [System.UInt64] -or $Value -is [System.Single] -or $Value -is [System.Double] -or
            $Value -is [System.Decimal])
        {
            return ([double]$Value).ToString([System.Globalization.CultureInfo]::InvariantCulture)
        }

        if ($Value -is [System.Collections.IDictionary])
        {
            $parts = foreach ($key in ($Value.Keys | Sort-Object))
            {
                '{0}={1}' -f $key.ToString().ToLowerInvariant(), (ConvertTo-NormalizedPolicySettingValue -Value $Value[$key])
            }
            return ('{{{0}}}' -f ($parts -join ';'))
        }

        # Arrays/lists (but not strings, which are handled above and are themselves enumerable).
        if ($Value -is [System.Collections.IEnumerable])
        {
            $items = foreach ($item in $Value) { ConvertTo-NormalizedPolicySettingValue -Value $item }
            return ('[{0}]' -f ($items -join ','))
        }

        # A PSCustomObject (the shape 'ConvertFrom-Json' produces for a JSON object) - walk its
        # properties the same way a hashtable's keys are walked above.
        if ($Value.PSObject -and $Value.PSObject.Properties.Count -gt 0)
        {
            $parts = foreach ($property in ($Value.PSObject.Properties | Sort-Object -Property Name))
            {
                '{0}={1}' -f $property.Name.ToLowerInvariant(), (ConvertTo-NormalizedPolicySettingValue -Value $property.Value)
            }
            return ('{{{0}}}' -f ($parts -join ';'))
        }

        return [string]$Value
    }
}