Private/Test-AvmTerraformConstraint.ps1

function Test-AvmTerraformConstraint {
    <#
    .SYNOPSIS
        Parses a Terraform version constraint string and evaluates it against a candidate
        target version.

    .DESCRIPTION
        Supports:
          - Exact pins: "0.3.1", "= 0.3.1"
          - Pessimistic: "~> 0.3", "~> 0.3.1"
          - Comma-separated simple ranges: ">= 0.3, < 0.5", ">= 0.3.0", "!= 0.4.0"
        Comma-separated clauses are combined with AND, matching Terraform's own constraint
        semantics. Anything that doesn't match one of the clause shapes above (e.g. caret
        ranges, malformed text) is reported as unparseable rather than guessed at.

        0.x pessimistic constraints are treated conservatively: AVM modules are 0.x and treat
        a *minor* bump as potentially breaking (see Get-AvmUpdateRisk), so "~> 0.3" is NOT
        considered satisfied by "0.4.x" even though Terraform's own resolver would accept it
        (">= 0.3, < 1.0.0"). Instead, a rewrite to "~> 0.4" is proposed so the bump goes
        through risk analysis / PR review instead of being silently picked up by a bare
        `terraform init -upgrade`. For 1.x+ majors, standard pessimistic-operator semantics
        apply (major is locked, everything to the right of it floats).

    .PARAMETER Constraint
        The raw constraint string as written in the .tf file, e.g. '~> 0.3', '>= 0.3, < 0.5'.

    .PARAMETER TargetVersion
        The candidate target version (a clean semver string, e.g. from Get-AvmLatestVersion).

    .OUTPUTS
        PSCustomObject:
          - parseable [bool] $false if any clause could not be parsed.
          - satisfied [bool] $true if TargetVersion already satisfies Constraint.
          - canRewrite [bool] $true if a safe rewrite proposal could be computed.
          - kind [string] 'exact' | 'pessimistic' | 'range' | 'unknown'.
          - baseVersion [string] Clean semver to use for risk-tier / interface-diff
                                         comparisons in place of the raw constraint text.
          - proposedConstraint [string] Rewritten constraint targeting TargetVersion. Only set
                                         when canRewrite -and -not satisfied.

    .EXAMPLE
        Test-AvmTerraformConstraint -Constraint '~> 0.3' -TargetVersion '0.3.9'
        # satisfied = $true

    .EXAMPLE
        Test-AvmTerraformConstraint -Constraint '~> 0.3' -TargetVersion '0.4.1'
        # satisfied = $false; canRewrite = $true; proposedConstraint = '~> 0.4'
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$Constraint,

        [Parameter(Mandatory)]
        [string]$TargetVersion
    )

    $result = [PSCustomObject]@{
        parseable          = $false
        satisfied          = $false
        canRewrite         = $false
        kind               = 'unknown'
        baseVersion        = $null
        proposedConstraint = $null
    }

    $target = ConvertTo-SemVer -Version $TargetVersion
    if (-not $target) { return $result }

    $clauseTexts = @($Constraint -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    if ($clauseTexts.Count -eq 0) { return $result }

    # Operator + up-to-three-part version number, nothing else.
    $clausePattern = '^(~>|>=|<=|!=|=|>|<)?\s*(\d+(?:\.\d+){0,2})$'
    $clauses = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($text in $clauseTexts) {
        $m = [regex]::Match($text, $clausePattern)
        if (-not $m.Success) { return $result }   # unparseable clause -> whole constraint unparseable

        $op  = if ($m.Groups[1].Success -and $m.Groups[1].Value) { $m.Groups[1].Value } else { '=' }
        $ver = $m.Groups[2].Value
        $segmentCount = @($ver -split '\.').Count
        $sv  = ConvertTo-SemVer -Version $ver
        if (-not $sv) { return $result }

        $clauses.Add([PSCustomObject]@{ Operator = $op; SegmentCount = $segmentCount; SemVer = $sv })
    }

    $result.parseable = $true

    if ($clauses.Count -eq 1 -and $clauses[0].Operator -eq '~>') {
        $result.kind = 'pessimistic'
    } elseif ($clauses.Count -eq 1 -and $clauses[0].Operator -eq '=') {
        $result.kind = 'exact'
    } else {
        $result.kind = 'range'
    }

    # --- Evaluate satisfaction: every clause must hold (AND) ---
    $satisfied = $true
    foreach ($c in $clauses) {
        $clauseSatisfied = switch ($c.Operator) {
            '='  { $target -eq $c.SemVer }
            '!=' { $target -ne $c.SemVer }
            '>'  { $target -gt $c.SemVer }
            '>=' { $target -ge $c.SemVer }
            '<'  { $target -lt $c.SemVer }
            '<=' { $target -le $c.SemVer }
            '~>' {
                if ($c.SegmentCount -eq 2 -and $c.SemVer.Major -eq 0) {
                    # Conservative AVM rule: 0.x + two-part pessimistic locks major AND minor.
                    ($target.Major -eq $c.SemVer.Major) -and ($target.Minor -eq $c.SemVer.Minor)
                } elseif ($c.SegmentCount -eq 2) {
                    # Standard pessimistic operator: lock major, float minor(+patch).
                    ($target.Major -eq $c.SemVer.Major) -and ($target -ge $c.SemVer)
                } else {
                    # Three-part: lock major.minor, float patch.
                    ($target.Major -eq $c.SemVer.Major) -and ($target.Minor -eq $c.SemVer.Minor) -and ($target -ge $c.SemVer)
                }
            }
            default { $false }
        }
        if (-not $clauseSatisfied) { $satisfied = $false }
    }
    $result.satisfied = $satisfied

    # Used for risk-tier / interface-diff comparisons in place of the raw constraint text.
    # Single-clause constraints have one obvious "current version"; for ranges we fall back to
    # the first clause's version as a best-effort baseline.
    $result.baseVersion = ($clauses | Select-Object -First 1).SemVer.ToString()

    if (-not $satisfied) {
        if ($result.kind -eq 'pessimistic') {
            $c = $clauses[0]
            $result.canRewrite = $true
            $result.proposedConstraint = if ($c.SegmentCount -eq 2) {
                "~> $($target.Major).$($target.Minor)"
            } else {
                "~> $($target.Major).$($target.Minor).$($target.Patch)"
            }
        } elseif ($result.kind -eq 'exact') {
            $result.canRewrite = $true
            $result.proposedConstraint = $target.ToString()
        } else {
            # Ranges / multi-clause constraints are never auto-rewritten — too easy to mangle
            # intent (e.g. a deliberate upper bound). Surfaced as skipped with a clear reason.
            $result.canRewrite = $false
        }
    }

    return $result
}