src/Engine/State.ps1

# State.ps1 — refresh entry state from the live session and the disk.

# --- psmm's own modules vs the user's session (gh#16) ---------------------
#
# psmm manages the modules YOU asked for. The ones it imports to render that
# view are infrastructure and must not be reported as if you had asked for
# them: they inflate the "N loaded" count, they turn up in the unmanaged scan
# as if they were yours to adopt, and - worst - the files > apply sweep would
# happily Remove-Module psmm and its own UI engine mid-session.
#
# Two related but DIFFERENT sets, and conflating them is the trap:
#
# own modules psmm + its UI dependency. Marked as infrastructure in
# the UI, never unloaded by psmm, never counted as yours.
# private imports the module OBJECTS psmm imported into its own session
# state. Subtracted from the loaded view, because your
# prompt cannot see them.
#
# The second set is tracked by INSTANCE, not by name, and that distinction is
# load-bearing: if you import the same module globally yourself, `Get-Module`
# inside psmm returns YOUR instance (verified), so a by-name exclusion would
# hide a module you really do have loaded.

function Get-PSMMUIDependencyName { 'PwshSpectreConsole' }

function Get-PSMMOwnModuleName {
    [CmdletBinding()] param()
    $self = ''
    try { $self = "$($ExecutionContext.SessionState.Module.Name)" } catch { }
    if (-not $self) { $self = 'psmm' }
    @($self, (Get-PSMMUIDependencyName))
}

function Test-PSMMOwnModule {
    [CmdletBinding()]
    param([AllowNull()][string]$Name)
    if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
    foreach ($n in (Get-PSMMOwnModuleName)) {
        if ([string]::Equals($n, $Name, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
    }
    $false
}

# Record a module psmm imported into its OWN session state.
function Register-PSMMPrivateImport {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Module)
    if (-not $script:PSMM_PrivateImports) { $script:PSMM_PrivateImports = [System.Collections.Generic.List[object]]::new() }
    if (-not (Test-PSMMPrivateImport -Module $Module)) { $script:PSMM_PrivateImports.Add($Module) }
}

function Get-PSMMPrivateImport {
    [CmdletBinding()] param()
    if ($script:PSMM_PrivateImports) { @($script:PSMM_PrivateImports) } else { @() }
}

# Is THIS module object one psmm imported for itself? Reference equality, not
# name equality - see the note above.
function Test-PSMMPrivateImport {
    [CmdletBinding()]
    param([AllowNull()] $Module)
    if (-not $Module -or -not $script:PSMM_PrivateImports) { return $false }
    foreach ($m in $script:PSMM_PrivateImports) {
        if ([object]::ReferenceEquals($m, $Module)) { return $true }
    }
    $false
}

# Cheap: in-session loaded modules only (no disk scan).
# NB: $Entries params here allow null/empty - an empty entry set is a normal
# state (fresh machine, zero configs), and PowerShell returns empty arrays
# from functions as $null.
#
# Session-state caveat (gh#2): `Get-Module` run inside the psmm module returns
# the GLOBAL module table plus anything imported into psmm's own private state.
# Every import psmm performs ON THE USER'S BEHALF passes -Global (see
# Import-PSMMModuleTimed), so those two sets coincide - except for the modules
# psmm imports FOR ITSELF, which are subtracted here by instance (gh#16).
function Update-PSMMLoaded {
    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()] $Entries)
    if (-not $Entries) { return }
    $loaded = @{}
    Get-Module -ErrorAction SilentlyContinue | ForEach-Object {
        # psmm's own private copy is not in the user's session - reporting it
        # as loaded is the same lie gh#2 was about, just smaller
        if (Test-PSMMPrivateImport -Module $_) { return }
        $loaded[$_.Name] = $_
    }
    foreach ($e in $Entries) {
        $m = $loaded[$e.Name]
        $e.Loaded = $loaded.ContainsKey($e.Name)
        $e.LoadedVersion = if ($m) { $m.Version } else { $null }
        $e.LoadedPrerelease = Get-PSMMPrereleaseLabel -ModuleInfo $m
    }
}

# Availability, versions and scope from disk.
# With -Name: cheap, name-filtered refresh for just those modules.
# Without: ONE full -ListAvailable scan (call sparingly: open + reload only).
function Update-PSMMAvailable {
    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()] $Entries,
        [string[]]$Name
    )
    if (-not $Entries) { return }
    $apply = {
        param($entry, $mods)   # $mods: Get-Module -ListAvailable results, one per version
        $sorted = @($mods | Sort-Object Version -Descending)
        $entry.Installed         = [bool]$sorted
        $entry.InstalledVersion  = if ($sorted) { $sorted[0].Version } else { $null }
        # the prerelease label rides along from the manifest we already have in
        # hand - no extra disk cost, and versions must never be shown without it
        $entry.InstalledPrerelease = if ($sorted) { Get-PSMMPrereleaseLabel -ModuleInfo $sorted[0] } else { '' }
        $entry.InstalledVersions = @($sorted | ForEach-Object {
            [pscustomobject]@{
                Version    = $_.Version
                Prerelease = (Get-PSMMPrereleaseLabel -ModuleInfo $_)
                Path       = $_.ModuleBase
                Scope      = Get-PSMMScopeForPath -Path $_.ModuleBase
                ModuleType = "$($_.ModuleType)"
                Manifest   = "$($_.Path)"
            }
        })
        $scopes = @($entry.InstalledVersions.Scope | Select-Object -Unique)
        $entry.InstallScope = if ($scopes.Count -gt 1) { 'mixed' } elseif ($scopes.Count -eq 1) { $scopes[0] } else { $null }
    }

    if ($Name) {
        foreach ($nm in $Name) {
            $mods = @(Get-Module -ListAvailable -Name $nm -ErrorAction SilentlyContinue)
            foreach ($e in @($Entries | Where-Object Name -eq $nm)) { & $apply $e $mods }
        }
        return
    }
    $avail = @{}
    Get-Module -ListAvailable -ErrorAction SilentlyContinue | Group-Object Name | ForEach-Object {
        $avail[$_.Name] = $_.Group
    }
    foreach ($e in $Entries) {
        & $apply $e @(if ($e.Name -and $avail.ContainsKey($e.Name)) { $avail[$e.Name] } else { @() })
    }
}

# Modules installed with MORE than one version on disk (cleanup feature):
# every version except the newest is a pruning candidate.
function Get-PSMMDuplicateVersion {
    [CmdletBinding()] param()
    Get-Module -ListAvailable -ErrorAction SilentlyContinue |
        Group-Object Name |
        Where-Object { @($_.Group.Version | Select-Object -Unique).Count -gt 1 } |
        ForEach-Object {
            $sorted = @($_.Group | Sort-Object Version -Descending)
            [pscustomobject]@{
                Name     = $_.Name
                Latest   = $sorted[0].Version
                Obsolete = @($sorted | Select-Object -Skip 1 | ForEach-Object {
                    [pscustomobject]@{
                        Version = $_.Version
                        Path    = $_.ModuleBase
                        Scope   = Get-PSMMScopeForPath -Path $_.ModuleBase
                    }
                })
            }
        }
}

# Installed modules NOT named in any config file (unmanaged-module feature).
# Returns one object per module name, newest version, with scope.
# psmm's own modules are excluded: offering the user psmm's UI engine as
# something to "adopt into a config" is noise, not a feature (gh#16).
function Get-PSMMUnmanagedModule {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string[]]$ManagedNames)
    $managed = [System.Collections.Generic.HashSet[string]]::new(
        [string[]](@($ManagedNames) + @(Get-PSMMOwnModuleName)), [System.StringComparer]::OrdinalIgnoreCase)
    Get-Module -ListAvailable -ErrorAction SilentlyContinue |
        Group-Object Name |
        Where-Object { -not $managed.Contains($_.Name) } |
        ForEach-Object {
            $newest = @($_.Group | Sort-Object Version -Descending)[0]
            [pscustomobject]@{
                Name        = $_.Name
                Version     = $newest.Version
                Scope       = Get-PSMMScopeForPath -Path $newest.ModuleBase
                Description = $newest.Description
            }
        }
}