Private/Read-NSPFortiGateConfigTree.ps1

function Read-NSPFortiGateConfigTree {
    <#
    .SYNOPSIS
        Builds the config/edit/set tree from FortiOS CLI output lines.
    .DESCRIPTION
        Returns the root sections. Each section has Path, Vdom, LineNumber,
        Settings, Entries, and Sections; each entry has Name, Vdom, LineNumber,
        Settings, and Sections. Settings values are string arrays.

        Console noise is tolerated: terminal wraps are rejoined and grep
        markers removed (Join-NSPFortiGateWrappedLine), prompt lines outside a
        block are skipped, and a quoted value spanning lines (a certificate,
        a multi-line comment) keeps its line breaks.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line)

    $roots = New-Object System.Collections.Generic.List[object]
    $stack = New-Object System.Collections.Generic.Stack[object]
    $keywords = @('config', 'edit', 'set', 'unset', 'append', 'select', 'unselect', 'next', 'end')
    $pending = $null
    $startLine = 0

    foreach ($logical in @(Join-NSPFortiGateWrappedLine -Line $Line) + , $null) {
        if ($null -ne $logical) {
            if ($null -ne $pending) {
                $text = $pending + "`n" + $logical.Text
            } else {
                $text = $logical.Text
                $startLine = $logical.LineNumber
            }
            $parsed = Split-NSPFortiGateToken -Text $text
            # Only a set/append line may open a multi-line quoted value; a stray quote in a prompt must not swallow the file.
            if ($parsed.Unterminated -and ($null -ne $pending -or $text -match '^\s*(set|append)\s')) {
                $pending = $text
                continue
            }
        } elseif ($null -ne $pending) {
            Write-Warning "Unterminated quoted value starting at line $startLine."
            $parsed = Split-NSPFortiGateToken -Text $pending
        } else {
            break
        }
        $pending = $null

        $tokens = $parsed.Tokens
        if ($tokens.Count -eq 0) { continue }
        $command = $tokens[0].ToLowerInvariant()
        $top = if ($stack.Count) { $stack.Peek() } else { $null }

        if ($keywords -notcontains $command) {
            if ($null -ne $top) { Write-Verbose "Skipped unrecognized line $startLine`: $($tokens -join ' ')" }
            continue
        }

        switch ($command) {
            'config' {
                if ($tokens.Count -lt 2) { Write-Verbose "Skipped 'config' without a path at line $startLine."; break }
                $path = ($tokens[1..($tokens.Count - 1)] -join ' ')
                $vdom = ''
                if ($null -ne $top) {
                    $vdom = $top.Node.Vdom
                    if ($top.Kind -eq 'Entry' -and $top.SectionPath -eq 'vdom') { $vdom = $top.Node.Name }
                    elseif ($top.Kind -eq 'Section' -and $top.Node.Path -eq 'global') { $vdom = 'global' }
                }
                $section = [pscustomobject]@{
                    PSTypeName = 'NSP.FortiGate.ConfigSection'
                    Path = $path
                    Vdom = $vdom
                    LineNumber = $startLine
                    Settings = [ordered]@{}
                    Entries = New-Object System.Collections.Generic.List[object]
                    Sections = New-Object System.Collections.Generic.List[object]
                }
                if ($null -eq $top) { $roots.Add($section) } else { $top.Node.Sections.Add($section) }
                $stack.Push(@{ Kind = 'Section'; Node = $section })
            }
            'edit' {
                if ($null -eq $top -or $top.Kind -ne 'Section') { Write-Verbose "Skipped 'edit' outside a config block at line $startLine."; break }
                $entry = [pscustomobject]@{
                    PSTypeName = 'NSP.FortiGate.ConfigEntry'
                    Name = if ($tokens.Count -gt 1) { $tokens[1] } else { '' }
                    Vdom = $top.Node.Vdom
                    LineNumber = $startLine
                    Settings = [ordered]@{}
                    Sections = New-Object System.Collections.Generic.List[object]
                }
                $top.Node.Entries.Add($entry)
                $stack.Push(@{ Kind = 'Entry'; Node = $entry; SectionPath = $top.Node.Path })
            }
            'next' {
                if ($null -ne $top -and $top.Kind -eq 'Entry') { [void]$stack.Pop() }
            }
            'end' {
                if ($null -ne $top -and $top.Kind -eq 'Entry') { [void]$stack.Pop() }
                if ($stack.Count -and $stack.Peek().Kind -eq 'Section') { [void]$stack.Pop() }
            }
            default {
                if ($null -eq $top -or $tokens.Count -lt 2) { break }
                $settings = $top.Node.Settings
                $key = $tokens[1]
                $values = if ($tokens.Count -gt 2) { $tokens[2..($tokens.Count - 1)] } else { @() }
                $existing = if ($settings.Contains($key)) { @($settings[$key]) } else { @() }
                switch ($command) {
                    'unset' { $settings[$key] = [string[]]@() }
                    'append' { $settings[$key] = [string[]]($existing + $values) }
                    'unselect' { $settings[$key] = [string[]]@($existing | Where-Object { $values -notcontains $_ }) }
                    default { $settings[$key] = [string[]]$values }
                }
            }
        }
    }

    if ($stack.Count) { Write-Warning 'Input ended inside a config block without a matching end; output may be incomplete.' }
    , $roots.ToArray()
}