Netscoot.Shared/Common/Plan.ps1

# Shared move-plan engine used by the move cmdlets so the transaction is uniform.
#
# A move is a transaction: detach references (while old paths resolve) -> move -> reattach.
# Each reconciliation item bundles its Detach + Reattach. Confirmation/preview is the caller's
# job via $PSCmdlet.ShouldProcess (canonical -WhatIf/-Confirm); this engine just runs the
# transaction once the operation is approved.

function New-MoveResult {
    # Build a move cmdlet's result object with a uniform base shape (Engine, Source,
    # Destination, Performed, SkippedCount) plus engine-specific extras, and stamp the
    # given PSTypeName for formatting/filtering. Every move cmdlet emits one of these.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$TypeName,
        [Parameter(Mandatory)][ValidateSet('dotnet', 'native', 'unity', 'powershell')][string]$Engine,
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Destination,
        [bool]$Performed,
        [int]$SkippedCount = 0,
        [hashtable]$Extra = @{}
    )
    $ordered = [ordered]@{
        Engine       = $Engine
        Source       = $Source
        Destination  = $Destination
        Performed    = $Performed
        SkippedCount = $SkippedCount
    }
    foreach ($k in $Extra.Keys) { $ordered[$k] = $Extra[$k] }
    $obj = [pscustomobject]$ordered
    $obj.PSObject.TypeNames.Insert(0, $TypeName)
    return $obj
}

function Resolve-MoveContext {
    # Shared front-half of every move: resolve git usage (red guidance + ShouldContinue/abort
    # when missing) and whether to confirm per-line. Returns { UseGit; PerLine } or $null on
    # abort (after writing the GitMissingAborted error via the calling cmdlet).
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet,
        [switch]$Force,
        [Parameter(Mandatory)]$TargetForError
    )
    $gitMode = Resolve-GitUsage -Cmdlet $Cmdlet -Force:$Force
    if ($gitMode -eq 'Abort') {
        $Cmdlet.WriteError([System.Management.Automation.ErrorRecord]::new(
                [System.OperationCanceledException]::new('Aborted: git not found and the plain-move fallback was declined.'),
                'GitMissingAborted', [System.Management.Automation.ErrorCategory]::OperationStopped, $TargetForError))
        return $null
    }
    [pscustomobject]@{ UseGit = ($gitMode -eq 'Git') }
}

function New-MoveItem {
    # Build one reconciliation item. Pass module-bound scriptblocks (not .GetNewClosure() -
    # closures rebind to the caller's scope and lose module-private functions like Invoke-Dotnet)
    # and hand loop values in via *Args. Mark Optional for heuristic/non-load-bearing items.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Description,
        [scriptblock]$Detach,
        [object[]]$DetachArgs = @(),
        [scriptblock]$Reattach,
        [object[]]$ReattachArgs = @(),
        [switch]$Optional
    )
    [pscustomobject]@{
        Description  = $Description
        Detach       = $Detach;   DetachArgs   = $DetachArgs
        Reattach     = $Reattach; ReattachArgs = $ReattachArgs
        Optional     = [bool]$Optional
    }
}

function Invoke-MovePlan {
    # Run the move transaction: detach all reconciliation items (old paths still resolve),
    # perform the move, reattach all items. Confirmation/-WhatIf is the caller's ShouldProcess
    # gate - this only runs once approved.
    #
    # Rollback: any step that fails throws (Invoke-Dotnet throws on non-zero exit; Move-PathTracked
    # throws on a failed move). To avoid leaving a half-reconciled repository, the caller passes the files
    # the reconciliation edits (-BackupPath) and a move-reversing scriptblock (-Rollback). On any
    # failure this restores those files from a snapshot and reverses the move, returning the repository to
    # its pre-move state. This is the safety net for the -Force (no-git) path, which otherwise has
    # no git history to recover from; with git it complements (does not replace) `git restore`.
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)][string]$Caption,
        [AllowEmptyCollection()][object[]]$Items = @(),
        [Parameter(Mandatory)][scriptblock]$Move,
        [object[]]$MoveArgs = @(),
        [string[]]$BackupPath = @(),
        [scriptblock]$Rollback,
        [object[]]$RollbackArgs = @()
    )
    Write-Verbose "Reconciling $(@($Items).Count) reference(s) around: $Caption"

    # Snapshot the files the reconciliation will edit, keyed by their original path.
    $snapDir = $null
    $snap = [ordered]@{}
    if (@($BackupPath).Count) {
        $snapDir = Join-Path ([System.IO.Path]::GetTempPath()) ("netscoot_snap_" + [guid]::NewGuid().ToString('N').Substring(0, 8))
        New-Item -ItemType Directory -Path $snapDir | Out-Null
        $n = 0
        foreach ($p in (@($BackupPath) | Select-Object -Unique)) {
            if ($p -and (Test-Path -LiteralPath $p -PathType Leaf)) {
                $copy = Join-Path $snapDir ("f{0}" -f $n); $n++
                Copy-Item -LiteralPath $p -Destination $copy -Force
                $snap[$p] = $copy
            }
        }
    }

    $moved = $false
    try {
        # NOTE: @var is the splat operator (needs a bare variable); @(expr) is array-subexpression
        # and would pass the whole array as one argument. So copy to a local, then splat.
        foreach ($it in $Items) {
            if ($it.Detach) { $da = @($it.DetachArgs); & $it.Detach @da }
        }
        $ma = @($MoveArgs); & $Move @ma
        $moved = $true
        foreach ($it in $Items) {
            if ($it.Reattach) { $ra = @($it.ReattachArgs); & $it.Reattach @ra }
        }
    } catch {
        $cause = $_
        $rollbackOk = $true
        # Reverse the move first (so files return to where the snapshot expects them)...
        if ($moved -and $Rollback) {
            try { $rba = @($RollbackArgs); & $Rollback @rba }
            catch { $rollbackOk = $false; Write-Warning "Rollback move-back failed: $($_.Exception.Message)" }
        }
        # ...then restore every edited file's original content.
        foreach ($orig in $snap.Keys) {
            try { Copy-Item -LiteralPath $snap[$orig] -Destination $orig -Force }
            catch { $rollbackOk = $false; Write-Warning "Rollback restore failed for ${orig}: $($_.Exception.Message)" }
        }
        if ($snapDir) { Remove-Item -LiteralPath $snapDir -Recurse -Force -ErrorAction SilentlyContinue }
        if ($rollbackOk) {
            throw "Move failed and was rolled back to the original state. Cause: $($cause.Exception.Message)"
        }
        throw "Move failed AND rollback was incomplete - the repository may be in a partial state, check git status. Cause: $($cause.Exception.Message)"
    }
    if ($snapDir) { Remove-Item -LiteralPath $snapDir -Recurse -Force -ErrorAction SilentlyContinue }

    [pscustomobject]@{ Applied = @($Items).Count; Skipped = 0 }
}