public/Compare-SfStateAndCountrySettings.ps1

function Compare-SfStateAndCountrySettings {

    <#
        .SYNOPSIS
        Compares the TO-BE state/country picklist entries from the Data Dictionary against the
        AS-IS entries in the org's Address Settings and returns the resulting delta as an action list.

        .DESCRIPTION
        TO-BE is read from MetadataModel.Tables._StateAndCountryPicklists, as produced by
        Import-SfMetadataModel. AS-IS is read from the Address Settings metadata XML at SourcePath,
        converted via ConvertFrom-SfCountriesAndStatesXml.

        For matching, '.' and an empty string are both treated as "no state code" (i.e. a country row),
        consistent with how '.' is treated elsewhere in the Data Dictionary.

        TO-BE vs. AS-IS differences are classified by which mechanism can fix them:
        - A _Label difference requires the Setup UI (Create.../Update...ViaSetup), because _Label is the
          only property that cannot be set via a regular Metadata API deployment and must go through
          Add-SfCountryPicklistValue / Add-SfStatePicklistValue / Update-SfCountryPicklistValue /
          Update-SfStatePicklistValue instead.
        - A difference in _IntegrationValue, _IsStandard, _IsDefaultCountry or _Status is reported as
          Deploy, because these properties can be set via a regular Metadata API deployment of
          Address.settings-meta.xml. Deploy is not split into country/state variants, because a
          deployment is typically applied to the whole file at once, not per entry.
        - An entry with no difference at all is reported as Equal. Equal rows are always part of the
          output, regardless of -Verbose.

        The result is sorted by _CountryCode, _StateCode and is intended to be piped into
        Set-SfStateAndCountryPicklistValue.

        .OUTPUTS
        [PSCustomObject]
        One object per TO-BE entry, plus one per AS-IS-only entry (AddToDataDictionary). Properties:
        Action, _CountryCode, _StateCode, _IsStandard, _IsDefaultCountry, _Status, _IntegrationValue
        and _Label.

        Action is one of:
        - CreateCountryViaSetup - country exists in TO-BE but not in AS-IS
        - CreateStateViaSetup - state exists in TO-BE but not in AS-IS
        - UpdateCountryViaSetup - country exists in both, but _Label differs
        - UpdateStateViaSetup - state exists in both, but _Label differs
        - Deploy - entry exists in both, _Label matches, but _IntegrationValue,
                                   _IsStandard, _IsDefaultCountry or _Status differs
        - Equal - entry exists in both and every compared property matches
        - AddToDataDictionary - entry exists in AS-IS but not in TO-BE (_CountryCode/_StateCode/
                                   _IntegrationValue/_Label are taken from AS-IS in this case)

        .EXAMPLE
        Compare-SfStateAndCountrySettings -MetadataModel $MetadataModel |
            Set-SfStateAndCountryPicklistValue -WebSession $WebSession

        .LINK
        https://codeberg.org/uwe-braig/sf-metadata-toolkit
    #>


    [CmdletBinding()]
    param (
        # TO-BE: metadata model as returned by Import-SfMetadataModel.
        [Parameter(Mandatory, Position = 0)]
        [PSCustomObject] $MetadataModel,

        # AS-IS: path to the Address Settings metadata XML.
        [Parameter(Position = 1)]
        [ValidateScript({ Test-Path $_ -PathType Leaf })]
        [string] $SourcePath = (Join-Path -Path 'force-app' -ChildPath 'main' -AdditionalChildPath 'default',
            'settings', 'Address.settings-meta.xml')
    )

    $ScriptName = 'Compare-SfStateAndCountrySettings'
    $ErrorActionPreference = 'Stop'
    [bool] $IsDebug   = $DebugPreference   -ne 'SilentlyContinue'
    [bool] $IsVerbose = $VerbosePreference -ne 'SilentlyContinue'

    if ($null -eq $MetadataModel.PSObject.Properties['Tables']) {
        throw "$ScriptName|MetadataModel has no 'Tables' property - was it created by Import-SfMetadataModel?"
    }
    if (-not $MetadataModel.Tables.ContainsKey('_StateAndCountryPicklists')) {
        throw "$ScriptName|MetadataModel.Tables has no '_StateAndCountryPicklists' - check the Data Dictionary."
    }

    # Nested helpers - reduced overhead applies (coding conventions, section 11).

    # '.' and '' both mean "no state code" (i.e. a country row) - treat them as equal for matching.
    function GetNormalizedStateCode {
        param ([string] $StateCode)

        if ($StateCode -eq '.') {
            return ''
        }
        return $StateCode
    }

    function GetKey {
        param ([string] $CountryCode, [string] $StateCode)

        return "$CountryCode|$(GetNormalizedStateCode $StateCode)"
    }

    function ConvertToActionRow {
        param ([string] $Action, $Row)

        return [PSCustomObject]@{
            Action            = $Action
            _CountryCode      = $Row._CountryCode
            _StateCode        = $Row._StateCode
            _IsStandard       = $Row._IsStandard
            _IsDefaultCountry = $Row._IsDefaultCountry
            _Status           = $Row._Status
            _IntegrationValue = $Row._IntegrationValue
            _Label            = $Row._Label
        }
    }

    # --- AS-IS: read from the Address Settings metadata XML
    Write-Verbose "$ScriptName|Reading AS-IS from '$SourcePath'"
    $Xml = Get-SfMetadataXmlFile -Path $SourcePath
    [array] $AsIsRows = @(
        ConvertFrom-SfCountriesAndStatesXml -Countries $Xml.AddressSettings.countriesAndStates.countries
    )

    [hashtable] $AsIsByKey = @{}
    foreach ($Row in $AsIsRows) {
        $AsIsByKey[(GetKey $Row._CountryCode $Row._StateCode)] = $Row
    }

    # --- TO-BE: read from the Data Dictionary
    Write-Verbose "$ScriptName|Reading TO-BE from MetadataModel.Tables._StateAndCountryPicklists"
    [array] $ToBeRows = @($MetadataModel.Tables._StateAndCountryPicklists)

    [hashtable] $ToBeByKey = @{}
    foreach ($Row in $ToBeRows) {
        $ToBeByKey[(GetKey $Row._CountryCode $Row._StateCode)] = $Row
    }

    [array] $Results = @(
        $(
            # --- TO-BE vs. AS-IS: Setup-UI creates/updates, Deploy, Equal
            foreach ($Row in $ToBeRows) {
                [bool] $IsCountry = (GetNormalizedStateCode $Row._StateCode) -eq ''
                $AsIsRow = $AsIsByKey[(GetKey $Row._CountryCode $Row._StateCode)]

                if ($null -eq $AsIsRow) {
                    [string] $Action = $IsCountry ? 'CreateCountryViaSetup' : 'CreateStateViaSetup'
                    Write-Output (ConvertToActionRow $Action $Row)
                    continue
                }

                if ($AsIsRow._Label -ne $Row._Label) {
                    [string] $Action = $IsCountry ? 'UpdateCountryViaSetup' : 'UpdateStateViaSetup'
                    Write-Output (ConvertToActionRow $Action $Row)
                    continue
                }

                if (($AsIsRow._IntegrationValue -ne $Row._IntegrationValue) -or
                    ($AsIsRow._IsStandard       -ne $Row._IsStandard) -or
                    ($AsIsRow._IsDefaultCountry -ne $Row._IsDefaultCountry) -or
                    ($AsIsRow._Status           -ne $Row._Status)
                ) {
                    Write-Output (ConvertToActionRow 'Deploy' $Row)
                    continue
                }

                Write-Output (ConvertToActionRow 'Equal' $Row)
            }

            # --- AS-IS vs. TO-BE: entries present in the org but missing from the Data Dictionary
            foreach ($Row in $AsIsRows) {
                if ($null -eq $ToBeByKey[(GetKey $Row._CountryCode $Row._StateCode)]) {
                    Write-Output (ConvertToActionRow 'AddToDataDictionary' $Row)
                }
            }
        ) | Sort-Object -Property '_CountryCode', '_StateCode'
    )

    [string] $Summary = ($Results | Group-Object -Property 'Action' | ForEach-Object {
        "$($_.Name)=$($_.Count)"
    }) -join ', '
    Write-Host "$ScriptName|Comparison complete|$Summary"

    return @($Results)
}