Private/GitHubWorkflowCommand.Helpers.ps1

function ConvertTo-GitHubCommandData {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [object] $Value
    )

    if ($null -eq $Value) {
        return ''
    }

    return ([string]$Value).
        Replace('%', '%25').
        Replace("`r", '%0D').
        Replace("`n", '%0A')
}

function ConvertTo-GitHubCommandProperty {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object] $Value
    )

    return (ConvertTo-GitHubCommandData -Value $Value).
        Replace(':', '%3A').
        Replace(',', '%2C')
}

function Write-GitHubWorkflowCommandInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $Command,

        [AllowNull()]
        [object] $Message,

        [System.Collections.IDictionary] $Properties
    )

    $propertySegments = @()

    if ($Properties) {
        foreach ($key in $Properties.Keys) {
            $value = $Properties[$key]

            if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value)) {
                continue
            }

            $propertySegments += ('{0}={1}' -f $key, (ConvertTo-GitHubCommandProperty -Value $value))
        }
    }

    $propertyText = ''
    if ($propertySegments.Count -gt 0) {
        $propertyText = ' ' + ($propertySegments -join ',')
    }

    Write-Output ('::{0}{1}::{2}' -f $Command.ToLowerInvariant(), $propertyText, (ConvertTo-GitHubCommandData -Value $Message))
}

function Get-GitHubEnvironmentFilePath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $VariableName
    )

    $filePath = [System.Environment]::GetEnvironmentVariable($VariableName)

    if ([string]::IsNullOrWhiteSpace($filePath)) {
        throw "The $VariableName environment variable is not available in the current session."
    }

    return $filePath
}

function ConvertTo-GitHubFileCommandEntry {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string] $Name,

        [AllowNull()]
        [object] $Value
    )

    $stringValue = if ($null -eq $Value) { '' } else { [string]$Value }

    if ($stringValue -match "`r|`n") {
        $delimiter = [guid]::NewGuid().Guid
        $lines = $stringValue -split "`r?`n"

        while ($lines -contains $delimiter) {
            $delimiter = [guid]::NewGuid().Guid
        }

        return @(
            "$Name<<$delimiter",
            $stringValue,
            $delimiter
        ) -join [System.Environment]::NewLine
    }

    return "$Name=$stringValue"
}

function Add-GitHubEnvironmentFileEntryInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $VariableName,

        [Parameter(Mandatory)]
        [string] $Name,

        [AllowNull()]
        [object] $Value
    )

    $filePath = Get-GitHubEnvironmentFilePath -VariableName $VariableName
    $entry = ConvertTo-GitHubFileCommandEntry -Name $Name -Value $Value

    $entry | Out-File -FilePath $filePath -Append -Encoding utf8
}

function Write-GitHubEnvironmentFileContentInternal {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $VariableName,

        [AllowEmptyCollection()]
        [string[]] $Content = @(),

        [switch] $Append
    )

    $filePath = Get-GitHubEnvironmentFilePath -VariableName $VariableName
    $text = ($Content -join [System.Environment]::NewLine)

    if ($Append) {
        $text | Out-File -FilePath $filePath -Append -Encoding utf8
        return
    }

    $text | Out-File -FilePath $filePath -Encoding utf8
}

function Remove-GitHubEnvironmentFileContentInternal {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string] $VariableName
    )

    $filePath = Get-GitHubEnvironmentFilePath -VariableName $VariableName

    if ((Test-Path -Path $filePath) -and $PSCmdlet.ShouldProcess($filePath, 'Remove GitHub Actions environment file')) {
        Remove-Item -Path $filePath -Force
    }
}

function New-GitHubAnnotationPropertyMap {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param(
        [string] $Title,
        [string] $File,
        [Nullable[int]] $Line,
        [Nullable[int]] $EndLine,
        [Nullable[int]] $Column,
        [Nullable[int]] $EndColumn
    )

    $target = if ([string]::IsNullOrWhiteSpace($Title)) { 'GitHub annotation' } else { $Title }

    if (-not $PSCmdlet.ShouldProcess($target, 'Create GitHub annotation property map')) {
        return
    }

    $properties = [ordered]@{}

    if (-not [string]::IsNullOrWhiteSpace($Title)) {
        $properties.title = $Title
    }

    if (-not [string]::IsNullOrWhiteSpace($File)) {
        $properties.file = $File
    }

    if ($Line.HasValue) {
        $properties.line = $Line.Value
    }

    if ($EndLine.HasValue) {
        $properties.endLine = $EndLine.Value
    }

    if ($Column.HasValue) {
        $properties.col = $Column.Value
    }

    if ($EndColumn.HasValue) {
        $properties.endColumn = $EndColumn.Value
    }

    return $properties
}

function ConvertTo-GitHubCacheSegmentInternal {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowNull()]
        [object] $Value
    )

    if ($null -eq $Value) {
        return $null
    }

    $text = [string]$Value
    if ([string]::IsNullOrWhiteSpace($text)) {
        return $null
    }

    if ($text -match "`r|`n") {
        throw 'GitHub cache key segments cannot contain newline characters.'
    }

    return $text.Trim()
}

function New-GitHubCacheKeyTextInternal {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]] $Segment,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string] $Separator
    )

    $normalizedSegments = foreach ($currentSegment in $Segment) {
        $normalizedSegment = ConvertTo-GitHubCacheSegmentInternal -Value $currentSegment
        if ($null -ne $normalizedSegment) {
            $normalizedSegment
        }
    }

    if (@($normalizedSegments).Count -eq 0) {
        throw 'At least one GitHub cache key segment must be provided.'
    }

    $key = @($normalizedSegments) -join $Separator
    if ($key.Length -gt 512) {
        throw 'GitHub cache keys cannot exceed 512 characters.'
    }

    return $key
}

function Resolve-GitHubCacheHashInputInternal {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo[]])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string[]] $Path
    )

    $files = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
    $seenPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($currentPath in $Path) {
        $candidateFiles = @()

        if (Test-Path -LiteralPath $currentPath -PathType Leaf) {
            $candidateFiles = @(Get-Item -LiteralPath $currentPath)
        }
        elseif (Test-Path -LiteralPath $currentPath -PathType Container) {
            $candidateFiles = @(Get-ChildItem -LiteralPath $currentPath -File -Recurse)
        }
        else {
            $candidateFiles = @(Get-ChildItem -Path $currentPath -File -Recurse -ErrorAction SilentlyContinue)
        }

        if ($candidateFiles.Count -eq 0) {
            throw "The cache hash path '$currentPath' did not match any files."
        }

        foreach ($candidateFile in ($candidateFiles | Sort-Object FullName -Unique)) {
            if ($seenPaths.Add($candidateFile.FullName)) {
                $files.Add($candidateFile)
            }
        }
    }

    return [System.IO.FileInfo[]]($files.ToArray() | Sort-Object FullName)
}

function Get-GitHubCacheHashIdentityInternal {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo] $File
    )

    $workspacePath = [System.Environment]::GetEnvironmentVariable('GITHUB_WORKSPACE', 'Process')
    if (-not [string]::IsNullOrWhiteSpace($workspacePath)) {
        $workspaceRoot = [System.IO.Path]::GetFullPath($workspacePath)
        $relativePath = [System.IO.Path]::GetRelativePath($workspaceRoot, $File.FullName)

        if (-not $relativePath.StartsWith('..')) {
            return $relativePath.Replace('\\', '/')
        }
    }

    return $File.FullName.Replace('\\', '/')
}

function Get-GitHubCacheHashInternal {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [System.IO.FileInfo[]] $File
    )

    $entries = foreach ($currentFile in ($File | Sort-Object FullName)) {
        $fileHash = (Get-FileHash -LiteralPath $currentFile.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
        '{0}:{1}' -f (Get-GitHubCacheHashIdentityInternal -File $currentFile), $fileHash
    }

    $contentBytes = [System.Text.Encoding]::UTF8.GetBytes(($entries -join "`n"))
    $sha256 = [System.Security.Cryptography.SHA256]::Create()

    try {
        return [System.Convert]::ToHexString($sha256.ComputeHash($contentBytes)).ToLowerInvariant()
    }
    finally {
        $sha256.Dispose()
    }
}