src/Engine/Locations.ps1

# Locations.ps1 — module LOCATIONS as a thing you can manage: add a folder to
# the search path, and move module folders between locations (gh#4, gh#12,
# gh#13).
#
# Layout assumption, straight out of about_PSModulePath: an installed module
# lives at <root>\<Name>\<version>\ (versioned install) or <root>\<Name>\
# (side-loaded). Get-Module -ListAvailable reports ModuleBase = the folder
# holding the manifest, so the folder to MOVE is the <root>\<Name> tree, never
# the version folder alone - moving one version out of a multi-version tree
# would leave a half-populated module behind.

# Can we create/delete inside this directory? Probe by writing a temp file:
# ACL maths is a losing game (inherited denies, network shares, OneDrive).
function Test-PSMMDirectoryWritable {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    try {
        if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return $false }
        $probe = Join-Path $Path (".psmm_w_{0}.tmp" -f ([guid]::NewGuid().ToString('N')))
        [System.IO.File]::WriteAllText($probe, '')
        Remove-Item -LiteralPath $probe -Force -ErrorAction Stop
        $true
    } catch { $false }
}

# The <root>\<Name> tree a ModuleBase belongs to, plus the search root above
# it. $Name disambiguates the two layouts without guessing at version syntax.
function Get-PSMMModuleTree {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$ModuleBase,
        [Parameter(Mandatory)][string]$Name
    )
    $base = $ModuleBase.TrimEnd('\', '/')
    $leaf = Split-Path -Leaf $base
    $tree = if ($leaf -eq $Name) { $base } else { Split-Path -Parent $base }
    [pscustomobject]@{
        Tree = $tree
        Root = (Split-Path -Parent $tree)
    }
}

# Total size of a folder tree in bytes (0 when unreadable) - used to tell the
# user what a move is about to shift before they commit to it.
function Get-PSMMFolderSize {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    try {
        $sum = (Get-ChildItem -LiteralPath $Path -File -Recurse -Force -ErrorAction SilentlyContinue |
                Measure-Object -Property Length -Sum).Sum
        if ($sum) { [long]$sum } else { 0L }
    } catch { 0L }
}

# Human-readable byte size, one decimal, no trailing period (design system).
function Format-PSMMSize {
    [CmdletBinding()]
    param([long]$Bytes)
    if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) }
    if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) }
    if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) }
    "$Bytes B"
}

# The module folders directly under a search root, with size and version count.
function Get-PSMMLocationModule {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return @() }
    @(Get-ChildItem -LiteralPath $Path -Directory -Force -ErrorAction SilentlyContinue | ForEach-Object {
            $versions = @(Get-ChildItem -LiteralPath $_.FullName -Directory -Force -ErrorAction SilentlyContinue |
                    Where-Object { $_.Name -match '^\d+(\.\d+){0,3}$' })
            [pscustomobject]@{
                Name     = $_.Name
                Path     = $_.FullName
                Versions = $versions.Count
                Bytes    = (Get-PSMMFolderSize -Path $_.FullName)
            }
        })
}

# Move one folder tree to a new parent. Returns the new path; throws with a
# usable message on collision / permission / locked files.
function Move-PSMMFolder {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$TargetRoot
    )
    if (-not (Test-Path -LiteralPath $Source -PathType Container)) { throw "not found: $Source" }
    $leaf = Split-Path -Leaf $Source.TrimEnd('\', '/')
    # GetFullPath, NOT Resolve-Path: the target root legitimately may not exist
    # yet (we create it below), and Resolve-Path throws on a missing path -
    # which would turn "move into a new folder" into a confusing failure.
    $srcFull = [System.IO.Path]::GetFullPath($Source).TrimEnd('\', '/')
    $destFull = [System.IO.Path]::GetFullPath((Join-Path $TargetRoot $leaf)).TrimEnd('\', '/')
    $cmp = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
    if ([string]::Equals($srcFull, $destFull, $cmp)) { throw 'source and target are the same folder' }
    # moving a tree into itself (...\Foo -> ...\Foo\sub) would either fail
    # half-way or recurse - refuse it before anything is touched
    if ($destFull.StartsWith($srcFull + [System.IO.Path]::DirectorySeparatorChar, $cmp)) {
        throw 'the target folder is inside the folder being moved'
    }
    if (Test-Path -LiteralPath $destFull) { throw "'$leaf' already exists in the target location" }
    if (-not (Test-Path -LiteralPath $TargetRoot -PathType Container)) {
        New-Item -ItemType Directory -Force -Path $TargetRoot | Out-Null
    }
    try {
        Move-Item -LiteralPath $Source -Destination $destFull -ErrorAction Stop
    } catch {
        # Move-Item across volumes is copy-then-delete and is NOT atomic: a
        # locked or unreadable file part-way through leaves files in BOTH
        # places. psmm cannot roll that back safely (deleting the partial copy
        # could destroy the only copy of a file that did move), so say exactly
        # where things stand instead of pretending nothing happened.
        if (Test-Path -LiteralPath $destFull) {
            throw ("$($_.Exception.Message) - PARTIALLY MOVED: files exist in both " +
                "'$Source' and '$destFull'. Compare them before deleting either.")
        }
        throw
    }
    $destFull
}

# Move every installed copy of ONE module to another search root (gh#4).
# $InstalledVersions is the entry's list (Version / Path / Scope). Returns one
# result record per module tree: Moved + From/To, or Skipped + Reason.
function Move-PSMMModuleTree {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][AllowEmptyCollection()] $InstalledVersions,
        [Parameter(Mandatory)][string]$TargetRoot
    )
    $trees = @($InstalledVersions | ForEach-Object { (Get-PSMMModuleTree -ModuleBase "$($_.Path)" -Name $Name).Tree } |
            Select-Object -Unique | Where-Object { $_ })
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($t in $trees) {
        try {
            $dest = Move-PSMMFolder -Source $t -TargetRoot $TargetRoot
            $results.Add([pscustomobject]@{ Name = $Name; From = $t; To = $dest; Moved = $true; Reason = '' })
        } catch {
            $results.Add([pscustomobject]@{ Name = $Name; From = $t; To = ''; Moved = $false; Reason = $_.Exception.Message })
        }
    }
    @($results)
}

# Move every module folder from one search root to another (gh#13).
# Skips - never fails on - collisions and locked trees; $OnProgress is called
# as &$OnProgress $index $total $name before each move.
# $Skip maps a module name to the REASON it is being skipped, because the
# caller knows why (imported in this session / already in the target) and the
# user is owed the real reason, not a guess. Lookup is case-insensitive
# (PowerShell hashtable default), like every other module-name comparison here.
function Move-PSMMLocationContent {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Target,
        [hashtable]$Skip = @{},
        [scriptblock]$OnProgress
    )
    $mods = @(Get-PSMMLocationModule -Path $Source)
    $results = [System.Collections.Generic.List[object]]::new()
    $i = 0
    foreach ($m in $mods) {
        $i++
        if ($OnProgress) { & $OnProgress $i $mods.Count $m.Name }
        if ($Skip.ContainsKey($m.Name)) {
            $results.Add([pscustomobject]@{ Name = $m.Name; Moved = $false; Reason = "$($Skip[$m.Name])" })
            continue
        }
        try {
            $null = Move-PSMMFolder -Source $m.Path -TargetRoot $Target
            $results.Add([pscustomobject]@{ Name = $m.Name; Moved = $true; Reason = '' })
        } catch {
            $results.Add([pscustomobject]@{ Name = $m.Name; Moved = $false; Reason = $_.Exception.Message })
        }
    }
    @($results)
}

# --- adding a location to the module search path (gh#12) ------------------

# Is this path already on $env:PSModulePath? (trailing separators and case are
# noise on Windows; on Linux/macOS only the separator is)
function Test-PSMMModulePathContains {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    $norm = $Path.TrimEnd('\', '/')
    $cmp = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
    foreach ($p in ($env:PSModulePath -split [System.IO.Path]::PathSeparator)) {
        if ($p -and [string]::Equals($p.TrimEnd('\', '/'), $norm, $cmp)) { return $true }
    }
    $false
}

# Add a folder to the SESSION search path (first or last). Returns $false when
# it was already there.
function Add-PSMMModulePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [switch]$First
    )
    if (Test-PSMMModulePathContains -Path $Path) { return $false }
    $sep = [System.IO.Path]::PathSeparator
    $rest = @($env:PSModulePath -split $sep | Where-Object { $_ })
    $env:PSModulePath = if ($First) { (@($Path) + $rest) -join $sep } else { (@($rest) + @($Path)) -join $sep }
    $true
}

# Persist a folder in the USER-scope PSModulePath environment variable, which
# pwsh merges into $env:PSModulePath for every new session (about_PSModulePath).
# NOT powershell.config.json: its PSModulePath key REPLACES the CurrentUser
# location rather than adding one - that is what Set-PSMMUserModulePath does.
# Windows-only; returns the new user-scope value. $TargetSeam is a test seam.
function Add-PSMMPersistentModulePath {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [string]$Existing,
        [switch]$WhatIfOnly
    )
    if (-not $IsWindows -and -not $PSBoundParameters.ContainsKey('Existing')) {
        throw 'persisting a module location is a Windows feature - add it to your $PROFILE instead'
    }
    $cur = if ($PSBoundParameters.ContainsKey('Existing')) { "$Existing" }
           else { "$([Environment]::GetEnvironmentVariable('PSModulePath', 'User'))" }
    $sep = [System.IO.Path]::PathSeparator
    $parts = @($cur -split $sep | Where-Object { $_ })
    $cmp = if ($IsWindows) { [System.StringComparison]::OrdinalIgnoreCase } else { [System.StringComparison]::Ordinal }
    foreach ($p in $parts) {
        if ([string]::Equals($p.TrimEnd('\', '/'), $Path.TrimEnd('\', '/'), $cmp)) { return $cur }
    }
    $new = (@($parts) + @($Path)) -join $sep
    if (-not $WhatIfOnly) { [Environment]::SetEnvironmentVariable('PSModulePath', $new, 'User') }
    $new
}