Private/DeployPlan.ps1

# DeployPlan.ps1
# Shared plan artifact + rendering for every Plan/Apply cmdlet (ADR 0009, extends ADR 0002).
#
# One structured plan object, one renderer used by BOTH -Plan and -Apply (so -Apply shows
# exactly what -Plan shows — ADR 0002 §"Confirmation flow" step 1), and one markdown report
# writer used by -Plan only.
#
# Contract:
# $plan = New-DeployPlan -Cmdlet 'Publish-FlutterWeb' -Target 'prod (1.2.3.4)' `
# -Sections $sections -Actions $actions
# Show-DeployPlan -Plan $plan # -> screen (Plan AND Apply)
# $report = Save-DeployPlan -Plan $plan -ProjectRoot $cwd # -> file (Plan only)
#
# A section is an [ordered] map of label -> value, where value is a plain string or a row
# built with New-DeployPlanRow (text + severity level). Level drives color on screen and a
# marker in the markdown report.

$script:DeployPlanColors = @{
    info  = 'White'
    ok    = 'Green'
    warn  = 'Yellow'
    error = 'Red'
    muted = 'DarkGray'
}

$script:DeployPlanMarkers = @{
    info  = ''
    ok    = 'OK '
    warn  = 'WARN '
    error = 'FAIL '
    muted = ''
}

function New-DeployPlanRow {
    <#
    .SYNOPSIS
        A single plan value with a severity level (drives color on screen / marker in md).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Text,
        [ValidateSet('info', 'ok', 'warn', 'error', 'muted')][string]$Level = 'info'
    )
    return [pscustomobject]@{ Text = $Text; Level = $Level }
}

function ConvertTo-DeployPlanRow {
    <#
    .SYNOPSIS
        Normalizes a section value (plain string or a row object) into a row object.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowNull()]$Value)

    if ($null -eq $Value) { return (New-DeployPlanRow -Text '' -Level 'muted') }
    if ($Value -is [psobject] -and $Value.PSObject.Properties['Text'] -and $Value.PSObject.Properties['Level']) {
        return $Value
    }
    return (New-DeployPlanRow -Text ([string]$Value) -Level 'info')
}

function New-DeployPlan {
    <#
    .SYNOPSIS
        Builds the common, cmdlet-agnostic plan object consumed by Show-/Save-DeployPlan.
    .PARAMETER Cmdlet
        The cmdlet the plan belongs to (e.g. 'Publish-FlutterWeb').
    .PARAMETER Target
        Human-readable deploy target (e.g. 'prod (192.168.10.18)').
    .PARAMETER Sections
        [ordered] map of section-name -> ([ordered] map of label -> string|row).
    .PARAMETER Actions
        Ordered list of the concrete steps -Apply will perform.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Cmdlet,
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][System.Collections.Specialized.OrderedDictionary]$Sections,
        [string[]]$Actions = @()
    )
    return [pscustomobject]@{
        Cmdlet   = $Cmdlet
        Target   = $Target
        Sections = $Sections
        Actions  = @($Actions)
    }
}

function Show-DeployPlan {
    <#
    .SYNOPSIS
        Renders a plan object to the screen. Used by BOTH -Plan and -Apply so that the
        preview -Apply confirms against is identical to what -Plan shows.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)]$Plan)

    Write-Host ""
    Write-Host " Plan: $($Plan.Cmdlet) -> $($Plan.Target)" -ForegroundColor Cyan

    foreach ($sectionName in $Plan.Sections.Keys) {
        Write-Host ""
        Write-Host " --- $sectionName ---" -ForegroundColor Cyan
        $rows = $Plan.Sections[$sectionName]
        foreach ($label in $rows.Keys) {
            $row = ConvertTo-DeployPlanRow $rows[$label]
            $color = $script:DeployPlanColors[$row.Level]
            if (-not $color) { $color = 'White' }
            Write-Host (" {0} {1}" -f ("$($label):").PadRight(12), $row.Text) -ForegroundColor $color
        }
    }

    if ($Plan.Actions.Count -gt 0) {
        Write-Host ""
        Write-Host " --- Acciones que realizará -Apply ---" -ForegroundColor Cyan
        for ($i = 0; $i -lt $Plan.Actions.Count; $i++) {
            Write-Host (" {0}. {1}" -f ($i + 1), $Plan.Actions[$i]) -ForegroundColor White
        }
    }
    Write-Host ""
}

function Get-DeployPlanBlocker {
    <#
    .SYNOPSIS
        Returns the 'error'-level rows of a plan as "Label: Text" strings; empty array if none.
    .DESCRIPTION
        An 'error' row means the plan already knows the apply will fail (e.g. the nginx port is
        taken). -Apply uses this to stop BEFORE doing expensive work instead of building and
        uploading toward a late failure — with -AutoApprove nobody is reading the red line.
 
        Emits the blockers to the output stream (0..N strings), so callers MUST wrap the call
        in @() to count them: a single blocker arrives as a scalar and none as $null.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([Parameter(Mandatory)]$Plan)

    $blockers = New-Object System.Collections.Generic.List[string]
    foreach ($sectionName in $Plan.Sections.Keys) {
        $rows = $Plan.Sections[$sectionName]
        foreach ($label in $rows.Keys) {
            $row = ConvertTo-DeployPlanRow $rows[$label]
            if ($row.Level -eq 'error') { $blockers.Add("$($label): $($row.Text)") }
        }
    }
    return $blockers.ToArray()
}

function ConvertTo-DeployPlanCell {
    <#
    .SYNOPSIS
        Escapes a value so it cannot break out of a markdown table cell.
    .DESCRIPTION
        A raw '|' would be read as a column separator (turning a 2-column row into 3+), and a
        newline would end the row. Matters for cmdlets whose plan values are less tame than
        Flutter Web's (SQL object names, connection strings) as ADR 0009 rolls out.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$Text)

    return (($Text -replace '\|', '\|') -replace '\r?\n', ' ')
}

function Format-DeployPlanMarkdown {
    <#
    .SYNOPSIS
        Pure renderer: turns a plan object into a markdown change report (no side effects).
    .PARAMETER Timestamp
        Optional ISO-8601 timestamp string to stamp the report (injected for deterministic tests).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Plan,
        [string]$Timestamp = ''
    )

    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add("# Deploy plan — $($Plan.Cmdlet)")
    $lines.Add('')
    $lines.Add("- **Target:** $($Plan.Target)")
    if ($Timestamp) { $lines.Add("- **Generated:** $Timestamp") }
    $lines.Add('')

    foreach ($sectionName in $Plan.Sections.Keys) {
        $lines.Add("## $sectionName")
        $lines.Add('')
        $lines.Add('| Campo | Valor |')
        $lines.Add('|---|---|')
        $rows = $Plan.Sections[$sectionName]
        foreach ($label in $rows.Keys) {
            $row = ConvertTo-DeployPlanRow $rows[$label]
            $marker = $script:DeployPlanMarkers[$row.Level]
            $cell = ConvertTo-DeployPlanCell -Text "$marker$($row.Text)"
            $lines.Add("| $(ConvertTo-DeployPlanCell -Text $label) | $cell |")
        }
        $lines.Add('')
    }

    if ($Plan.Actions.Count -gt 0) {
        $lines.Add('## Acciones que realizará -Apply')
        $lines.Add('')
        for ($i = 0; $i -lt $Plan.Actions.Count; $i++) {
            $lines.Add("$($i + 1). $($Plan.Actions[$i])")
        }
        $lines.Add('')
    }

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

function Save-DeployPlan {
    <#
    .SYNOPSIS
        Writes the plan as a markdown change report. Used by -Plan only (ADR 0009); -Apply
        renders the same plan on screen but does NOT persist a report.
    .DESCRIPTION
        Default location is a gitignored '.macss/plans/' under the project root; override the
        full path with -OutFile (parity with `terraform plan -out`). Returns the written path.
    .PARAMETER ProjectRoot
        Project root under which '.macss/plans/' is created (ignored when -OutFile is given).
    .PARAMETER OutFile
        Full path override for the report file.
    .PARAMETER Timestamp
        Optional ISO-8601 stamp injected into the report body (tests pass a fixed value). It also
        drives the generated file name, so injecting it makes the whole call deterministic.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]$Plan,
        [Parameter(Mandatory)][string]$ProjectRoot,
        [string]$OutFile = '',
        [string]$Timestamp = ''
    )

    # The file name stamp is derived from $Timestamp (not a second Get-Date) so that an injected
    # timestamp pins the name too; otherwise a "deterministic" call still produced a random name.
    if ($Timestamp) {
        try {
            $stampSource = [datetime]::Parse(
                $Timestamp,
                [Globalization.CultureInfo]::InvariantCulture,
                [Globalization.DateTimeStyles]::RoundtripKind)
        }
        catch { $stampSource = Get-Date }
    }
    else {
        $stampSource = Get-Date
        $Timestamp = $stampSource.ToString('yyyy-MM-ddTHH:mm:ssK')
    }
    $stamp = $stampSource.ToString('yyyyMMddHHmmss')

    $utf8NoBom = New-Object System.Text.UTF8Encoding($false)

    if ($OutFile) {
        $path = $OutFile
    }
    else {
        $safeCmdlet = ($Plan.Cmdlet -replace '[^\w.-]', '_')
        $safeTarget = ($Plan.Target -replace '[^\w.-]', '_')
        $fileName = "$safeCmdlet-$safeTarget-$stamp.md"
        $path = Join-Path (Join-Path $ProjectRoot '.macss/plans') $fileName

        # The report embeds the target alias and IP. Make '.macss/' self-ignoring (the
        # '.terraform/' pattern) so a consumer project cannot leak it by forgetting a
        # .gitignore entry — ADR 0009 asked consumers to add one by hand.
        $macssRoot = Join-Path $ProjectRoot '.macss'
        $macssIgnore = Join-Path $macssRoot '.gitignore'
        if (-not (Test-Path $macssIgnore)) {
            New-Item -ItemType Directory -Path $macssRoot -Force | Out-Null
            [System.IO.File]::WriteAllText($macssIgnore, "*`n", $utf8NoBom)
        }
    }

    $dir = Split-Path -Parent $path
    if ($dir -and -not (Test-Path $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }

    $markdown = Format-DeployPlanMarkdown -Plan $Plan -Timestamp $Timestamp
    [System.IO.File]::WriteAllText($path, $markdown, $utf8NoBom)
    return $path
}