Build-FunctionDocs.ps1

# Build-FunctionDocs.ps1
# Regenerates cloudstack-ps.md from the module's comment-based help.
#
# pwsh -NoProfile -File ./cloudstack-ps/Build-FunctionDocs.ps1 # rewrite the docs if anything changed
# pwsh -NoProfile -File ./cloudstack-ps/Build-FunctionDocs.ps1 -Check # exit 1 if the docs are out of date
#
# The file is only rewritten when something other than the 'Generated:' timestamp
# changes, so running this on every build does not create noise in git.
[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string]$ModulePath = $PSScriptRoot,

    [Parameter(Mandatory = $false)]
    [string]$OutputPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'cloudstack-ps.md'),

    # Optional list of API commands and descriptions, used as the synopsis for
    # functions that have no comment-based help yet.
    [Parameter(Mandatory = $false)]
    [string]$ApiEndpointsPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'api-endpoints.json'),

    [switch]$Check
)

$ErrorActionPreference = 'Stop'

function Get-ApiDescriptions {
    param([string]$Path)

    $map = @{}
    if (-not (Test-Path $Path)) { return $map }

    # api-endpoints.json is a stream of { "description", "name" } objects rather
    # than one JSON array, so pull the pairs out individually.
    $text = [System.IO.File]::ReadAllText($Path)
    foreach ($match in [regex]::Matches($text, '\{[^{}]*\}')) {
        try { $entry = $match.Value | ConvertFrom-Json } catch { continue }
        if ($entry.name -and $entry.description) { $map[[string]$entry.name] = [string]$entry.description }
    }
    return $map
}

function Get-ApiCommands {
    param([string]$Definition)

    # Direct calls (-Command 'listVolumes' / -Command listClusters) and the older
    # hashtable style (command = "addNicToVirtualMachine").
    $found = [System.Collections.Generic.List[string]]::new()
    $patterns = @(
        '-Command\s+[''"]?([A-Za-z0-9]+)',
        '\bcommand\s*=\s*[''"]([A-Za-z0-9]+)[''"]'
    )
    foreach ($pattern in $patterns) {
        foreach ($match in [regex]::Matches($Definition, $pattern)) {
            $name = $match.Groups[1].Value
            # queryAsyncJobResult is plumbing for -Wait, not what the function wraps.
            if ($name -ne 'queryAsyncJobResult' -and -not $found.Contains($name)) { $found.Add($name) }
        }
    }
    return $found
}

function Format-HelpText {
    <#
        Renders a comment-based help section as markdown. Lines at the base
        indentation are joined into paragraphs; indented lines (with or without a
        leading '-') become list items, and deeper-indented lines continue the
        list item above them.
    #>

    param([string]$Text)

    if ([string]::IsNullOrWhiteSpace($Text)) { return '' }

    $blocks = [System.Collections.Generic.List[string]]::new()
    $paragraph = [System.Collections.Generic.List[string]]::new()
    $items = [System.Collections.Generic.List[string]]::new()
    $itemIndent = -1

    $flushParagraph = {
        if ($paragraph.Count -gt 0) { $blocks.Add(($paragraph -join ' ')); $paragraph.Clear() }
    }
    $flushItems = {
        if ($items.Count -gt 0) { $blocks.Add((($items | ForEach-Object { "- $_" }) -join "`n")); $items.Clear() }
        Set-Variable -Name itemIndent -Value -1 -Scope 1
    }

    foreach ($rawLine in (($Text -replace "`r", '') -split "`n")) {
        $trimmed = $rawLine.Trim()
        if ($trimmed -eq '') { & $flushParagraph; & $flushItems; continue }

        $indent = $rawLine.Length - $rawLine.TrimStart().Length
        if ($indent -eq 0) {
            & $flushItems
            $paragraph.Add($trimmed)
            continue
        }

        & $flushParagraph
        $isBullet = $trimmed.StartsWith('- ')
        if (-not $isBullet -and $items.Count -gt 0 -and $indent -gt $itemIndent) {
            $items[$items.Count - 1] += " $trimmed"
            continue
        }
        $items.Add($(if ($isBullet) { $trimmed.Substring(2).Trim() } else { $trimmed }))
        # Continuation lines of a '- item' are indented past the '- ' marker.
        $itemIndent = if ($isBullet) { $indent + 1 } else { $indent }
    }
    & $flushParagraph
    & $flushItems

    return ($blocks -join "`n`n")
}

function Get-InlineHelp {
    <#
        PowerShell's help parser ignores help written as a single-line comment
        block (SYNOPSIS/EXAMPLE keywords all on one line between the block
        comment markers), which several older functions use. Read the
        keywords out of that form here.
    #>

    param([string]$Definition)

    $comment = [regex]::Match($Definition, '(?s)<#(.*?)#>')
    if (-not $comment.Success -or $comment.Groups[1].Value -notmatch '\.SYNOPSIS') { return $null }

    $result = [pscustomobject]@{ Synopsis = $null; Description = $null; Examples = [System.Collections.Generic.List[string]]::new() }
    $pattern = '\.(SYNOPSIS|DESCRIPTION|EXAMPLE)\s+(.*?)(?=\s+\.(?:SYNOPSIS|DESCRIPTION|EXAMPLE|PARAMETER|NOTES|LINK)\b|\s*$)'
    foreach ($match in [regex]::Matches($comment.Groups[1].Value, $pattern, 'Singleline')) {
        $value = $match.Groups[2].Value.Trim()
        switch ($match.Groups[1].Value) {
            'SYNOPSIS' { $result.Synopsis = $value }
            'DESCRIPTION' { $result.Description = $value }
            'EXAMPLE' { $result.Examples.Add($value) }
        }
    }
    return $result
}

function Test-ProseLine {
    # True when an example line reads as the example's description rather than
    # code. The module's convention is: code lines first, then a sentence
    # describing them ("Lists every volume in your account.").
    param([string]$Line)

    if ($Line -match '^\s') { return $false }                          # indented: continuation of code
    $trimmed = $Line.Trim()
    if ($trimmed -match '^[#$(\[{}"''@|.&!<>]') { return $false }      # comment, variable, expression...
    if ($trimmed -match '^(if|elseif|else|foreach|for|while|do|switch|try|catch|finally|function|param|return)\s*[\({]') { return $false }
    $firstWord = ($trimmed -split '\s+')[0]
    if ($firstWord -match '^[A-Za-z]+-[A-Za-z]') { return $false }     # Verb-Noun command
    return ($trimmed -match '^[A-Z][a-z]+\b')
}

function Split-Example {
    param([string]$Text)

    $code = [System.Collections.Generic.List[string]]::new()
    $description = [System.Collections.Generic.List[string]]::new()
    foreach ($line in (($Text -replace "`r", '').TrimEnd() -split "`n")) {
        if ($description.Count -gt 0) { $description.Add($line.Trim()); continue }
        $hasCode = @($code | Where-Object { $_.Trim() }).Count -gt 0
        if ($hasCode -and $line.Trim() -and (Test-ProseLine $line)) { $description.Add($line.Trim()); continue }
        $code.Add($line.TrimEnd())
    }
    while ($code.Count -gt 0 -and -not $code[$code.Count - 1].Trim()) { $code.RemoveAt($code.Count - 1) }
    while ($code.Count -gt 0 -and -not $code[0].Trim()) { $code.RemoveAt(0) }

    [pscustomobject]@{
        Code = ($code -join "`n")
        Description = (($description | Where-Object { $_ }) -join ' ')
    }
}

function Format-Cell {
    param([string]$Text)
    if ([string]::IsNullOrEmpty($Text)) { return '-' }
    return $Text.Replace('|', '\|')
}

# --- Load the module the same way a user would ---------------------------------
$manifestPath = Join-Path $ModulePath 'cloudstack-ps.psd1'
$module = Import-Module $manifestPath -Force -PassThru -WarningAction SilentlyContinue
try {
    $apiDescriptions = Get-ApiDescriptions -Path $ApiEndpointsPath
    $commonParameters = [System.Management.Automation.Cmdlet]::CommonParameters +
        [System.Management.Automation.Cmdlet]::OptionalCommonParameters

    $aliasesByCommand = @{}
    foreach ($alias in $module.ExportedAliases.Values) {
        $target = $alias.ResolvedCommandName
        if (-not $aliasesByCommand.ContainsKey($target)) { $aliasesByCommand[$target] = [System.Collections.Generic.List[string]]::new() }
        $aliasesByCommand[$target].Add($alias.Name)
    }

    $functions = @($module.ExportedFunctions.Values | Sort-Object Name)
    $sections = foreach ($command in $functions) {
        $functionAst = $command.ScriptBlock.Ast
        $help = $functionAst.GetHelpContent()
        if (-not $help) { $help = Get-InlineHelp -Definition $command.Definition }
        $apiCommands = @(Get-ApiCommands -Definition $command.Definition)
        $lines = [System.Collections.Generic.List[string]]::new()

        $lines.Add("## $($command.Name)")
        $lines.Add('')
        $lines.Add("**Source File:** ``$(Split-Path $command.ScriptBlock.File -Leaf)``")

        $synopsis = if ($help -and $help.Synopsis) { ($help.Synopsis -replace '\s+', ' ').Trim() } else { $null }
        if (-not $synopsis -and $apiCommands.Count -gt 0 -and $apiDescriptions[$apiCommands[0]]) {
            $synopsis = $apiDescriptions[$apiCommands[0]].Trim()
            if ($synopsis -notmatch '[.!?]$') { $synopsis += '.' }
        }
        if (-not $synopsis -and $apiCommands.Count -gt 0) { $synopsis = "Calls the CloudStack $($apiCommands[0]) API." }
        if (-not $synopsis) { $synopsis = '_No synopsis yet._' }
        $lines.Add("**Synopsis:** $synopsis")

        if ($aliasesByCommand.ContainsKey($command.Name)) {
            $names = ($aliasesByCommand[$command.Name] | Sort-Object | ForEach-Object { "``$_``" }) -join ', '
            $lines.Add("**Deprecated aliases:** $names")
        }
        $lines.Add('')

        if ($help -and $help.Description) {
            $lines.Add("**Description:** $(Format-HelpText $help.Description)")
            $lines.Add('')
        }

        if ($apiCommands.Count -gt 0) {
            $apiText = ($apiCommands | ForEach-Object { '`' + $_ + '`' }) -join ', '
            $lines.Add("**API Command:** $apiText")
            $lines.Add('')
        }

        $binding = $command.ScriptBlock.Attributes | Where-Object { $_ -is [System.Management.Automation.CmdletBindingAttribute] } | Select-Object -First 1
        if ($binding) {
            $bindingLine = '**CmdletBinding:** Yes'
            if ($binding.SupportsShouldProcess) {
                $bindingLine += " | **SupportsShouldProcess:** Yes | **ConfirmImpact:** $($binding.ConfirmImpact)"
            }
        }
        else {
            $bindingLine = '**CmdletBinding:** No'
        }
        $lines.Add($bindingLine)
        $lines.Add('')

        # Default values only exist in the source, so read them from the AST.
        $defaults = @{}
        $parameterAsts = if ($functionAst.Body.ParamBlock) { $functionAst.Body.ParamBlock.Parameters } else { $functionAst.Parameters }
        foreach ($parameterAst in @($parameterAsts)) {
            if ($parameterAst -and $parameterAst.DefaultValue) {
                $defaults[$parameterAst.Name.VariablePath.UserPath] = $parameterAst.DefaultValue.Extent.Text
            }
        }

        $parameters = @($command.Parameters.Values | Where-Object { $commonParameters -notcontains $_.Name } | Sort-Object Name)
        if ($parameters.Count -gt 0) {
            $lines.Add('### Parameters')
            $lines.Add('')
            $lines.Add('| Parameter | Type | Mandatory | Pipeline | Aliases | ValidateSet | Default |')
            $lines.Add('|-----------|------|-----------|----------|---------|-------------|---------|')
            foreach ($parameter in $parameters) {
                $parameterAttributes = @($parameter.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] })
                $mandatory = [bool]($parameterAttributes | Where-Object Mandatory)
                $pipeline = @()
                if ($parameterAttributes | Where-Object ValueFromPipeline) { $pipeline += 'ByValue' }
                if ($parameterAttributes | Where-Object ValueFromPipelineByPropertyName) { $pipeline += 'ByPropertyName' }
                $validateSet = $parameter.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -First 1
                $setText = if ($validateSet) { ($validateSet.ValidValues | ForEach-Object { "``$_``" }) -join ', ' } else { '' }
                $aliasText = ($parameter.Aliases | ForEach-Object { "``$_``" }) -join ', '
                $defaultText = if ($defaults.ContainsKey($parameter.Name)) { "``$($defaults[$parameter.Name])``" } else { '' }

                $lines.Add(('| **{0}** | `{1}` | {2} | {3} | {4} | {5} | {6} |' -f
                    $parameter.Name, $parameter.ParameterType.Name, $mandatory,
                    (Format-Cell ($pipeline -join ', ')), (Format-Cell $aliasText),
                    (Format-Cell $setText), (Format-Cell $defaultText)))
            }
            $lines.Add('')
        }

        $examples = if ($help) { @($help.Examples | Where-Object { $_ -and $_.Trim() }) } else { @() }
        if ($examples.Count -gt 0) {
            $lines.Add('### Examples')
            $lines.Add('')
            $number = 0
            foreach ($example in $examples) {
                $number++
                $parts = Split-Example $example
                $title = if ($parts.Description) { "**Example $number - $($parts.Description)**" } else { "**Example $number**" }
                $lines.Add($title)
                $lines.Add('```powershell')
                $lines.Add($parts.Code)
                $lines.Add('```')
                $lines.Add('')
            }
        }

        $lines.Add('---')
        $lines.Add('')
        $lines -join "`n"
    }

    $body = @(
        ''
        "Total Functions: $($functions.Count)"
        ''
        ($sections -join "`n")
    ) -join "`n"
    $title = '# CloudStack PowerShell Module Function Documentation'
}
finally {
    Remove-Module $module -Force -ErrorAction SilentlyContinue
}

# --- Write only when the content (ignoring the timestamp) changed ------------
$stripTimestamp = { param($text) ($text -replace "`r", '') -replace '(?m)^Generated: .*\n', '' }
$existing = if (Test-Path $OutputPath) { [System.IO.File]::ReadAllText($OutputPath) } else { '' }
$newContent = "$title`nGenerated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`n$body"

if ((& $stripTimestamp $existing) -ceq (& $stripTimestamp $newContent)) {
    Write-Host "Docs are up to date: $OutputPath ($($functions.Count) functions)" -ForegroundColor Green
    return
}

if ($Check) {
    Write-Host "Docs are out of date: $OutputPath. Run cloudstack-ps/Build-FunctionDocs.ps1 to regenerate them." -ForegroundColor Red
    exit 1
}

# UTF-8 without a BOM and LF line endings, matching the existing file.
[System.IO.File]::WriteAllText($OutputPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Wrote $OutputPath ($($functions.Count) functions)" -ForegroundColor Green