Private/Read-RunbookDefinition.ps1

function Read-RunbookDefinition {
    <#
    .SYNOPSIS
        Parses a YAML runbook file into a structured PowerShell object.
    .DESCRIPTION
        Attempts to use the PowerShell-Yaml module for parsing. If unavailable, falls back
        to a built-in basic YAML parser that handles simple key-value pairs, lists, and
        multi-line strings sufficient for the runbook format.
        Validates that required fields (name, steps with id/action) are present.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    if (-not (Test-Path $Path)) {
        throw "Runbook file not found: $Path"
    }

    $yamlContent = Get-Content -Path $Path -Raw -ErrorAction Stop

    # Try PowerShell-Yaml module first
    $parsed = $null
    $usedModule = $false

    try {
        if (Get-Module -ListAvailable -Name 'powershell-yaml' -ErrorAction SilentlyContinue) {
            Import-Module powershell-yaml -ErrorAction Stop
            $parsed = ConvertFrom-Yaml -Yaml $yamlContent -ErrorAction Stop
            $usedModule = $true
        }
    }
    catch {
        Write-Verbose "PowerShell-Yaml module not available or failed, using built-in parser: $_"
        $parsed = $null
    }

    if (-not $usedModule -or $null -eq $parsed) {
        $parsed = ConvertFrom-BasicYaml -YamlText $yamlContent
    }

    # Validate required fields
    if (-not $parsed.name) {
        throw "Runbook validation failed: 'name' field is required."
    }

    if (-not $parsed.steps -or $parsed.steps.Count -eq 0) {
        throw "Runbook validation failed: 'steps' field is required and must contain at least one step."
    }

    foreach ($step in $parsed.steps) {
        if (-not $step.id) {
            throw "Runbook validation failed: each step must have an 'id' field."
        }
        if (-not $step.action) {
            throw "Runbook validation failed: step '$($step.id)' must have an 'action' field."
        }
    }

    # Return as a structured object
    [PSCustomObject]@{
        Name        = $parsed.name
        Version     = if ($parsed.version) { $parsed.version } else { '1.0' }
        Description = if ($parsed.description) { $parsed.description } else { '' }
        Trigger     = if ($parsed.trigger) { $parsed.trigger } else { $null }
        Parameters  = if ($parsed.parameters) { $parsed.parameters } else { @() }
        Steps       = $parsed.steps
        RawParsed   = $parsed
        SourcePath  = $Path
    }
}

function ConvertFrom-BasicYaml {
    <#
    .SYNOPSIS
        Basic YAML parser for runbook definitions.
    .DESCRIPTION
        Handles simple key-value pairs, nested objects, lists (with - prefix),
        multi-line strings (| and > blocks), and quoted strings.
        Not a full YAML spec implementation - designed specifically for runbook structures.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$YamlText
    )

    $lines = $YamlText -split "`n" | ForEach-Object { $_ -replace "`r$", '' }

    function Get-IndentLevel {
        param([string]$Line)
        if ($Line -match '^(\s*)') {
            return $Matches[1].Length
        }
        return 0
    }

    function Parse-Value {
        param([string]$Val)
        $Val = $Val.Trim()
        if ($Val -eq '' -or $Val -eq '~' -or $Val -eq 'null') { return $null }
        if ($Val -eq 'true') { return $true }
        if ($Val -eq 'false') { return $false }
        if ($Val -match '^\d+$') { return [int]$Val }
        if ($Val -match '^\d+\.\d+$') { return [double]$Val }
        # Remove quotes
        if (($Val.StartsWith('"') -and $Val.EndsWith('"')) -or
            ($Val.StartsWith("'") -and $Val.EndsWith("'"))) {
            return $Val.Substring(1, $Val.Length - 2)
        }
        return $Val
    }

    function Parse-Block {
        param(
            [string[]]$Lines,
            [ref]$Index,
            [int]$BaseIndent
        )

        $result = [ordered]@{}
        $currentKey = $null

        while ($Index.Value -lt $Lines.Count) {
            $line = $Lines[$Index.Value]

            # Skip empty lines and comments
            if ($line.Trim() -eq '' -or $line.Trim().StartsWith('#')) {
                $Index.Value++
                continue
            }

            $indent = Get-IndentLevel -Line $line
            $trimmed = $line.Trim()

            # If less indented than our base, we're done with this block
            if ($indent -lt $BaseIndent -and $Index.Value -gt 0) {
                break
            }

            # List item at current level
            if ($trimmed.StartsWith('- ') -and $indent -eq $BaseIndent) {
                # This is a list at the top level of this block - shouldn't happen for root
                # but handle for nested lists
                break
            }

            # Key-value pair
            if ($trimmed -match '^([^:]+?):\s*(.*)$') {
                $key = $Matches[1].Trim()
                $value = $Matches[2].Trim()

                if ($value -eq '' -or $value -eq '|' -or $value -eq '>') {
                    # Check what's next
                    $isMultiline = ($value -eq '|' -or $value -eq '>')
                    $foldStyle = ($value -eq '>')
                    $Index.Value++

                    if ($Index.Value -ge $Lines.Count) {
                        $result[$key] = $null
                        continue
                    }

                    $nextLine = $Lines[$Index.Value]
                    $nextIndent = Get-IndentLevel -Line $nextLine
                    $nextTrimmed = $nextLine.Trim()

                    if ($nextTrimmed -eq '' -or $nextIndent -le $indent) {
                        if ($isMultiline) {
                            $result[$key] = ''
                        }
                        else {
                            $result[$key] = $null
                        }
                        continue
                    }

                    if ($isMultiline) {
                        # Multi-line string block
                        $multiLines = @()
                        $blockIndent = $nextIndent
                        while ($Index.Value -lt $Lines.Count) {
                            $ml = $Lines[$Index.Value]
                            $mlIndent = Get-IndentLevel -Line $ml
                            if ($ml.Trim() -eq '') {
                                $multiLines += ''
                                $Index.Value++
                                continue
                            }
                            if ($mlIndent -lt $blockIndent) { break }
                            $multiLines += $ml.Substring([Math]::Min($blockIndent, $ml.Length))
                            $Index.Value++
                        }
                        if ($foldStyle) {
                            $result[$key] = ($multiLines -join ' ').Trim()
                        }
                        else {
                            $result[$key] = ($multiLines -join "`n").TrimEnd()
                        }
                        continue
                    }

                    if ($nextTrimmed.StartsWith('- ')) {
                        # It's a list
                        $listItems = @()
                        $listIndent = $nextIndent
                        while ($Index.Value -lt $Lines.Count) {
                            $ll = $Lines[$Index.Value]
                            $llTrimmed = $ll.Trim()
                            $llIndent = Get-IndentLevel -Line $ll

                            if ($llTrimmed -eq '' -or $llTrimmed.StartsWith('#')) {
                                $Index.Value++
                                continue
                            }
                            if ($llIndent -lt $listIndent) { break }
                            if (-not $llTrimmed.StartsWith('- ') -and $llIndent -eq $listIndent) { break }

                            if ($llTrimmed.StartsWith('- ') -and $llIndent -eq $listIndent) {
                                $itemContent = $llTrimmed.Substring(2).Trim()

                                # Check if this list item has nested content
                                if ($itemContent -match '^([^:]+?):\s*(.*)$') {
                                    # It's a mapping in a list
                                    $itemObj = [ordered]@{}
                                    $itemKey = $Matches[1].Trim()
                                    $itemVal = $Matches[2].Trim()
                                    $itemObj[$itemKey] = Parse-Value -Val $itemVal

                                    $Index.Value++
                                    # Check for more keys in this list item
                                    while ($Index.Value -lt $Lines.Count) {
                                        $subLine = $Lines[$Index.Value]
                                        $subTrimmed = $subLine.Trim()
                                        $subIndent = Get-IndentLevel -Line $subLine

                                        if ($subTrimmed -eq '' -or $subTrimmed.StartsWith('#')) {
                                            $Index.Value++
                                            continue
                                        }
                                        if ($subIndent -le $listIndent) { break }
                                        if ($subTrimmed.StartsWith('- ')) { break }

                                        if ($subTrimmed -match '^([^:]+?):\s*(.*)$') {
                                            $subKey = $Matches[1].Trim()
                                            $subVal = $Matches[2].Trim()

                                            if ($subVal -eq '' -or $subVal -eq '|' -or $subVal -eq '>') {
                                                $isMulti = ($subVal -eq '|' -or $subVal -eq '>')
                                                $isFold = ($subVal -eq '>')
                                                $Index.Value++

                                                if ($isMulti -and $Index.Value -lt $Lines.Count) {
                                                    $mLines = @()
                                                    $bIndent = Get-IndentLevel -Line $Lines[$Index.Value]
                                                    while ($Index.Value -lt $Lines.Count) {
                                                        $ml2 = $Lines[$Index.Value]
                                                        $ml2Indent = Get-IndentLevel -Line $ml2
                                                        if ($ml2.Trim() -eq '') {
                                                            $mLines += ''
                                                            $Index.Value++
                                                            continue
                                                        }
                                                        if ($ml2Indent -lt $bIndent) { break }
                                                        $mLines += $ml2.Substring([Math]::Min($bIndent, $ml2.Length))
                                                        $Index.Value++
                                                    }
                                                    if ($isFold) {
                                                        $itemObj[$subKey] = ($mLines -join ' ').Trim()
                                                    }
                                                    else {
                                                        $itemObj[$subKey] = ($mLines -join "`n").TrimEnd()
                                                    }
                                                }
                                                else {
                                                    $itemObj[$subKey] = $null
                                                }
                                                continue
                                            }
                                            $itemObj[$subKey] = Parse-Value -Val $subVal
                                        }
                                        $Index.Value++
                                    }
                                    $listItems += [PSCustomObject]$itemObj
                                }
                                else {
                                    $listItems += (Parse-Value -Val $itemContent)
                                    $Index.Value++
                                }
                            }
                            else {
                                $Index.Value++
                            }
                        }
                        $result[$key] = $listItems
                        continue
                    }
                    else {
                        # Nested object
                        $subObj = Parse-Block -Lines $Lines -Index $Index -BaseIndent $nextIndent
                        $result[$key] = [PSCustomObject]$subObj
                        continue
                    }
                }
                else {
                    $result[$key] = Parse-Value -Val $value
                    $Index.Value++
                }
            }
            else {
                $Index.Value++
            }
        }

        return $result
    }

    $idx = [ref]0
    $rootObj = Parse-Block -Lines $lines -Index $idx -BaseIndent 0
    return [PSCustomObject]$rootObj
}