Private/New-DFInstallPlan.ps1

#Requires -Version 7.0

function New-DFInstallPlan {
    <#
    .SYNOPSIS
        Builds the install layer of the session graph: a source and manager per tool, stages in dependency order, and gaps.
    .DESCRIPTION
        For each named tool, picks a source (Resolve-DFInstallSource). A
        manager that is itself one of the named tools counts as planned, so the
        tool waits for it. A tool with no packages that requires a tool in the
        plan is "provided by" it (npm comes with node). Nothing unnamed is
        added, except a manager the user chose for a gap (-Choice), which
        joins the plan. A tool's stage is one more than the highest stage it
        depends on; within a stage, tools are batched per manager.
    .PARAMETER Name
        The tools to install.
    .PARAMETER ToolDb
        Name -> tool record; must include every named tool and the manager records.
    .PARAMETER IsAvailable
        { param($record) } -> whether that tool is installed now.
    .PARAMETER Choice
        Source -> manager name, from the user (interactive) or -UseDefaults.
    .PARAMETER Via
        Tool -> source for this call (Install-DFTool -Via).
    .OUTPUTS
        PSCustomObject: Items, Stages, Gaps.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string[]]$Name,
        [Parameter(Mandatory)][hashtable]$ToolDb,
        [Parameter(Mandatory)][scriptblock]$IsAvailable,
        [hashtable]$Choice = @{},
        [hashtable]$Via = @{}
    )
    $want = [System.Collections.Generic.List[string]]::new()
    foreach ($n in $Name) { if (-not $want.Contains($n)) { $want.Add($n) } }
    # A manager the user chose for a gap joins the plan when it isn't installed.
    foreach ($m in $Choice.Values) {
        if ($m -and -not $want.Contains($m) -and $ToolDb[$m] -and -not (& $IsAvailable $ToolDb[$m])) { $want.Add($m) }
    }

    # A manager that needs admin rights runs only in an elevated shell or
    # through an elevator (the elevator role, e.g. gsudo) that is installed or
    # installed earlier in this run.
    $elevated = Test-DFElevated
    $elevators = @($ToolDb.Values | Where-Object { $_.roles.PSObject.Properties['elevator'] } | Sort-Object name)
    $elevatorNow = $elevators | Where-Object { & $IsAvailable $_ } | Select-Object -First 1
    $elevatorPlanned = if (-not $elevatorNow) { $elevators | Where-Object { $want.Contains($_.name) } | Select-Object -First 1 }
    $elevator = if ($elevatorNow) { $elevatorNow } else { $elevatorPlanned }
    $canElevate = $elevated -or [bool]$elevator

    $items = [ordered]@{}
    $gaps = [System.Collections.Generic.List[object]]::new()
    for ($i = 0; $i -lt $want.Count; $i++) {
        $t = $ToolDb[$want[$i]]
        if (-not $t) { continue }
        $required = @($t.requires | Where-Object { $_ -and $_ -notlike 'role:*' -and $want.Contains($_) })
        # (@($null?.X) is a one-element array, so test the object first.)
        $hasPackages = $t.packages -and @($t.packages.PSObject.Properties).Count -gt 0
        if (-not $hasPackages -and $required) {
            $items[$t.name] = [pscustomobject]@{ Tool = $t.name; Source = $null; Manager = $null; Ref = $null; ProvidedBy = $required[0]; DependsOn = [string[]]$required; Stage = 0 }
            continue
        }
        # No packages, and what it comes with isn't being installed: say what to add.
        $providers = @($t.requires | Where-Object { $_ -and $_ -notlike 'role:*' })
        if (-not $hasPackages -and $providers) {
            $gaps.Add([pscustomobject]@{
                Tool = $t.name; Reason = "comes with $($providers[0]): add $($providers[0]) to install it"
                Options = @(); Source = $null; Dependents = [string[]]@()
            })
            continue
        }
        $planned = [string[]]@($want | Where-Object { $_ -ne $t.name })
        $r = Resolve-DFInstallSource -Tool $t -ToolDb $ToolDb -IsAvailable $IsAvailable -Planned $planned -Choice $Choice -Via $Via -CanElevate $canElevate
        if ($r.Gap) {
            $gaps.Add([pscustomobject]@{
                Tool = $t.name; Reason = $r.Gap; Options = $r.Options
                Source = ($r.Sources | Select-Object -First 1)
                Dependents = [string[]]@()
            })
            continue
        }
        $needsElevator = $r.Block.elevate -and -not $elevated -and $elevatorPlanned
        $deps = @(@($required) + $(if ($want.Contains($r.Manager.name)) { $r.Manager.name }) + $(if ($needsElevator) { $elevatorPlanned.name }) |
            Where-Object { $_ -and $_ -ne $t.name } | Select-Object -Unique)
        $items[$t.name] = [pscustomobject]@{ Tool = $t.name; Source = $r.Source; Manager = $r.Manager; Block = $r.Block; Ref = $r.Ref; ProvidedBy = $null; DependsOn = [string[]]$deps; Stage = 0 }
    }

    # Tools that depend (transitively) on a gap can't be installed either.
    $blocked = @($gaps.Tool)
    $blockedBy = @{}
    do {
        $more = @($items.Values | Where-Object { $_.Tool -notin $blocked -and @($_.DependsOn | Where-Object { $_ -in $blocked }).Count })
        foreach ($b in $more) {
            $blocked += $b.Tool
            # Credit the dependent to the gap it ultimately waits on (directly, or through another blocked tool).
            $root = $b.DependsOn | Where-Object { $_ -in $blocked } | Select-Object -First 1
            while ($root -and -not ($gaps | Where-Object Tool -eq $root)) { $root = $blockedBy[$root] }
            $blockedBy[$b.Tool] = $root
            $g = $gaps | Where-Object Tool -eq $root | Select-Object -First 1
            if ($g) { $g.Dependents += $b.Tool }
            $items.Remove($b.Tool)
        }
    } while ($more)

    # Stage = 1 + the highest stage depended on (a cycle falls back to stage 1).
    $stageOf = @{}
    $visit = $null
    $visit = {
        param($n, $seen)
        if ($stageOf.ContainsKey($n)) { return $stageOf[$n] }
        if ($n -in $seen) { return 0 }
        $deps = @($items[$n].DependsOn | Where-Object { $items.Contains($_) })
        $s = 1 + (@($deps | ForEach-Object { & $visit $_ (@($seen) + $n) }) + 0 | Measure-Object -Maximum).Maximum
        $stageOf[$n] = $s
        $s
    }
    foreach ($n in @($items.Keys)) { $items[$n].Stage = & $visit $n @() }

    $stages = @(foreach ($g in ($items.Values | Group-Object Stage | Sort-Object { [int]$_.Name })) {
        # One batch per manager and source: uv's PyPI tools and its Pythons are separate commands.
        $batches = @(foreach ($b in ($g.Group | Where-Object Manager | Group-Object { "$($_.Manager.name)|$($_.Source)" })) {
            $m = $b.Group[0].Manager
            $blk = $b.Group[0].Block
            $elev = [bool]$blk.elevate -and -not $elevated
            [pscustomobject]@{
                Manager = $m; Source = $b.Group[0].Source; Block = $blk; Items = @($b.Group)
                Elevate = $elev; ElevateWith = $(if ($elev -and $elevator) { $elevator.executable })
            }
        })
        [pscustomobject]@{ Number = [int]$g.Name; Batches = $batches; Provided = @($g.Group | Where-Object ProvidedBy) }
    })
    [pscustomobject]@{ Items = @($items.Values); Stages = $stages; Gaps = @($gaps) }
}