Private/Convert-AzLocalUpdateWindowToCron.ps1

function Convert-AzLocalUpdateWindowToCron {
    <#
    .SYNOPSIS
        Derives the recommended cron expression(s) needed to fire an apply-updates
        pipeline at the opening edge of every maintenance window encoded in an
        UpdateWindow tag value.
    .DESCRIPTION
        Used by Test-AzLocalApplyUpdatesScheduleCoverage. Reuses the existing
        ConvertFrom-AzLocalUpdateWindow parser, then for each parsed segment:
 
          - computes the fire time = StartTime - LeadTimeMinutes
            (with day wrap when the fire time goes negative)
          - converts the DayOfWeek[] set to cron DoW notation
            (Sun=0, Mon=1, ..., Sat=6 - contiguous sets emit ranges, others emit comma lists)
          - emits one cron string '<M> <H> * * <DoW>' per window opening edge
 
        Same-day window: fire at (start - lead) on each day in the set.
        Overnight window: the window opens on the listed day(s); fire only on the
                           opening edge - the runtime gate (Test-AzLocalUpdateScheduleAllowed)
                           handles the wrap into the next day.
 
        Multi-segment windows like 'Mon-Fri_22:00-04:00;Sat-Sun_02:00-10:00'
        produce one cron string per segment.
    .PARAMETER UpdateWindow
        The raw UpdateWindow tag value.
    .PARAMETER LeadTimeMinutes
        Minutes before the window opens that the pipeline should fire. Default 5.
    .OUTPUTS
        PSCustomObject[] - one per window segment, with:
            Segment - the raw window segment string
            Days - DayOfWeek[]
            CronDoWSet - sorted int[] (cron 0-6) of firing days (after lead-time wrap)
            CronExpression - '<M> <H> * * <DoW>' string suitable for GitHub Actions / ADO
            FireMinute - int 0-59
            FireHour - int 0-23
            DayShift - $true if the lead-time pushed the fire time onto the previous day
    .EXAMPLE
        Convert-AzLocalUpdateWindowToCron -UpdateWindow 'Sat-Sun_02:00-06:00' -LeadTimeMinutes 5
        # Returns one row, CronExpression = '55 1 * * 6,0'
    #>

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

        [Parameter(Mandatory = $false)]
        [ValidateRange(0, 60)]
        [int]$LeadTimeMinutes = 5
    )

    # DayOfWeek enum value -> cron DoW int. Cron: Sun=0, Mon=1, ..., Sat=6 -
        # this also matches the .NET DayOfWeek enum numeric values.
    $dowToCron = @{
        [System.DayOfWeek]::Sunday    = 0
        [System.DayOfWeek]::Monday    = 1
        [System.DayOfWeek]::Tuesday   = 2
        [System.DayOfWeek]::Wednesday = 3
        [System.DayOfWeek]::Thursday  = 4
        [System.DayOfWeek]::Friday    = 5
        [System.DayOfWeek]::Saturday  = 6
    }

    $parsed = ConvertFrom-AzLocalUpdateWindow -WindowString $UpdateWindow

    $output = New-Object System.Collections.Generic.List[PSCustomObject]
    foreach ($w in $parsed) {
        # Compute fire time = StartTime - LeadTimeMinutes. If this crosses
        # midnight backwards, push each firing day back by one (e.g. Mon 00:05
        # window with 10min lead fires at Sun 23:55).
        $startMinutes = ($w.StartTime.Hours * 60) + $w.StartTime.Minutes
        $fireMinutes  = $startMinutes - $LeadTimeMinutes
        $dayShift     = $false
        if ($fireMinutes -lt 0) {
            $fireMinutes += (24 * 60)
            $dayShift = $true
        }
        $fireHour   = [int]([math]::Floor($fireMinutes / 60))
        $fireMinute = $fireMinutes - ($fireHour * 60)

        # Translate firing days (with optional shift) to cron DoW ints.
        $cronDows = New-Object System.Collections.Generic.List[int]
        foreach ($d in $w.Days) {
            $cronDow = $dowToCron[$d]
            if ($dayShift) {
                $cronDow = ($cronDow + 6) % 7   # shift back one day, wrap Sun->Sat
            }
            $cronDows.Add($cronDow)
        }
        $cronDowSet = @($cronDows | Sort-Object -Unique)

        # Render DoW set: a contiguous range becomes '<a>-<b>', otherwise a comma list.
        # Special-case Sun (0) merged with Sat (6) - the parser may emit {0,6}
        # which is logically Sat-Sun but cron can't express a wrap range, so
        # emit as '6,0' (Sat first, then Sun) to read like the human tag value
        # 'Sat-Sun'. Cron treats day lists as unordered so '6,0' and '0,6' are
        # equivalent at runtime - this is purely cosmetic.
        $dowStr = if ($cronDowSet.Count -eq 1) {
            "$($cronDowSet[0])"
        }
        elseif ($cronDowSet.Count -eq 2 -and $cronDowSet[0] -eq 0 -and $cronDowSet[1] -eq 6) {
            '6,0'
        }
        elseif ($cronDowSet.Count -gt 1 -and ($cronDowSet[-1] - $cronDowSet[0]) -eq ($cronDowSet.Count - 1)) {
            "$($cronDowSet[0])-$($cronDowSet[-1])"
        }
        else {
            ($cronDowSet -join ',')
        }

        $output.Add([PSCustomObject]@{
            Segment        = $w.Raw
            Days           = $w.Days
            CronDoWSet     = $cronDowSet
            CronExpression = "$fireMinute $fireHour * * $dowStr"
            FireMinute     = $fireMinute
            FireHour       = $fireHour
            DayShift       = $dayShift
        })
    }

    # WARNING: Callers MUST use direct assignment ($x = func ...) and NEVER
    # wrap with @(func ...). The unary-comma return below preserves Object[N]
    # shape for any N including 0 and 1, but @() at the call site collapses
    # to Object[1] containing the inner array, silently producing one-row
    # output instead of N rows. See `docs/MODULE-REVIEW-AND-RECOMMENDATIONS.md`
    # Finding 1 for the v0.7.75 incident.
    return , $output.ToArray()
}