LibreDevOpsHelpers.Yaml/LibreDevOpsHelpers.Yaml.psm1

Set-StrictMode -Version Latest

function ConvertFrom-LdoYaml {
    <#
    .SYNOPSIS
        Parses YAML into objects, preferring the yq binary with a powershell-yaml fallback.

    .DESCRIPTION
        Uses yq (present on GitHub hosted runners and most dev machines) to convert YAML
        to JSON and returns the parsed object; when yq is unavailable, installs and uses
        the powershell-yaml module instead. One code path for CI and local use. Two
        unrelated tools ship as "yq" (mikefarah's Go build and the kislyuk Python jq
        wrapper) with different dialects; the flavour is detected per call.

    .PARAMETER Path
        A YAML file to parse.

    .PARAMETER Content
        YAML content to parse.

    .EXAMPLE
        ConvertFrom-LdoYaml -Path ./custom-detections/identity/rule.yaml

    .OUTPUTS
        System.Object
    #>

    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [OutputType([object])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Path')][ValidateNotNullOrEmpty()][string]$Path,
        [Parameter(Mandatory, ParameterSetName = 'Content')][ValidateNotNullOrEmpty()][string]$Content
    )

    if ($PSCmdlet.ParameterSetName -eq 'Path' -and -not (Test-Path $Path)) {
        throw "ConvertFrom-LdoYaml: file not found: $Path"
    }

    if (Get-Command yq -ErrorAction SilentlyContinue) {
        $goFlavour = ((& yq --version 2>&1) -join ' ') -match 'mikefarah'

        $json = if ($PSCmdlet.ParameterSetName -eq 'Path') {
            if ($goFlavour) { & yq eval -o=json '.' $Path } else { & yq '.' $Path }
        }
        else {
            if ($goFlavour) { $Content | & yq eval -o=json '.' - } else { $Content | & yq '.' }
        }
        Assert-LdoLastExitCode -Operation 'yq (yaml to json)'
        return ($json -join "`n") | ConvertFrom-Json -Depth 100
    }

    if (-not (Get-Module -ListAvailable powershell-yaml)) {
        Write-LdoLog -Level INFO -Message 'yq not found; installing the powershell-yaml module.'
        Install-Module powershell-yaml -Scope CurrentUser -Force -AllowClobber
    }
    Import-Module powershell-yaml -ErrorAction Stop

    $raw = if ($PSCmdlet.ParameterSetName -eq 'Path') { Get-Content -Raw $Path } else { $Content }
    return ConvertFrom-Yaml -Yaml $raw
}

function Test-LdoYamlPlainScalar {
    # Internal: whether a string can be emitted unquoted in YAML without changing meaning.
    param([string]$Value)

    if ($null -eq $Value -or $Value.Length -eq 0) { return $false }
    if ($Value -ne $Value.Trim()) { return $false }
    if ($Value -match '[\x00-\x1f]') { return $false }
    # Reserved words and things a YAML parser would retype.
    if ($Value -match '^(?i)(true|false|null|~|yes|no|on|off)$') { return $false }
    if ($Value -match '^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$') { return $false }
    # Leading characters and substrings with YAML syntax meaning.
    if ($Value -match '^[\-?:,\[\]{}#&*!|>''"%@` ]') { return $false }
    if ($Value -match '(:\s)|(:$)|(\s#)') { return $false }
    return $true
}

function Format-LdoYamlScalar {
    # Internal: renders a single-line scalar, quoting only when required.
    param($Value)

    if ($null -eq $Value) { return 'null' }
    if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() }
    if ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) {
        return [string]([System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture))
    }
    $s = [string]$Value
    if (Test-LdoYamlPlainScalar -Value $s) { return $s }
    $escaped = $s.Replace('\', '\\').Replace('"', '\"')
    return "`"$escaped`""
}

function ConvertTo-LdoYamlNode {
    # Internal: renders a value as either an inline scalar, a block scalar, or nested lines.
    param($Value, [int]$Level, [int]$Depth)

    if ($Depth -le 0) { throw 'ConvertTo-LdoYaml: maximum depth exceeded (is the object cyclic?).' }
    $pad = ' ' * $Level

    # Multiline strings become literal block scalars, the analyst friendly shape for KQL.
    if ($Value -is [string] -and $Value.Contains("`n")) {
        $body = foreach ($line in ($Value.Replace("`r`n", "`n").TrimEnd("`n") -split "`n")) {
            if ($line.Length -gt 0) { "$pad$line" } else { '' }
        }
        return [pscustomobject]@{ Kind = 'block'; Lines = @($body) }
    }

    if ($null -eq $Value -or $Value -is [string] -or $Value -is [bool] -or $Value.GetType().IsPrimitive -or
        $Value -is [decimal] -or $Value -is [datetime]) {
        return [pscustomobject]@{ Kind = 'inline'; Text = (Format-LdoYamlScalar -Value $Value) }
    }

    # Mappings: hashtables and ordered dictionaries keep their enumeration order; PSCustomObjects
    # keep property order, so [ordered] input round trips deterministically. Entries build with
    # the unary comma append because a single iteration foreach unwraps its only output, which
    # would mangle one key maps.
    if ($Value -is [System.Collections.IDictionary] -or $Value -is [pscustomobject]) {
        $entries = @()
        if ($Value -is [System.Collections.IDictionary]) {
            foreach ($k in $Value.Keys) { $entries += , @([string]$k, $Value[$k]) }
        }
        else {
            foreach ($p in $Value.PSObject.Properties) { $entries += , @($p.Name, $p.Value) }
        }
        if ($entries.Count -eq 0) { return [pscustomobject]@{ Kind = 'inline'; Text = '{}' } }

        $lines = foreach ($e in $entries) {
            $key = Format-LdoYamlScalar -Value $e[0]
            $node = ConvertTo-LdoYamlNode -Value $e[1] -Level ($Level + 1) -Depth ($Depth - 1)
            switch ($node.Kind) {
                'inline' { "$pad${key}: $($node.Text)" }
                'block' { @("$pad${key}: |") + $node.Lines }
                'nested' { @("$pad${key}:") + $node.Lines }
            }
        }
        return [pscustomobject]@{ Kind = 'nested'; Lines = @($lines) }
    }

    if ($Value -is [System.Collections.IEnumerable]) {
        $items = @($Value)
        if ($items.Count -eq 0) { return [pscustomobject]@{ Kind = 'inline'; Text = '[]' } }
        $childPad = ' ' * ($Level + 1)
        $lines = foreach ($item in $items) {
            $node = ConvertTo-LdoYamlNode -Value $item -Level ($Level + 1) -Depth ($Depth - 1)
            switch ($node.Kind) {
                'inline' { "$pad- $($node.Text)" }
                'block' { @("$pad- |") + $node.Lines }
                'nested' {
                    # Fold the first nested line onto the dash so maps in sequences read naturally.
                    $nested = @($node.Lines)
                    @("$pad- " + $nested[0].Substring($childPad.Length)) + @($nested | Select-Object -Skip 1)
                }
            }
        }
        return [pscustomobject]@{ Kind = 'nested'; Lines = @($lines) }
    }

    return [pscustomobject]@{ Kind = 'inline'; Text = (Format-LdoYamlScalar -Value ([string]$Value)) }
}

function ConvertTo-LdoYaml {
    <#
    .SYNOPSIS
        Renders an object as clean, human first YAML.

    .DESCRIPTION
        A PowerShell native YAML emitter for the estate's analyst facing files: two space
        indentation, literal block scalars for multiline strings (KQL reads as written),
        minimal quoting, deterministic key order (use [ordered] hashtables or
        PSCustomObjects), and inline {} / [] for empty collections. Pair with
        ConvertFrom-LdoYaml for round trips.

    .PARAMETER InputObject
        The object to render (typically an [ordered] hashtable or PSCustomObject).

    .PARAMETER Depth
        Maximum nesting depth. Defaults to 32.

    .EXAMPLE
        [ordered]@{ display_name = 'Rule'; query = "A`n| take 1" } | ConvertTo-LdoYaml

    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)][AllowNull()]$InputObject,
        [int]$Depth = 32
    )

    process {
        $node = ConvertTo-LdoYamlNode -Value $InputObject -Level 0 -Depth $Depth
        $text = switch ($node.Kind) {
            'inline' { $node.Text }
            'block' { (@('|') + $node.Lines) -join "`n" }
            'nested' { ($node.Lines -join "`n") }
        }
        return "$text`n"
    }
}