Private/ConvertFrom-AzLocalScheduleYaml.ps1

function ConvertFrom-AzLocalScheduleYaml {
    <#
    .SYNOPSIS
        Parses the AzLocal apply-updates-schedule.yml (schema v1) into a
        [PSCustomObject]. Zero external dependencies.
 
    .DESCRIPTION
        Intentionally NOT a general-purpose YAML parser. Accepts only the
        narrow shape documented in
        Automation-Pipeline-Examples/apply-updates-schedule.example.yml:
 
          schemaVersion: <int>
          cycleWeeks: <int>
          cycleAnchorISOWeek: <int>
          cycleAnchorYear: <int>
          schedule:
            - weeksInCycle: '<expr>'
              daysOfWeek: '<expr>'
              rings: '<expr>'
              notes: '<text>' # optional
            - ...
 
        Indentation must be 2 spaces (list-item marker) + 2 spaces
        (continuation keys), exactly. Inline '#' comments and blank
        lines are ignored. Quoted strings (single or double) and bare
        integers are supported as scalar values; everything else is
        treated as a bare string.
 
        Validation lives in Get-AzLocalApplyUpdatesScheduleConfig - this
        function only converts text to structure. Errors here are limited
        to structural problems (unexpected indent, malformed key:value).
 
    .PARAMETER Text
        The raw file contents (read via Get-Content -Raw).
 
    .PARAMETER SourcePath
        Optional path used in error messages so operators see the file
        they were trying to load.
 
    .OUTPUTS
        [PSCustomObject] with top-level scalar properties and a Schedule
        property of type [PSCustomObject[]]. Property names match the
        YAML keys exactly. Missing top-level keys are emitted as $null
        - the validator surfaces them.
 
    .EXAMPLE
        $text = Get-Content -Raw .\.github\apply-updates-schedule.yml
        ConvertFrom-AzLocalScheduleYaml -Text $text
    #>

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

        [Parameter(Mandatory = $false)]
        [string]$SourcePath = '<inline>'
    )

    # ---- Scalar parser -------------------------------------------------
    # Strips one matching pair of surrounding single or double quotes.
    # Bare integers (^-?\d+$) become [int]; everything else stays string.
    # Trailing '# comment' is stripped BEFORE we get here; this is just
    # the value side.
    function ConvertTo-AzLocalScalar([string]$raw) {
        $v = $raw.Trim()
        if ($v.Length -ge 2) {
            $first = $v[0]; $last = $v[$v.Length - 1]
            if (($first -eq "'" -and $last -eq "'") -or
                ($first -eq '"' -and $last -eq '"')) {
                return $v.Substring(1, $v.Length - 2)
            }
        }
        if ($v -match '^-?\d+$') { return [int]$v }
        return $v
    }

    # ---- Trailing-comment stripper ------------------------------------
    # Walks the line and finds the first '#' that is NOT inside a single
    # or double-quoted string. The match before it is the value; from #
    # onward is a YAML comment.
    function Remove-AzLocalTrailingYamlComment([string]$line) {
        $inSingle = $false; $inDouble = $false
        for ($i = 0; $i -lt $line.Length; $i++) {
            $c = $line[$i]
            if ($c -eq "'" -and -not $inDouble) { $inSingle = -not $inSingle; continue }
            if ($c -eq '"' -and -not $inSingle) { $inDouble = -not $inDouble; continue }
            if ($c -eq '#' -and -not $inSingle -and -not $inDouble) {
                return $line.Substring(0, $i).TrimEnd()
            }
        }
        return $line.TrimEnd()
    }

    # ---- Tokenise -----------------------------------------------------
    # Each non-comment, non-blank line becomes a (LineNumber, Indent,
    # Kind, Key, RawValue) record. Kind is 'Pair' for "key: value", or
    # 'ListItemPair' for "- key: value". Continuation keys for a list
    # item are also 'Pair' but with higher indent than the marker line.
    $tokens = New-Object System.Collections.Generic.List[psobject]
    $lineNum = 0
    foreach ($raw in ($Text -split "`r?`n")) {
        $lineNum++
        $stripped = Remove-AzLocalTrailingYamlComment $raw
        if ([string]::IsNullOrWhiteSpace($stripped)) { continue }

        $indent = 0
        while ($indent -lt $stripped.Length -and $stripped[$indent] -eq ' ') { $indent++ }
        $body = $stripped.Substring($indent)

        if ($body.StartsWith('- ')) {
            $rest = $body.Substring(2)
            $m = [regex]::Match($rest, '^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$')
            if (-not $m.Success) {
                throw "ConvertFrom-AzLocalScheduleYaml: malformed list item at $SourcePath line $($lineNum): '$raw'."
            }
            $tokens.Add([pscustomobject]@{
                LineNumber = $lineNum
                Indent     = $indent
                Kind       = 'ListItemPair'
                Key        = $m.Groups[1].Value
                RawValue   = $m.Groups[2].Value
            }) | Out-Null
            continue
        }

        $m = [regex]::Match($body, '^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$')
        if (-not $m.Success) {
            throw "ConvertFrom-AzLocalScheduleYaml: malformed key:value at $SourcePath line $($lineNum): '$raw'."
        }
        $tokens.Add([pscustomobject]@{
            LineNumber = $lineNum
            Indent     = $indent
            Kind       = 'Pair'
            Key        = $m.Groups[1].Value
            RawValue   = $m.Groups[2].Value
        }) | Out-Null
    }

    # ---- Assemble ------------------------------------------------------
    # Top-level scalars accumulate into a hashtable. The `schedule:` key
    # (which has an empty RawValue) opens a list block; subsequent
    # ListItemPair tokens start new entries, and Pair tokens with indent
    # > the marker indent extend the current entry.
    $topLevel = [ordered]@{}
    $schedule = New-Object System.Collections.Generic.List[psobject]
    $currentItem = $null
    $inSchedule  = $false
    $scheduleIndent = -1

    foreach ($t in $tokens) {
        if (-not $inSchedule) {
            if ($t.Indent -ne 0) {
                throw "ConvertFrom-AzLocalScheduleYaml: unexpected indented key '$($t.Key)' at $SourcePath line $($t.LineNumber) - top-level keys must start at column 0."
            }
            if ($t.Key -eq 'schedule') {
                if (-not [string]::IsNullOrWhiteSpace($t.RawValue)) {
                    throw "ConvertFrom-AzLocalScheduleYaml: top-level 'schedule' key must have NO inline value (list follows on subsequent lines) at $SourcePath line $($t.LineNumber)."
                }
                $inSchedule = $true
                continue
            }
            $topLevel[$t.Key] = ConvertTo-AzLocalScalar $t.RawValue
            continue
        }

        # Inside the schedule list now.
        if ($t.Kind -eq 'ListItemPair') {
            # New list entry begins.
            if ($scheduleIndent -lt 0) { $scheduleIndent = $t.Indent }
            if ($t.Indent -ne $scheduleIndent) {
                throw "ConvertFrom-AzLocalScheduleYaml: schedule list items must all share the same indent (expected $scheduleIndent, got $($t.Indent)) at $SourcePath line $($t.LineNumber)."
            }
            if ($currentItem) { $schedule.Add([pscustomobject]$currentItem) | Out-Null }
            $currentItem = [ordered]@{
                _LineNumber = $t.LineNumber
                ($t.Key)    = ConvertTo-AzLocalScalar $t.RawValue
            }
            continue
        }

        # Pair while inside schedule. Indent 0 closes the list and goes
        # back to top-level (rare, but supported). Otherwise it extends
        # the current item, provided the indent is greater than the
        # marker indent.
        if ($t.Indent -eq 0) {
            if ($currentItem) { $schedule.Add([pscustomobject]$currentItem) | Out-Null; $currentItem = $null }
            $inSchedule = $false
            $topLevel[$t.Key] = ConvertTo-AzLocalScalar $t.RawValue
            continue
        }
        if (-not $currentItem) {
            throw "ConvertFrom-AzLocalScheduleYaml: indented key '$($t.Key)' at $SourcePath line $($t.LineNumber) has no preceding list-item marker."
        }
        if ($t.Indent -le $scheduleIndent) {
            throw "ConvertFrom-AzLocalScheduleYaml: continuation key '$($t.Key)' at $SourcePath line $($t.LineNumber) must be indented further than the list-item marker."
        }
        $currentItem[$t.Key] = ConvertTo-AzLocalScalar $t.RawValue
    }

    if ($currentItem) { $schedule.Add([pscustomobject]$currentItem) | Out-Null }

    # ---- Project to a stable shape -----------------------------------
    # Always emit the four scalar keys (as $null when missing) plus an
    # array Schedule. Validator decides what's required.
    # v0.7.89 (schema v2) added an optional top-level
    # 'allowedUpdateVersions' string (semicolon-separated). It is
    # surfaced here as AllowedUpdateVersionsRaw (the raw string, or
    # $null when absent) so the validator + resolver can split + dedupe
    # it without re-parsing the file. Per-row 'allowedUpdateVersions' is
    # passed through generically via the row PSCustomObject (the
    # tokenizer accepts any continuation key) - no projection needed
    # here.
    $obj = [ordered]@{
        SchemaVersion              = $topLevel['schemaVersion']
        CycleWeeks                 = $topLevel['cycleWeeks']
        CycleAnchorISOWeek         = $topLevel['cycleAnchorISOWeek']
        CycleAnchorYear            = $topLevel['cycleAnchorYear']
        AllowedUpdateVersionsRaw   = $topLevel['allowedUpdateVersions']
        Schedule                   = @($schedule)
        SourcePath                 = $SourcePath
    }
    return [pscustomobject]$obj
}