src/functions/qrun.ps1

function Invoke-QRun {
    [CmdletBinding()]
    param(
        [ArgumentCompleter({
                param ($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
                try {
                    $mapPath = if ($fakeBoundParameters.map) { $fakeBoundParameters.map } else { "./.process.map.ps1" }
                    $localMapExists = Test-Path $mapPath

                    $mapNotFound = $false
                    $map = try {
                        Import-ConfigMap -Map $fakeBoundParameters.map -Fallback "./.process.map.ps1" -ErrorAction Stop
                    }
                    catch {
                        if ($_.Exception.Message -match '^map file .* not found$|^No map provided and fallback .* not found$|^map is null and defaultMapFile is not provided$') {
                            $mapNotFound = $true
                            $null
                        }
                        else {
                            throw
                        }
                    }
                    if ($mapNotFound) {
                        return @("!init", "!state", "help", "list") | ? { $_.startswith($wordToComplete) }
                    }
                    $map = $map | Assert-ConfigMap

                    $completions = Get-EntryCompletion $map -language "build" @PSBoundParameters
                    if (!$localMapExists) {
                        $completions = @("!init" | ? { $_.startswith($wordToComplete) }) + $completions
                    }
                    return @(@("!state", "help", "list") | ? { $_.startswith($wordToComplete) }) + $completions
                }
                catch {
                    return "ERROR [-entry]: $($_.Exception.Message) $($_.ScriptStackTrace)"
                }
            })]
        [Parameter(Position = 0)]
        $entry = $null,

        [switch]$Follow,
        [switch]$Force,
        [switch]$Status,
        [switch]$Reset,

        [Parameter(DontShow)]
        $map = "./.process.map.ps1",

        [Parameter(ValueFromRemainingArguments = $true, DontShow = $true)]
        [string[]]$RemainingArguments
    )
    dynamicparam {
        if (-not $entry -or $entry -in @('help', 'list', '!init', '!state')) {
            return
        }
        try {
            $dynMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" | Assert-ConfigMap
            return Get-ProcessDynamicParam -Map $dynMap -ProcessKey $entry -Follow:$Follow
        }
        catch {
            return
        }
    }

    process {
        if ($entry -eq "help") {
            Write-Host ""
            Write-Host "QRUN" -ForegroundColor Cyan
            Write-Host "Run multi-step processes with durable step state" -ForegroundColor Gray
            Write-Host ""
            Write-Host "USAGE:" -ForegroundColor Yellow
            Write-Host " qrun <process> [-Force] [-Status] [-Reset]" -ForegroundColor White
            Write-Host " qrun <process>.<step> [-Follow] [-Force]" -ForegroundColor White
            Write-Host " qrun !state [show|clean] [<process>]" -ForegroundColor White
            Write-Host ""
            Write-Host "OPTIONS:" -ForegroundColor Yellow
            Write-Host " -Follow With a step path, also run all following steps" -ForegroundColor White
            Write-Host " -Force Rerun succeeded steps; also allow starting mid-process when prior steps are incomplete" -ForegroundColor White
            Write-Host " -Status Show persisted step statuses without running" -ForegroundColor White
            Write-Host " -Reset Clear persisted state for the process" -ForegroundColor White
            Write-Host ""
            Write-Host "STATE:" -ForegroundColor Yellow
            Write-Host " qrun Show process state (same as !state)" -ForegroundColor White
            Write-Host " qrun !state Show all process state (alias for !state show)" -ForegroundColor White
            Write-Host " qrun !state show [name] Show state for all processes, or one process" -ForegroundColor White
            Write-Host " qrun !state clean [name] Clean all process state, or one process" -ForegroundColor White
            Write-Host ""
            Write-Host "Dot paths work like qbuild: 'qrun bootstrap.prepare' runs that step; add -Follow to continue." -ForegroundColor Gray
            Write-Host ""
            return
        }

        if ($entry -eq "list") {
            $listMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1"
            if (!$listMap) {
                Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
                return
            }
            Write-MapHelp -map $listMap -invocation $MyInvocation -language "build"
            return
        }

        if ($entry -eq "!init") {
            if (Test-Path $map) {
                throw "map file '$map' already exists"
            }
            Initialize-ProcessMap -file $map
            return
        }

        if (-not $entry -or $entry -eq "!state") {
            $stateArgs = @($RemainingArguments | Where-Object { $_ })
            $action = 'show'
            $processFilter = $null

            if ($entry -eq "!state" -and $stateArgs.Count -ge 1) {
                if ($stateArgs[0] -in @('show', 'clean')) {
                    $action = $stateArgs[0]
                    if ($stateArgs.Count -ge 2) {
                        $processFilter = $stateArgs[1]
                    }
                    if ($stateArgs.Count -gt 2) {
                        throw "!state $action accepts at most one process name."
                    }
                }
                else {
                    throw "!state expects 'show' or 'clean', got '$($stateArgs[0])'. Try: qrun !state show"
                }
            }

            $stateMap = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" -ErrorAction Ignore
            if (!$stateMap) {
                Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
                return
            }

            $baseDir = if ($stateMap._baseDir) { $stateMap._baseDir } else { (Get-Location).Path }
            $sourceFile = if ($stateMap._sourceFile) { [string]$stateMap._sourceFile } else { $null }
            $operationContext = New-ConfigMapOperationContext

            Invoke-ProcessStateCommand `
                -Action $action `
                -ProcessKey $processFilter `
                -Map $stateMap `
                -BaseDir $baseDir `
                -MapFile $sourceFile `
                -OperationContext $operationContext
            return
        }

        $mapPath = $map
        $map = . $ImportConfigMap -Map $map -Fallback "./.process.map.ps1" -ErrorAction Ignore
        if (!$map) {
            Write-Help -invocation $MyInvocation -mapPath "./.process.map.ps1"
            return
        }

        $operationContext = New-ConfigMapOperationContext
        $target = Resolve-ProcessTarget -Map $map -Entry $entry -OperationContext $operationContext
        $processKey = $target.ProcessKey
        $processEntry = $target.ProcessEntry
        $stepNames = @($target.StepNames)
        $fromStep = $target.ImpliedStep

        if ($Follow -and -not $fromStep) {
            throw "-Follow requires a step path (e.g. qrun bootstrap.restore -Follow)"
        }

        $baseDir = if ($processEntry._baseDir) { $processEntry._baseDir } elseif ($map._baseDir) { $map._baseDir } else { (Get-Location).Path }
        $sourceFile = if ($map._sourceFile) { [string]$map._sourceFile } else { $null }
        $statePath = Get-ProcessStatePath -ProcessKey $processKey -BaseDir $baseDir -MapFile $sourceFile

        if ($Reset) {
            Reset-ProcessState -StatePath $statePath
            Write-Host "Reset process state for '$processKey'" -ForegroundColor Yellow
            if (-not $Status -and -not $fromStep -and -not $Follow -and -not $Force) {
                return
            }
        }

        $state = Read-ProcessState -StatePath $statePath -ProcessKey $processKey -StepNames $stepNames -MapFile $sourceFile

        if ($Status) {
            Write-ProcessStatus -ProcessKey $processKey -StepNames $stepNames -State $state
            return
        }

        $skippedPrior = @()
        if ($fromStep) {
            $skippedPrior = @(Assert-ProcessPriorSteps -StepNames $stepNames -FromStep $fromStep -State $state -Force:$Force)
        }

        $stepsToConsider = Get-ProcessStepsToRun -StepNames $stepNames -FromStep $fromStep -Follow:$Follow
        $total = $stepNames.Count

        Write-Host ""
        Write-Host "PROCESS " -NoNewline -ForegroundColor Cyan
        Write-Host $processKey -ForegroundColor White
        if ($fromStep) {
            $rangeLabel = if ($Follow) { "$fromStep …" } else { $fromStep }
            Write-Host "Step: $rangeLabel" -ForegroundColor DarkGray
        }
        Write-Host "State: $statePath" -ForegroundColor DarkGray
        Write-Host ""

        foreach ($priorName in $skippedPrior) {
            $priorIndex = [array]::IndexOf($stepNames, $priorName) + 1
            $priorStatus = if ($state.steps[$priorName]) { [string]$state.steps[$priorName].status } else { 'Pending' }
            Write-Host "[$priorIndex/$total] " -NoNewline -ForegroundColor DarkGray
            Write-Host "SKIP" -NoNewline -ForegroundColor DarkYellow
            Write-Host " $priorName" -NoNewline -ForegroundColor White
            Write-Host " (forced; was $priorStatus)" -ForegroundColor DarkGray
        }

        $bound = @{}
        foreach ($key in $PSBoundParameters.Keys) {
            if ($key -in @('entry', 'Follow', 'Force', 'Status', 'Reset', 'map', 'RemainingArguments')) {
                continue
            }
            $bound[$key] = $PSBoundParameters[$key]
        }
        $passthrough = @($RemainingArguments) | Where-Object { $null -ne $_ }
        if ($passthrough.Count -gt 0) {
            $bound._context = @{ additionalArgs = @($passthrough) }
        }

        $ran = 0
        $skipped = 0

        foreach ($stepName in $stepsToConsider) {
            $stepIndex = [array]::IndexOf($stepNames, $stepName) + 1
            $prefix = "[$stepIndex/$total]"
            $stepStatus = if ($state.steps[$stepName]) { [string]$state.steps[$stepName].status } else { 'Pending' }

            if ($stepStatus -eq 'Succeeded' -and -not $Force) {
                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "SKIP" -NoNewline -ForegroundColor DarkCyan
                Write-Host " $stepName" -NoNewline -ForegroundColor White
                Write-Host " (already succeeded)" -ForegroundColor DarkGray
                $skipped++
                continue
            }

            Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
            Write-Host "RUN " -NoNewline -ForegroundColor Yellow
            Write-Host " $stepName..." -ForegroundColor White

            $state.steps[$stepName] = [ordered]@{
                status     = 'Running'
                startedAt  = (Get-Date).ToString('o')
                finishedAt = $null
                error      = $null
            }
            Write-ProcessState -StatePath $statePath -State $state

            $stepEntry = Get-ProcessStepEntry -ProcessEntry $processEntry -StepName $stepName
            try {
                Push-Location $baseDir
                try {
                    $settingsEntry = if ($stepEntry -is [System.Collections.IDictionary]) { $stepEntry } else { $processEntry }
                    Invoke-WithEntrySettings -Map $map -EntryKey "$processKey.$stepName" -Entry $settingsEntry -OperationContext $operationContext -ScriptBlock {
                        Invoke-EntryCommand -entry $stepEntry -key "exec" -bound $bound
                    }
                }
                finally {
                    Pop-Location
                }

                $state.steps[$stepName] = [ordered]@{
                    status     = 'Succeeded'
                    startedAt  = $state.steps[$stepName].startedAt
                    finishedAt = (Get-Date).ToString('o')
                    error      = $null
                }
                Write-ProcessState -StatePath $statePath -State $state

                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "OK " -NoNewline -ForegroundColor Green
                Write-Host " $stepName" -ForegroundColor White
                $ran++
            }
            catch {
                $errMsg = $_.Exception.Message
                $state.steps[$stepName] = [ordered]@{
                    status     = 'Failed'
                    startedAt  = $state.steps[$stepName].startedAt
                    finishedAt = (Get-Date).ToString('o')
                    error      = $errMsg
                }
                Write-ProcessState -StatePath $statePath -State $state

                Write-Host "$prefix " -NoNewline -ForegroundColor DarkGray
                Write-Host "FAIL" -NoNewline -ForegroundColor Red
                Write-Host " $stepName" -ForegroundColor White
                Write-Host " $errMsg" -ForegroundColor DarkRed
                throw
            }
        }

        Write-Host ""
        Write-Host "Process '$processKey' finished" -NoNewline -ForegroundColor Green
        Write-Host " (ran: $ran, skipped: $skipped)" -ForegroundColor DarkGray
        Write-Host ""
    }
}

Set-Alias -Name "qrun" -Value "Invoke-QRun" -Force