LibreDevOpsHelpers.TerraformDocs/LibreDevOpsHelpers.TerraformDocs.psm1

Set-StrictMode -Version Latest

function Format-LdoTerraform {
    <#
    .SYNOPSIS
        Runs 'terraform fmt -recursive' against a configuration folder.

    .DESCRIPTION
        Confirms terraform is on PATH, then formats all Terraform files beneath the folder.
        Throws on failure. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .EXAMPLE
        Format-LdoTerraform -CodePath ./terraform

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath
    )

    if (-not (Test-Path $CodePath)) {
        throw "Terraform code not found: $CodePath"
    }

    $orig = Get-Location
    try {
        Assert-LdoCommand -Name 'terraform'
        Set-Location $CodePath
        & terraform fmt -recursive
        Assert-LdoLastExitCode -Operation 'terraform fmt -recursive'
        Write-LdoLog -Level INFO -Message 'Terraform files formatted (fmt -recursive).'
    }
    finally {
        Set-Location $orig
    }
}

function Get-LdoTerraformFileContent {
    <#
    .SYNOPSIS
        Reads a Terraform file and returns its raw content.

    .DESCRIPTION
        Returns the full text of a file, throwing when the file does not exist.

    .PARAMETER Filename
        Path to the file to read.

    .EXAMPLE
        Get-LdoTerraformFileContent -Filename ./variables.tf

    .OUTPUTS
        System.String
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Filename)

    if (-not (Test-Path $Filename)) {
        throw "File not found: $Filename"
    }
    return Get-Content -Raw -LiteralPath $Filename
}

function Set-LdoTerraformFileContent {
    <#
    .SYNOPSIS
        Writes content to a Terraform file.

    .DESCRIPTION
        Overwrites the named file with the supplied content.

    .PARAMETER Filename
        Path to the file to write.

    .PARAMETER Content
        Text content to write.

    .EXAMPLE
        Set-LdoTerraformFileContent -Filename ./variables.tf -Content $text

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Filename,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Content
    )

    $Content | Set-Content -LiteralPath $Filename -Encoding utf8
}

function Format-LdoTerraformVariables {
    <#
    .SYNOPSIS
        Sorts variable blocks in variables.tf content alphabetically.

    .DESCRIPTION
        Parses variable "name" { ... } blocks from the supplied content and returns them sorted
        by variable name, separated by blank lines.

    .PARAMETER VariablesContent
        Raw content of a variables.tf file.

    .EXAMPLE
        Format-LdoTerraformVariables -VariablesContent (Get-Content ./variables.tf -Raw)

    .OUTPUTS
        System.String
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'Operates on Terraform variable blocks.')]
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$VariablesContent)

    $pattern = 'variable\s+"[^"]+"\s+\{[\s\S]*?\n\}'
    $blocks = [regex]::Matches($VariablesContent, $pattern) | ForEach-Object { $_.Value }
    $sorted = $blocks | Sort-Object { ([regex]::Match($_, 'variable\s+"([^"]+)"')).Groups[1].Value }
    return ($sorted -join "`n`n")
}

function Format-LdoTerraformOutputs {
    <#
    .SYNOPSIS
        Sorts output blocks in outputs.tf content alphabetically.

    .DESCRIPTION
        Parses output "name" { ... } blocks from the supplied content and returns them sorted by
        output name, separated by blank lines.

    .PARAMETER OutputsContent
        Raw content of an outputs.tf file.

    .EXAMPLE
        Format-LdoTerraformOutputs -OutputsContent (Get-Content ./outputs.tf -Raw)

    .OUTPUTS
        System.String
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'Operates on Terraform output blocks.')]
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$OutputsContent)

    $pattern = 'output\s+"[^"]+"\s+\{[\s\S]*?\n\}'
    $blocks = [regex]::Matches($OutputsContent, $pattern) | ForEach-Object { $_.Value }
    $sorted = $blocks | Sort-Object { ([regex]::Match($_, 'output\s+"([^"]+)"')).Groups[1].Value }
    return ($sorted -join "`n`n")
}

function Format-LdoTerraformCode {
    <#
    .SYNOPSIS
        Formats Terraform code and alphabetises variables.tf and outputs.tf.

    .DESCRIPTION
        Runs terraform fmt -recursive, then sorts the variable and output blocks in the named
        files (when present and non-empty) so the declarations are kept in a consistent order.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER VariablesFile
        Variables file name within the folder. Defaults to variables.tf.

    .PARAMETER OutputsFile
        Outputs file name within the folder. Defaults to outputs.tf.

    .EXAMPLE
        Format-LdoTerraformCode -CodePath ./terraform

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$VariablesFile = 'variables.tf',
        [string]$OutputsFile = 'outputs.tf'
    )

    Format-LdoTerraform -CodePath $CodePath

    $varPath = Join-Path $CodePath $VariablesFile
    if (Test-Path $varPath) {
        $varsContent = Get-LdoTerraformFileContent -Filename $varPath
        if (-not [string]::IsNullOrWhiteSpace($varsContent)) {
            $sortedVars = Format-LdoTerraformVariables -VariablesContent $varsContent
            if (-not [string]::IsNullOrWhiteSpace($sortedVars)) {
                Set-LdoTerraformFileContent -Filename $varPath -Content $sortedVars
                Write-LdoLog -Level INFO -Message "Sorted variables in $varPath"
            }
            else {
                Write-LdoLog -Level INFO -Message "No variable blocks found to sort in $varPath; skipping write."
            }
        }
        else {
            Write-LdoLog -Level INFO -Message "File $varPath is empty; skipping variable sort."
        }
    }

    $outPath = Join-Path $CodePath $OutputsFile
    if (Test-Path $outPath) {
        $outContent = Get-LdoTerraformFileContent -Filename $outPath
        if (-not [string]::IsNullOrWhiteSpace($outContent)) {
            $sortedOut = Format-LdoTerraformOutputs -OutputsContent $outContent
            if (-not [string]::IsNullOrWhiteSpace($sortedOut)) {
                Set-LdoTerraformFileContent -Filename $outPath -Content $sortedOut
                Write-LdoLog -Level INFO -Message "Sorted outputs in $outPath"
            }
            else {
                Write-LdoLog -Level INFO -Message "No output blocks found to sort in $outPath; skipping write."
            }
        }
        else {
            Write-LdoLog -Level INFO -Message "File $outPath is empty; skipping output sort."
        }
    }
}

function Set-LdoReadmeHeader {
    <#
    .SYNOPSIS
        Writes a hand-authored markdown header above the terraform-docs injection markers.

    .DESCRIPTION
        Writes the supplied header content to the README, followed by the
        <!-- BEGIN_TF_DOCS --> / <!-- END_TF_DOCS --> markers that terraform-docs injects
        between. Run Update-LdoReadmeWithTerraformDocs afterwards to populate the section
        between the markers. When the header is empty, only the markers are written.

        Resulting README structure:

            [Your hand-authored header: title, description, usage example]

            <!-- BEGIN_TF_DOCS -->
            <!-- END_TF_DOCS -->

    .PARAMETER Header
        Markdown content to place above the markers. Pass an empty string to write
        markers-only.

    .PARAMETER ReadmeFile
        README file path to write. Defaults to README.md.

    .EXAMPLE
        Set-LdoReadmeHeader -Header (Get-Content ./HEADER.md -Raw)

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Header,
        [string]$ReadmeFile = 'README.md'
    )

    $markers = "<!-- BEGIN_TF_DOCS -->`n<!-- END_TF_DOCS -->`n"
    $body = if ($Header.Trim()) {
        $Header.TrimEnd() + "`n`n" + $markers
    }
    else {
        $markers
    }

    Set-Content -LiteralPath $ReadmeFile -Value $body -Encoding utf8 -NoNewline
    Write-LdoLog -Level INFO -Message "Wrote README header to $ReadmeFile."
}

function Update-LdoReadmeWithTerraformDocs {
    <#
    .SYNOPSIS
        Regenerates the terraform-docs section of a README for a Terraform folder.

    .DESCRIPTION
        Brings a module README in line with the Libre DevOps Terraform standard. When a header
        is supplied (inline or from a HEADER.md file), it writes that hand-authored header above
        the <!-- BEGIN_TF_DOCS --> / <!-- END_TF_DOCS --> markers, then runs terraform-docs in
        inject mode so only the content between the markers is regenerated and the header is
        preserved on every run. When a .terraform-docs.yml is present it is used automatically
        ('terraform-docs .'); otherwise a markdown table is injected. Skips with a warning when
        terraform-docs is not installed. The original working directory is always restored.

    .PARAMETER CodePath
        Path to the Terraform configuration folder.

    .PARAMETER ReadmeFile
        README file name within the folder. Defaults to README.md.

    .PARAMETER ReadmeHeader
        Hand-authored markdown header written above the markers. Ignored when ReadmeHeaderFile
        is supplied.

    .PARAMETER ReadmeHeaderFile
        Path to a markdown file (for example HEADER.md) used as the README header. Resolved
        relative to CodePath when not absolute. Throws when supplied but not found.

    .EXAMPLE
        Update-LdoReadmeWithTerraformDocs -CodePath ./terraform

    .EXAMPLE
        Update-LdoReadmeWithTerraformDocs -CodePath . -ReadmeHeaderFile HEADER.md

    .OUTPUTS
        None
    #>

    [Diagnostics.CodeAnalysis.SuppressMessage('PSUseSingularNouns', '', Justification = 'terraform-docs is a tool name.')]
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$CodePath,
        [string]$ReadmeFile = 'README.md',
        [string]$ReadmeHeader = '',
        [string]$ReadmeHeaderFile = ''
    )

    if (-not (Test-Path $CodePath)) {
        throw "Terraform code not found: $CodePath"
    }

    $orig = Get-Location
    try {
        Set-Location $CodePath

        # Resolve and validate the header first: a supplied-but-missing header file is a caller
        # error and must throw regardless of whether terraform-docs is installed. A header file
        # takes precedence over an inline header.
        $resolvedHeader = ''
        if ($ReadmeHeaderFile) {
            if (-not (Test-Path $ReadmeHeaderFile -PathType Leaf)) {
                throw "README header file not found: $ReadmeHeaderFile"
            }
            $resolvedHeader = Get-Content -Raw -LiteralPath $ReadmeHeaderFile
            Write-LdoLog -Level INFO -Message "Using README header from $ReadmeHeaderFile."
        }
        elseif ($ReadmeHeader) {
            $resolvedHeader = $ReadmeHeader
        }

        try {
            $td = Get-Command terraform-docs -ErrorAction Stop
            Write-LdoLog -Level INFO -Message "terraform-docs found at '$($td.Source)'"
        }
        catch {
            Write-LdoLog -Level WARN -Message 'terraform-docs not installed; README generation skipped.'
            return
        }

        # Write the hand-authored header plus markers. When no header is supplied, only ensure
        # the markers exist so terraform-docs has somewhere to inject without clobbering any
        # existing hand-authored content above them.
        if ($resolvedHeader) {
            Set-LdoReadmeHeader -Header $resolvedHeader -ReadmeFile $ReadmeFile
        }
        elseif (-not (Test-Path $ReadmeFile)) {
            Set-LdoReadmeHeader -Header '' -ReadmeFile $ReadmeFile
        }

        if (Test-Path '.terraform-docs.yml') {
            Write-LdoLog -Level INFO -Message 'Injecting terraform-docs output using .terraform-docs.yml.'
            terraform-docs .
        }
        else {
            Write-LdoLog -Level INFO -Message 'Injecting terraform-docs markdown table (no .terraform-docs.yml found).'
            terraform-docs markdown table --output-file $ReadmeFile --output-mode inject .
        }
        Assert-LdoLastExitCode -Operation 'terraform-docs'

        Write-LdoLog -Level SUCCESS -Message "Updated $ReadmeFile."
    }
    finally {
        Set-Location $orig
    }
}

Export-ModuleMember -Function `
    Format-LdoTerraform, `
    Format-LdoTerraformCode, `
    Get-LdoTerraformFileContent, `
    Set-LdoTerraformFileContent, `
    Format-LdoTerraformVariables, `
    Format-LdoTerraformOutputs, `
    Set-LdoReadmeHeader, `
    Update-LdoReadmeWithTerraformDocs