tests/TestSupport.ps1

# Shared helpers for the Pester suite. Dot-source from a test file's BeforeAll:
# . "$PSScriptRoot/TestSupport.ps1"
# (Not named *.Tests.ps1, so Pester never runs it as a test file.)

# Set-DFTestXdg's save stack. Initialized here (in the test file's script scope,
# where this file is dot-sourced) so the helpers stay StrictMode-safe.
$script:DFTestSavedXdg = [System.Collections.Generic.Stack[hashtable]]::new()

function Set-DFTestConfig {
    <#
    .SYNOPSIS
        Sets the session config the module files read through Get-DFConfig, without validation.
    .DESCRIPTION
        Tests dot-source the module files, so Get-DFConfig reads the test file's
        $script:DFSessionConfig. $null clears it (every Get-DFConfig returns its
        default). Use Set-DFSessionConfig instead to test validation warnings.
            BeforeEach { Set-DFTestConfig @{ Tools = @('bat') } }
            AfterEach { Set-DFTestConfig $null }
    .PARAMETER Config
        The config hashtable, or $null.
    .OUTPUTS
        None.
    #>

    param([System.Collections.IDictionary]$Config)
    $copy = @{}
    if ($Config) { foreach ($k in $Config.Keys) { $copy[$k] = $Config[$k] } }
    $script:DFSessionConfig = $copy
}

function Reset-DFTestSession {
    <#
    .SYNOPSIS
        Clears the session state Start-DFSession and Register-DFTool keep, so tests don't see each other's tools.
    .DESCRIPTION
        Register-DFTool folds the session's requested tools into each call (so
        role winners are computed over everything requested). Within one test
        file that state persists, so every Describe that registers tools calls
        this from its BeforeEach. Also clears the per-name record cache and
        the full tool DB cache.
    .OUTPUTS
        None.
    #>

    $script:DFSessionStatus = $null
    $script:DFSessionToolDb = @{}
    $script:DFSessionRoleWinners = $null
    $script:DFToolRecordCache = @{}
    $script:DFToolDb = $null
}

function Get-DFTestModuleFile {
    <#
    .SYNOPSIS
        Returns every module source file, in the order DotForge.psm1 loads them.
    .DESCRIPTION
        Tests dot-source the module's own files (rather than Import-Module) so
        Mock works on private functions without -ModuleName. Loading all of them,
        in the psm1's order, replaces hand-maintained per-test load lists: a new
        private dependency can no longer break unrelated test files.

        Dot-sourcing must happen in the test's scope, so this returns paths:
            BeforeAll {
                . "$PSScriptRoot/TestSupport.ps1"
                foreach ($f in Get-DFTestModuleFile) { . $f }
            }
        Tools/*.ps1 companions are not included; a test loads its sidecar itself.
        The on-demand modules (Modules/DotForge.Catalog, Modules/DotForge.Helpers)
        are loaded into the same scope, so a test can reach any function.
    .OUTPUTS
        System.String[]. Absolute paths: Shared/, Private/, Public/, then each
        on-demand module's Private/ and Public/.
    #>

    $root = Split-Path $PSScriptRoot -Parent
    # Same enumeration as DotForge.psm1 (and the on-demand modules' loaders), so
    # load order -- and therefore which $script: initializer runs first -- matches.
    foreach ($dir in Get-DFTestSourceDir) {
        (Get-ChildItem -Path $dir -Filter '*.ps1').FullName
    }
}

function Set-DFTestXdg {
    <#
    .SYNOPSIS
        Points all four XDG_*_HOME variables at folders under $TestDrive, saving the originals.
    .DESCRIPTION
        An unset XDG variable means the real default folder under $HOME
        (Get-DFXdgPath), so a test that can reach any cache/state/config/data
        writer must redirect all four. Pair with Restore-DFTestXdg:
            BeforeEach { Set-DFTestXdg }
            AfterEach { Restore-DFTestXdg }
        Folders are not created; DotForge creates what it writes.
    .PARAMETER Root
        Parent folder for the four homes. Default: $TestDrive\xdg.
    .OUTPUTS
        None.
    #>

    param([string]$Root = (Join-Path $TestDrive 'xdg'))
    # A stack, so nested use (BeforeAll in a Describe, BeforeEach in a Context
    # inside it) restores each level in turn instead of losing the outer save.
    $saved = @{}
    foreach ($kind in 'CONFIG', 'CACHE', 'DATA', 'STATE') {
        $name = "XDG_${kind}_HOME"
        $saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
        [Environment]::SetEnvironmentVariable($name, (Join-Path $Root $kind.ToLowerInvariant()), 'Process')
    }
    # git's global config too: activating delta runs `git config --global`, and no
    # test may ever change the developer's real one. (Restored with the XDG folders.)
    $saved['GIT_CONFIG_GLOBAL'] = [Environment]::GetEnvironmentVariable('GIT_CONFIG_GLOBAL', 'Process')
    New-Item -ItemType Directory -Path $Root -Force | Out-Null
    [Environment]::SetEnvironmentVariable('GIT_CONFIG_GLOBAL', (Join-Path $Root 'gitconfig'), 'Process')
    $script:DFTestSavedXdg.Push($saved)
}

function Restore-DFTestXdg {
    <#
    .SYNOPSIS
        Restores the XDG_*_HOME values Set-DFTestXdg saved (unset ones become unset again).
    .OUTPUTS
        None.
    #>

    if ($script:DFTestSavedXdg.Count -eq 0) {
        throw 'Restore-DFTestXdg called without a matching Set-DFTestXdg.'
    }
    foreach ($entry in $script:DFTestSavedXdg.Pop().GetEnumerator()) {
        [Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
    }
}

function Remove-DFTestGlobal {
    <#
    .SYNOPSIS
        Removes global functions and aliases a test defined, and nothing else.
    .DESCRIPTION
        Remove-Item ignores a scope qualifier on the Function: and Alias:
        drives: `Remove-Item function:global:git` silently does nothing, so a
        stub defined with `function global:git {}` outlived its test and every
        later test file in the run called the stub instead of git.exe.
        An unqualified `Remove-Item function:git` is no better: it removes the
        nearest definition, which may be a copy the test file dot-sourced.

        This runs the removal inside a throwaway module. A module's scope chain
        is module, then global, skipping the caller's scopes, so the name
        resolves to the global definition only.
    .PARAMETER Function
        Global function names to remove. Missing names are ignored.
    .PARAMETER Alias
        Global alias names to remove. Missing names are ignored.
    .EXAMPLE
        AfterEach { Remove-DFTestGlobal -Function git, scoop -Alias sins }
    .OUTPUTS
        None.
    #>

    param([string[]]$Function = @(), [string[]]$Alias = @())
    $scope = New-Module -ScriptBlock { }
    & $scope {
        param($Function, $Alias)
        foreach ($n in $Function) {
            if (Test-Path "function:global:$n") { Remove-Item -LiteralPath "function:$n" -Force }
        }
        foreach ($n in $Alias) {
            # Test-Path alias:global:<n> is always false, even for an existing global
            # alias, so ask Get-Alias for the global scope directly.
            if (Get-Alias -Name $n -Scope Global -ErrorAction Ignore) { Remove-Alias -Name $n -Scope Global -Force }
        }
    } $Function $Alias
}

function Get-DFTestSourceDir {
    <#
    .SYNOPSIS
        Every folder of module source files, in load order: Shared, the core's Private and Public, then each on-demand module's.
    .OUTPUTS
        System.String[]. Absolute folder paths.
    #>

    $root = Split-Path $PSScriptRoot -Parent
    foreach ($d in 'Shared', 'Private', 'Public') { Join-Path $root $d }
    foreach ($m in 'DotForge.Catalog', 'DotForge.Helpers') {
        foreach ($d in 'Private', 'Public') { Join-Path $root 'Modules' $m $d }
    }
}