Modules/Private/15-Progress.ps1

function Get-RangerProgressStatePath {
    <#
    .SYNOPSIS
        v1.6.0 (#213): resolve the IPC progress file path for a given run ID.
    .DESCRIPTION
        Sanitises $RunId against path traversal (strips \, /, .. sequences)
        and returns $env:TEMP\ranger-progress-<sanitized>.json.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$RunId
    )

    $safe = ($RunId -replace '[\\\/:*?"<>|]', '') -replace '\.\.+', ''
    if ([string]::IsNullOrWhiteSpace($safe)) { $safe = 'default' }
    $root = if ($env:TEMP) { $env:TEMP } else { [System.IO.Path]::GetTempPath() }
    return (Join-Path -Path $root -ChildPath ("ranger-progress-{0}.json" -f $safe))
}

function Write-RangerProgressState {
    <#
    .SYNOPSIS
        v1.6.0 (#213): atomically write a progress snapshot to the IPC file.
    .DESCRIPTION
        Writes percent / message / phase to
        $env:TEMP\ranger-progress-<RunId>.json atomically — renders to a temp
        file then File.Move -Force to the final path so readers never see a
        partial JSON document.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RunId,

        [ValidateRange(0, 100)]
        [int]$Percent = 0,

        [string]$Message,

        [ValidateSet('pre-check', 'collection', 'rendering', 'complete', 'failed')]
        [string]$Phase = 'collection'
    )

    $path = Get-RangerProgressStatePath -RunId $RunId

    $payload = [ordered]@{
        runId     = $RunId
        percent   = [int]$Percent
        message   = [string]$Message
        phase     = $Phase
        timestamp = (Get-Date).ToUniversalTime().ToString('o')
    }
    $json = ($payload | ConvertTo-Json -Depth 4 -Compress)

    # Atomic write: write to a per-process temp file then Move -Force over the target.
    $tempPath = "$path.$PID.tmp"
    try {
        [System.IO.File]::WriteAllText($tempPath, $json, [System.Text.Encoding]::UTF8)
        # File.Move -Force (destination overwrite) is atomic within a single volume on Windows.
        if (Test-Path -Path $path -PathType Leaf) {
            [System.IO.File]::Delete($path)
        }
        [System.IO.File]::Move($tempPath, $path)
    }
    catch {
        # Clean up stray temp file if the move failed
        if (Test-Path -Path $tempPath -PathType Leaf) {
            try { [System.IO.File]::Delete($tempPath) } catch { }
        }
        throw
    }
}

function Read-RangerProgressState {
    <#
    .SYNOPSIS
        v1.6.0 (#213): read the most recent progress snapshot for a run.
    .OUTPUTS
        PSCustomObject with runId, percent, message, phase, timestamp, or
        $null when the file does not exist (run not started).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RunId
    )

    $path = Get-RangerProgressStatePath -RunId $RunId
    if (-not (Test-Path -Path $path -PathType Leaf)) { return $null }

    try {
        $raw = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
        if ([string]::IsNullOrWhiteSpace($raw)) { return $null }
        return ($raw | ConvertFrom-Json)
    }
    catch {
        # Reader sees an in-progress write — return null, caller will poll again.
        return $null
    }
}

function Remove-RangerProgressState {
    <#
    .SYNOPSIS
        v1.6.0 (#213): delete the progress IPC file for a run. Idempotent.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$RunId
    )

    $path = Get-RangerProgressStatePath -RunId $RunId
    if (Test-Path -Path $path -PathType Leaf) {
        try { [System.IO.File]::Delete($path) } catch { }
    }
}