src/Engine/Plan.ps1

# Plan.ps1 — the ONE place the Mode x Install matrix is decided (gh#29).
#
# Before this file the decision was written out four times — the startup
# loader, the actuator, the deferred ThreadJob body and the grid's install
# task — and the four had drifted apart (gh#19, gh#20, gh#21, gh#25).
# Everything now goes through three functions:
#
# Get-PSMMEntryPlan entry (+ intent) -> a structured plan object
# Invoke-PSMMPlanAction plan -> executes the DISK/GALLERY half, one line out
# Get-PSMMJobPrelude the source text a ThreadJob dot-sources so it runs
# these real functions instead of re-implementing them
#
# Scheduling is DERIVED, not declared: an import is always foreground, and
# every disk or gallery operation is always background — in all nine cells.
# That is what keeps the gallery off the profile hot path (gh#19). Nothing
# outside this file may branch on Mode/Install to decide policy; there is a
# static guard for that in Tests/Engine.Plan.Tests.ps1.

# One entry's decision. Intent selects WHOSE decision it is: the startup
# loader's declared policy, or a user pressing i / u on a row (Q-4 — the
# policy governs automatic behaviour, never the user's hands).
function Get-PSMMEntryPlan {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Entry,
        [ValidateSet('Startup', 'Install', 'Update')][string]$Intent = 'Startup',
        [ValidateSet('CurrentUser', 'AllUsers')][string]$Scope = 'CurrentUser'
    )

    $mode     = "$($Entry.Mode)"
    $declared = "$($Entry.Install)"
    $pin      = "$($Entry.Version)"
    $exact    = [bool]$Entry.PinnedExact
    $range    = [bool]($pin -and -not $exact)
    # a pin that NAMES a prerelease implies the prerelease track regardless of
    # the entry's own opt-in — you cannot install 1.2.3-beta4 without it
    $pre      = ([bool]$Entry.AllowPrerelease) -or ([bool]$Entry.PinnedPrerelease)
    $notices  = [System.Collections.Generic.List[string]]::new()

    # the declared policy in the verbs the actuator understands
    $install = switch ($declared) {
        'CheckOnly' { 'none' }
        'Latest'    { 'latest' }
        default     { 'ifmissing' }    # IfMissing, and anything that degraded to it
    }

    # gh#20: an exact pin names ONE version, so there is nothing to update TO.
    # Left alone, Latest means "-Reinstall the version you already have", in
    # the foreground, every single shell start. Degrade it to IfMissing.
    if ($install -eq 'latest' -and $exact) {
        $install = 'ifmissing'
        $notices.Add("pinned to $pin $([char]0x2014) Latest installs the pin once and never updates past it")
    }

    $import = ($mode -eq 'Load')
    # gh#23 and the docs' promise ("pinned modules are never flagged update
    # available"): an exact pin is never checked, because the flag could only
    # ever be noise. A RANGE pin IS checked — against the newest version
    # inside the range (Get-PSMMGalleryLatest -VersionRange), so its flag can
    # actually clear when you press u.
    $check = -not $exact

    if ($mode -eq 'Ignore') {
        # cells 7-9: nothing, and no gallery I/O of any kind
        $import  = $false
        $install = 'none'
        $check   = $false
        if ($Entry.InstallExplicit) {
            $notices.Add('Install policy has no effect while Mode is Ignore')
        }
    }

    # An explicit keypress overrides the declared policy but not the pin: i
    # installs what is missing, u moves to the newest ELIGIBLE version, which
    # for an exact pin is the pin itself (i.e. nothing to do).
    switch ($Intent) {
        'Install' { $import = $false; $install = 'ifmissing'; $check = $false }
        'Update'  {
            $import = $false; $check = $false
            $install = if ($exact) { 'ifmissing' } else { 'latest' }
        }
    }

    # Derived, never declared. 'foreground' means the ONLY thing this plan does
    # is import — nothing touches disk or the gallery. 'background' means there
    # is disk/gallery work, and it is always deferred; cell 4 lands here too,
    # because even a bare presence report is a disk sweep. 'none' is Ignore.
    $schedule = if ($mode -eq 'Ignore' -and $Intent -eq 'Startup') { 'none' }
                elseif ($install -ne 'none' -or -not $import) { 'background' }
                else { 'foreground' }

    # plain words for the grid's context sentence and the startup report
    $reason = if ($Intent -ne 'Startup') {
        if ($install -eq 'latest') { 'update to the newest eligible version' } else { 'install if missing' }
    } elseif ($mode -eq 'Ignore') {
        'off - nothing happens at shell start'
    } elseif ($import) {
        # kept short on purpose: this is one muted line under the grid, and it
        # has to share the row with the session and on-disk clauses
        switch ($install) {
            'latest'    { if ($range) { 'imports at shell start, background-updates inside the pin' }
                          else { 'imports at shell start, background-updates afterwards' } }
            'ifmissing' { 'imports at shell start, background-installs if missing' }
            default     { 'imports at shell start, never auto-installed' }
        }
    } else {
        switch ($install) {
            'latest'    { if ($range) { 'background-updates inside the pin at shell start' }
                          else { 'background-updates to latest at shell start' } }
            'ifmissing' { 'background-installs at shell start when missing' }
            default     { 'watch only - psmm will never install or load this' }
        }
    }

    [pscustomobject]@{
        Name         = "$($Entry.Name)"
        FriendlyName = if ($Entry.FriendlyName) { "$($Entry.FriendlyName)" } else { "$($Entry.Name)" }
        Mode         = $mode
        Declared     = $declared
        Intent       = $Intent
        Import       = $import                        # foreground, this session only
        Install      = $install                       # 'none' | 'ifmissing' | 'latest'
        Check        = $check                          # report-only update check is meaningful
        Schedule     = $schedule                       # 'foreground' | 'background' | 'none'
        Version      = if ($pin) { $pin } else { $null }
        PinnedExact  = $exact
        PinnedRange  = $range
        Prerelease   = $pre
        Scope        = $Scope
        Reason       = $reason
        Notices      = $notices.ToArray()
    }
}

# Execute one plan's DISK/GALLERY half. Never imports — an import is
# foreground and belongs to the caller, which is what stops a background
# install from landing in a session the user is already typing into (Q-2).
# Returns one status line. Callers parse only the 'FAILED ' prefix.
function Invoke-PSMMPlanAction {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Plan)
    $name = "$($Plan.Name)"
    $have = [bool](Get-Module -ListAvailable -Name $name -ErrorAction SilentlyContinue)
    switch ("$($Plan.Install)") {
        'ifmissing' {
            if ($have) { return "ok $name" }
            Install-PSMMModule -Name $name -Version $Plan.Version -Prerelease:([bool]$Plan.Prerelease) -Scope "$($Plan.Scope)"
            return "installed $name"
        }
        'latest' {
            Install-PSMMModule -Name $name -Update -Version $Plan.Version -Prerelease:([bool]$Plan.Prerelease) -Scope "$($Plan.Scope)"
            return "updated $name"
        }
        default {
            # 'none' — CheckOnly. Presence IS the whole report.
            if ($have) { return "ok $name" }
            return "FAILED ${name}: not installed (check-only)"
        }
    }
}

# The source text a ThreadJob dot-sources so it executes psmm's REAL engine
# functions. Module functions are invisible inside Start-ThreadJob — its
# runspace holds no reference to this module — which is why the matrix used
# to be re-implemented by hand in two job bodies, and why those copies
# drifted (gh#25, gh#29). Shipping the definitions keeps exactly one copy.
#
# Cost is a few string concatenations plus one parse inside the job; it is
# never on the import path, and only runs when there IS background work.
function Get-PSMMJobPrelude {
    [CmdletBinding()]
    param(
        [string[]]$FunctionName = @(
            'Get-PSMMPrereleaseLabel'
            'Get-PSMMNormalVersion'
            'Test-PSMMVersionInstalled'
            'Test-PSMMInstalledPrerelease'
            'Install-PSMMModule'
            'Invoke-PSMMPlanAction'
        )
    )
    $sb = [System.Text.StringBuilder]::new()
    foreach ($n in $FunctionName) {
        $cmd = Get-Command -Name $n -CommandType Function -ErrorAction Stop
        $null = $sb.AppendLine("function $n {").AppendLine($cmd.Definition).AppendLine('}')
    }
    $sb.ToString()
}