Get-TFVersionFromFile.ps1

#Requires -Version 7.0.0
Set-StrictMode -Version 3.0
<#
.SYNOPSIS
    Get Terraform version from ".terraform-version" file.
    This function is mainly for internal use.
#>

function Get-TFVersionFromFile {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(ParameterSetName = 'Default', Mandatory = $false)]
        [string]$LiteralPath = './.terraform-version'
    )
    if ([string]::IsNullOrEmpty($LiteralPath)) {
        $LiteralPath = './.terraform-version'
    }
    # Test path
    if (-not (Test-Path -LiteralPath $LiteralPath)) {
        Write-Warning ('{0} not found.' -f $LiteralPath)
        return
    }

    $rowString = @(Get-Content -LiteralPath $LiteralPath)[0].Trim()
    if ('latest-allowed' -eq $rowString) {
        # -RootPath must be .terraform-version directory.
        $requiredValue = GetTerraformRequiredValue -RootPath $([System.IO.Path]::GetDirectoryName($LiteralPath))
        if ([string]::IsNullOrEmpty($requiredValue)) {
            Write-Warning '.terraform-version contains "latest-allowed", but "required_version" statement not found.'
            return
        }
        $arrowedVersion = ParseTerraformRequiredVersion -RawString $requiredValue
        if (-not $arrowedVersion) {
            Write-Warning '.terraform-version contains "latest-allowed", but failed to parse "required_version" statement.'
            return
        }
        return $arrowedVersion.AllowedMaxVersion
    }
    if ('min-required' -eq $rowString) {
        # -RootPath must be .terraform-version directory.
        $requiredValue = GetTerraformRequiredValue -RootPath $([System.IO.Path]::GetDirectoryName($LiteralPath))
        if ([string]::IsNullOrEmpty($requiredValue)) {
            Write-Warning '.terraform-version contains "min-required", but "required_version" statement not found.'
            return
        }
        $arrowedVersion = ParseTerraformRequiredVersion -RawString $requiredValue
        if (-not $arrowedVersion) {
            Write-Warning '.terraform-version contains "min-required", but failed to parse "required_version" statement.'
            return
        }
        return $arrowedVersion.AllowedMinVersion
    }
    if ('latest' -eq $rowString) {
        Write-Verbose 'Detect the latest version'
        return (Find-TFRelease -Latest).Version
    }
    if ($rowString -match '^latest:(?<match_exp>.+)$' ) {
        $matchExp = $Matches.match_exp
        Write-Verbose ("version match expression : {0}" -f $matchExp)
        $matchVersion = Find-TFVersion -Filter { "$_" -match $matchExp } -Take 1
        if (-not $matchVersion) {
            Write-Warning ('Failed to detect Terraform version. (expression = {0})' -f $matchExp)
            return
        }
        Write-Verbose ('Detect version {0}' -f $matchVersion)
        return $matchVersion
    }
    try {
        $version = [semver]$rowString
        Write-Verbose ('Detect version {0}' -f $version)
        return $version
    } catch {
        # do nothing
    }
    Write-Warning ('Failed to parse .terraform-version : {0}' -f $rowString)
}

# TODO : implement more formal parser
function GetTerraformRequiredValue ([string]$RootPath) {
    if (-not (Test-Path -Path $RootPath -PathType Container)) {
        return ""
    }
    foreach ($file in (Get-ChildItem -LiteralPath $RootPath -Include '*.tf', '*.tf.json')) {
        Select-String -LiteralPath $file.FullName -Pattern '^\s*[^#]*\s*required_version\s*=\s*["''](?<match_exp>.+)["'']$' | ForEach-Object {
            $rawString = ($_.Matches.Groups).Where({ $_.Name -eq 'match_exp' }).Value
            if ($rawString) {
                Write-Verbose ('Found rawString : {0}' -f $rawString)
                return $rawString
            }
        }
    }
}

# TODO : implement more formal parser
function ParseTerraformRequiredVersion ([string]$RawString) {
    $allVersions = @(Find-TFVersion)

    # Get each expressions
    $expressions = foreach ($str in $RawString -split ',') {
        $str = $str.Trim()
        switch -Regex ($str) {
            '^\s*(?<match_op>(=|>=|<=|~>|<|>))\s*(?<match_ver>.+)$' {
                # Value must be string to validate ~> operator
                switch ($Matches.match_op) {
                    '=' {
                        [PSCustomObject]@{
                            Operator   = $Matches.match_op
                            Value      = $Matches.match_ver
                            MinVersion = [semver]($Matches.match_ver)
                            MaxVersion = [semver]($Matches.match_ver)
                        }
                        break
                    }
                    '>=' {
                        [PSCustomObject]@{
                            Operator   = $Matches.match_op
                            Value      = $Matches.match_ver
                            MinVersion = [semver]($Matches.match_ver)
                            MaxVersion = $allVersions[0]
                        }
                        break
                    }
                    '<=' {
                        [PSCustomObject]@{
                            Operator   = $Matches.match_op
                            Value      = $Matches.match_ver
                            MinVersion = [semver]$allVersions[-1]
                            MaxVersion = [semver]($Matches.match_ver)
                        }
                        break
                    }
                    '~>' {
                        $v = $Matches.match_ver.Substring(0, $Matches.match_ver.LastIndexOf('.'))
                        $tempVersions = @($allVersions.Where({ $_ -ge "$($Matches.match_ver)" -and $_ -like "${v}.*" }))
                        [PSCustomObject]@{
                            Operator   = $Matches.match_op
                            Value      = $Matches.match_ver
                            MinVersion = [semver]$tempVersions[-1]
                            MaxVersion = [semver]$tempVersions[0]
                        }
                        break
                    }
                    '<' {
                        $tempVersions = @($allVersions.Where({ $_ -lt "$($Matches.match_ver)" }))
                        [PSCustomObject]@{
                            Operator   = $Matches.match_op
                            Value      = $Matches.match_ver
                            MinVersion = [semver]$allVersions[-1]
                            MaxVersion = [semver]$tempVersions[0]
                        }
                        break
                    }
                    '>' {
                        $tempVersions = @($allVersions.Where( { $_ -gt "$($Matches.match_ver)" } ))
                        [PSCustomObject]@{
                            Operator   = ($Matches.match_op)
                            Value      = ($Matches.match_ver)
                            MinVersion = [semver]$tempVersions[-1]
                            MaxVersion = [semver]$allVersions[0]
                        }
                        break
                    }
                }
                break
            }
            Default {
                # No matched : treat as "="
                try {
                    $tempVer = [semver]($str)
                    [PSCustomObject]@{
                        Operator   = '='
                        Value      = $str
                        MinVersion = $tempVer
                        MaxVersion = $tempVer
                    }
                } catch {
                    Write-Warning $_
                }
                break
            }
        }
    }
    
    # Simply "AND" all expressions
    $minVer = ($expressions.MinVersion | Measure-Object -Maximum).Maximum
    $maxVer = ($expressions.MaxVersion | Measure-Object -Minimum).Minimum
    if ($minVer -gt $maxVer) {
        Write-Warning ('"required_version" has inconsistent expression "{0}". (Min={1}, Max={2})' -f $RawString, $minVer, $maxVer)
        return
    }
    return [PSCustomObject]@{
        AllowedMinVersion = $minVer
        AllowedMaxVersion = $maxVer
    }
}