Private/ConvertFrom-AzLocalUpdateExclusion.ps1

function ConvertFrom-AzLocalUpdateExclusion {
    <#
    .SYNOPSIS
        Parses an UpdateExclusions tag value into structured date range objects.
    .DESCRIPTION
        Parses the exclusion date range syntax used in the UpdateExclusions Azure resource tag.
        Supports wildcards (*) for recurring annual patterns.
 
        Syntax: <start_date>/<end_date>[,<start_date>/<end_date>]
        Dates: YYYY-MM-DD format. * replaces a single digit for recurring patterns.
    .PARAMETER ExclusionString
        The UpdateExclusions tag value to parse.
    .PARAMETER ReferenceDate
        The date to use for resolving wildcards. Defaults to today (UTC).
    .OUTPUTS
        PSCustomObject[] with StartDate (datetime), EndDate (datetime), IsWildcard (bool), Raw (string)
    .EXAMPLE
        ConvertFrom-AzLocalUpdateExclusion -ExclusionString "2026-12-20/2027-01-03"
    .EXAMPLE
        ConvertFrom-AzLocalUpdateExclusion -ExclusionString "20**-12-20/20**-01-03"
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [string]$ExclusionString,

        [Parameter(Mandatory = $false)]
        [datetime]$ReferenceDate = (Get-Date).ToUniversalTime().Date
    )

    if ([string]::IsNullOrWhiteSpace($ExclusionString)) {
        throw "UpdateExclusions value cannot be empty."
    }

    if ($ExclusionString.Length -gt 256) {
        throw "UpdateExclusions value exceeds Azure tag limit of 256 characters (length: $($ExclusionString.Length))."
    }

    $exclusions = @()
    $ranges = $ExclusionString -split ','

    foreach ($range in $ranges) {
        $range = $range.Trim()
        if ([string]::IsNullOrWhiteSpace($range)) { continue }

        if ($range -notmatch '^([0-9*]{4}-[0-9*]{2}-[0-9*]{2})/([0-9*]{4}-[0-9*]{2}-[0-9*]{2})$') {
            throw "Invalid exclusion range syntax: '$range'. Expected format: YYYY-MM-DD/YYYY-MM-DD (wildcards * allowed)."
        }

        $startPattern = $matches[1]
        $endPattern = $matches[2]
        $isWildcard = ($startPattern -match '\*') -or ($endPattern -match '\*')

        if ($isWildcard) {
            # Resolve wildcards against current year and adjacent years
            $resolvedRanges = Resolve-WildcardDateRange -StartPattern $startPattern -EndPattern $endPattern -ReferenceDate $ReferenceDate
            foreach ($resolved in $resolvedRanges) {
                $exclusions += [PSCustomObject]@{
                    StartDate  = $resolved.StartDate
                    EndDate    = $resolved.EndDate
                    IsWildcard = $true
                    Raw        = $range
                }
            }
        }
        else {
            # Fixed dates (PS 5.1 compatible - avoid TryParseExact [ref] issues)
            $startDate = $null
            $endDate = $null
            try { $startDate = [datetime]::ParseExact($startPattern, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) }
            catch { throw "Invalid start date '$startPattern' in exclusion range '$range'." }
            try { $endDate = [datetime]::ParseExact($endPattern, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) }
            catch { throw "Invalid end date '$endPattern' in exclusion range '$range'." }

            if ($endDate -lt $startDate) {
                throw "End date ($endPattern) is before start date ($startPattern) in exclusion range '$range'."
            }

            $exclusions += [PSCustomObject]@{
                StartDate  = $startDate
                EndDate    = $endDate
                IsWildcard = $false
                Raw        = $range
            }
        }
    }

    return $exclusions
}