Napreth.PSDirEnv.psm1

Set-StrictMode -Version 3.0

$script:PdeEnabled = $false
$script:PdeBusy = $false
$script:PdeLayers = [System.Collections.Generic.List[object]]::new()
$script:PdeProfiles = @()
$script:PdeFailures = @{}
$script:PdeNotices = [System.Collections.Generic.HashSet[string]]::new()
$script:PdePrompt = $null
$script:PdeOriginalPrompt = $null
$script:PdePathComparer = if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }
$script:PdeAutomaticVariables = @(
    '?', '^', '$', '_', 'args', 'ConsoleFileName', 'EnabledExperimentalFeatures', 'Error',
    'Event', 'EventArgs', 'EventSubscriber', 'ExecutionContext', 'false', 'foreach', 'HOME',
    'Host', 'input', 'IsCoreCLR', 'IsLinux', 'IsMacOS', 'IsWindows', 'LASTEXITCODE',
    'Matches', 'MyInvocation', 'NestedPromptLevel', 'null', 'PID', 'PROFILE', 'PSBoundParameters',
    'PSCommandPath', 'PSCulture', 'PSDebugContext', 'PSEdition', 'PSHOME', 'PSItem',
    'PSScriptRoot', 'PSSenderInfo', 'PSUICulture', 'PSVersionTable', 'PWD', 'Sender',
    'ShellId', 'StackTrace', 'switch', 'this', 'true', 'PSDefaultParameterValues'
)
$script:PdeCommands = @('Enable-DirEnv', 'Disable-DirEnv', 'Approve-DirEnv', 'Revoke-DirEnv', 'Update-DirEnv', 'Get-DirEnv', 'prompt')
$stateRoot = if ($IsWindows) {
    [Environment]::GetFolderPath('LocalApplicationData')
} elseif ($env:XDG_STATE_HOME) {
    $env:XDG_STATE_HOME
} else {
    Join-Path $HOME '.local/state'
}
$script:PdeStorePath = Join-Path $stateRoot 'PSDirEnv/approvals.json'
Remove-Variable stateRoot

function Format-PdeMessage {
    param([string]$Message, [switch]$Error)
    if ($Message.StartsWith('psdirenv: ')) { return $Message }
    $prefix = if ($Error) { 'psdirenv: error ' } else { 'psdirenv: ' }
    return $prefix + $Message
}

function Write-PdeLog {
    [CmdletBinding()]
    param([string]$Message, [switch]$Error, [switch]$Quiet)
    if (!$Quiet) {
        # Use the information stream for human-readable logs, keeping command
        # results and the original prompt's return value on the success stream.
        $text = Format-PdeMessage $Message -Error:$Error
        $color = $env:TERM -ne 'dumb' -and [string]::IsNullOrEmpty($env:NO_COLOR) -and
            $PSStyle.OutputRendering -ne 'PlainText' -and
            ($PSStyle.OutputRendering -eq 'Ansi' -or $Host.UI.SupportsVirtualTerminal)
        if ($color) {
            # direnv uses ANSI red for errors and resets normal status output.
            $text = if ($Error -or $text.StartsWith('psdirenv: error ')) { "`e[31m${text}`e[0m" } else { "`e[0m$text" }
        }
        Write-Information -MessageData $text -InformationAction Continue
    }
}

function Format-PdePath {
    param([string]$Path)
    $homePrefix = $HOME.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
    if ($Path.StartsWith($homePrefix, $(if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }))) {
        return '~' + [IO.Path]::DirectorySeparatorChar + $Path.Substring($homePrefix.Length)
    }
    return $Path
}

function New-PdeMap {
    param([switch]$Environment)
    $comparer = if ($Environment) { $script:PdePathComparer } else { [StringComparer]::OrdinalIgnoreCase }
    return ,([hashtable]::new($comparer))
}

function Test-PdeReservedName {
    param([string]$Name)
    return $Name -match '(^Pde|^.*-Pde)' -or $Name -in $script:PdeCommands
}

function Get-PdeSnapshot {
    $snapshot = @{
        Environment = New-PdeMap -Environment
        Variable = New-PdeMap
        Function = New-PdeMap
        Alias = New-PdeMap
    }
    foreach ($entry in [Environment]::GetEnvironmentVariables('Process').GetEnumerator()) {
        $snapshot.Environment[$entry.Key] = [pscustomobject]@{ Value = [string]$entry.Value }
    }
    foreach ($variable in Get-Variable -Scope Global) {
        if ($variable.Name -in $script:PdeAutomaticVariables) { continue }
        $snapshot.Variable[$variable.Name] = [pscustomobject]@{
            Value = $variable.Value; Options = $variable.Options; Description = $variable.Description
        }
    }
    # The provider exposes inherited global functions as well as this module's helpers.
    # Helpers are never published or changed by the engine; reserve their names below.
    foreach ($function in Get-ChildItem Function:) {
        $snapshot.Function[$function.Name] = [pscustomobject]@{
            ScriptBlock = $function.ScriptBlock; Options = $function.Options; Description = $function.Description
        }
    }
    foreach ($alias in Get-Alias -Scope Global) {
        $snapshot.Alias[$alias.Name] = [pscustomobject]@{
            Definition = $alias.Definition; Options = $alias.Options; Description = $alias.Description
        }
    }
    return $snapshot
}

function Test-PdeStateEqual {
    param([string]$Kind, $Left, $Right)
    if ($null -eq $Left -or $null -eq $Right) { return $null -eq $Left -and $null -eq $Right }
    switch ($Kind) {
        Environment { return [string]::Equals($Left.Value, $Right.Value, [StringComparison]::Ordinal) }
        Variable { return [object]::Equals($Left.Value, $Right.Value) -and $Left.Options -eq $Right.Options -and $Left.Description -eq $Right.Description }
        Function { return [object]::ReferenceEquals($Left.ScriptBlock, $Right.ScriptBlock) -and $Left.Options -eq $Right.Options -and $Left.Description -eq $Right.Description }
        Alias { return $Left.Definition -ceq $Right.Definition -and $Left.Options -eq $Right.Options -and $Left.Description -eq $Right.Description }
    }
}

function Get-PdeChanges {
    param($Before, $After)
    foreach ($kind in @('Environment', 'Variable', 'Function', 'Alias')) {
        $comparer = if ($kind -eq 'Environment') { $script:PdePathComparer } else { [StringComparer]::OrdinalIgnoreCase }
        $names = [System.Collections.Generic.HashSet[string]]::new($comparer)
        foreach ($name in $Before[$kind].Keys) { $null = $names.Add($name) }
        foreach ($name in $After[$kind].Keys) { $null = $names.Add($name) }
        foreach ($name in $names) {
            if (!(Test-PdeStateEqual $kind $Before[$kind][$name] $After[$kind][$name])) {
                [pscustomobject]@{ Kind = $kind; Name = $name; Before = $Before[$kind][$name]; After = $After[$kind][$name] }
            }
        }
    }
}

function Set-PdeState {
    param([string]$Kind, [string]$Name, $State)
    switch ($Kind) {
        Environment {
            if ($null -eq $State) {
                # PowerShell converts $null to an empty System.String at this
                # API boundary. NullString requests deletion on .NET 9+ too.
                [Environment]::SetEnvironmentVariable($Name, [System.Management.Automation.Language.NullString]::Value, 'Process')
            } else {
                [Environment]::SetEnvironmentVariable($Name, $State.Value, 'Process')
            }
        }
        Variable {
            if ($null -eq $State) { Remove-Variable -Scope Global -Name $Name -Force -ErrorAction Stop }
            else { Set-Variable -Scope Global -Name $Name -Value $State.Value -Option $State.Options -Description $State.Description -Force -ErrorAction Stop }
        }
        Function {
            # Remove uses the inherited name: Function:\global:name does not remove
            # a global function when invoked from a module on PowerShell 7.
            if ($null -eq $State) { Remove-Item -LiteralPath "Function:\$Name" -Force -ErrorAction Stop }
            else {
                Set-Item -LiteralPath "Function:\global:$Name" -Value $State.ScriptBlock -Options $State.Options -Force -ErrorAction Stop
                (Get-Item -LiteralPath "Function:\$Name").Description = $State.Description
            }
        }
        Alias {
            if ($null -eq $State) { Remove-Alias -Scope Global -Name $Name -Force -ErrorAction Stop }
            else { Set-Alias -Scope Global -Name $Name -Value $State.Definition -Option $State.Options -Description $State.Description -Force -ErrorAction Stop }
        }
    }
}

function Undo-PdeChanges {
    param([object[]]$Changes, [switch]$Unconditional)
    $current = Get-PdeSnapshot
    for ($index = $Changes.Count - 1; $index -ge 0; $index--) {
        $change = $Changes[$index]
        if ($Unconditional -or (Test-PdeStateEqual $change.Kind $current[$change.Kind][$change.Name] $change.After)) {
            Set-PdeState $change.Kind $change.Name $change.Before
        }
    }
}

function Write-PdeDiff {
    param($Before, $After, [switch]$Quiet)
    if ($Quiet) { return }
    $items = [System.Collections.Generic.List[string]]::new()
    foreach ($change in Get-PdeChanges $Before $After) {
        $sign = if ($null -eq $change.Before) { '+' } elseif ($null -eq $change.After) { '-' } else { '~' }
        $name = switch ($change.Kind) {
            Environment { $change.Name }
            Variable { '$' + $change.Name }
            Function { 'function:' + $change.Name }
            Alias { 'alias:' + $change.Name }
        }
        $items.Add($sign + $name)
    }
    if ($items.Count) {
        $items.Sort([StringComparer]::Ordinal)
        Write-PdeLog ('export ' + ($items -join ' '))
    }
}

function Read-PdeApprovals {
    if (!(Test-Path -LiteralPath $script:PdeStorePath -PathType Leaf)) { return New-PdeMap -Environment }
    try { $store = Get-Content -LiteralPath $script:PdeStorePath -Raw -ErrorAction Stop | ConvertFrom-Json -AsHashtable -ErrorAction Stop }
    catch { throw (Format-PdeMessage "failed to parse ${script:PdeStorePath}: $($_.Exception.Message)" -Error) }
    if ($store -isnot [System.Collections.IDictionary] -or !$store.ContainsKey('Version') -or !$store.ContainsKey('Approvals') -or
        $store.Version -ne 1 -or $store.Approvals -isnot [System.Collections.IDictionary]) {
        throw (Format-PdeMessage "invalid approval store: $script:PdeStorePath" -Error)
    }
    $approvals = New-PdeMap -Environment
    foreach ($path in $store.Approvals.Keys) {
        if ($store.Approvals[$path] -isnot [string] -or $store.Approvals[$path] -notmatch '^[A-Fa-f0-9]{64}$') {
            throw (Format-PdeMessage "invalid approval entry: $path" -Error)
        }
        $approvals[$path] = $store.Approvals[$path]
    }
    return ,$approvals
}

function Write-PdeApproval {
    param([string]$Path, [AllowNull()][string]$Hash)
    $directory = Split-Path -Parent $script:PdeStorePath
    $null = New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop
    $lock = $null
    $temporaryPath = Join-Path $directory ('.approvals-' + [guid]::NewGuid().ToString('N') + '.tmp')
    try {
        for ($attempt = 0; $attempt -lt 40; $attempt++) {
            try {
                $lock = [IO.File]::Open($script:PdeStorePath + '.lock', 'OpenOrCreate', 'ReadWrite', 'None')
                break
            } catch [IO.IOException] { Start-Sleep -Milliseconds 50 }
        }
        if ($null -eq $lock) { throw (Format-PdeMessage 'approval store is busy; try again' -Error) }
        $approvals = Read-PdeApprovals
        if ([string]::IsNullOrEmpty($Hash)) { $approvals.Remove($Path) } else { $approvals[$Path] = $Hash }
        $json = @{ Version = 1; Approvals = $approvals } | ConvertTo-Json -Depth 4
        [IO.File]::WriteAllText($temporaryPath, $json, [Text.UTF8Encoding]::new($false))
        [IO.File]::Move($temporaryPath, $script:PdeStorePath, $true)
    } finally {
        if ($null -ne $lock) { $lock.Dispose() }
        if ([IO.File]::Exists($temporaryPath)) { [IO.File]::Delete($temporaryPath) }
    }
}

function Resolve-PdeProfilePath {
    param([string]$Path = '.')
    $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    if ([IO.Directory]::Exists($resolved)) { return [IO.Path]::Combine($resolved, '.ps.envrc') }
    if ([IO.Path]::GetFileName($resolved) -ne '.ps.envrc') { throw (Format-PdeMessage 'expected a directory or a .ps.envrc file' -Error) }
    return $resolved
}

function Read-PdeProfile {
    param([string]$Path)
    # Hash and execute the same bytes, so a write between approval checking and
    # evaluation cannot substitute different code.
    try { $bytes = [IO.File]::ReadAllBytes($Path) }
    catch [IO.FileNotFoundException] { throw (Format-PdeMessage '.ps.envrc file not found' -Error) }
    catch [IO.DirectoryNotFoundException] { throw (Format-PdeMessage '.ps.envrc file not found' -Error) }
    $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($bytes))
    $stream = [IO.MemoryStream]::new($bytes, $false)
    $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $true)
    try { $content = $reader.ReadToEnd() } finally { $reader.Dispose(); $stream.Dispose() }
    return [pscustomobject]@{ Path = $Path; Hash = $hash; Content = $content; Approved = $false; Status = 'Unapproved'; Error = $null }
}

function Get-PdeProfiles {
    $location = Get-Location
    if ($location.Provider.Name -ne 'FileSystem') { return }
    $directories = [System.Collections.Generic.List[string]]::new()
    $directory = [IO.DirectoryInfo]::new($location.ProviderPath)
    while ($null -ne $directory) { $directories.Insert(0, $directory.FullName); $directory = $directory.Parent }
    foreach ($path in $directories) {
        $file = [IO.Path]::Combine($path, '.ps.envrc')
        if ([IO.File]::Exists($file)) { Read-PdeProfile $file }
    }
}

function Add-PdeLayer {
    param($Profile)
    $before = Get-PdeSnapshot
    $location = Get-Location
    $module = $null
    try {
        $tokens = $null; $parseErrors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseInput($Profile.Content, $Profile.Path, [ref]$tokens, [ref]$parseErrors)
        if ($parseErrors.Count) { throw ($parseErrors.Message -join [Environment]::NewLine) }
        Set-Location -LiteralPath (Split-Path -Parent $Profile.Path) -ErrorAction Stop
        # A private module keeps functions bound to their .ps.envrc variables and
        # PSScriptRoot. Plain definitions do not need global: qualifiers.
        $module = New-Module -Name ('Napreth.PSDirEnv_Overlay_' + [guid]::NewGuid().ToString('N')) -ArgumentList $ast.GetScriptBlock() -ScriptBlock {
            param($PdeSource)
            $ErrorActionPreference = 'Stop'
            . $PdeSource
            # New-Module otherwise publishes exported functions into its caller's
            # scope, which would shadow globals inside the engine on later reads.
            Export-ModuleMember -Function @() -Alias @() -Variable @()
            Remove-Variable PdeSource, ErrorActionPreference
        } -ErrorAction Stop
        $definitions = & $module {
            [pscustomobject]@{
                Variables = @(Get-Variable -Scope Script)
                Functions = @(Get-ChildItem Function: | Where-Object ModuleName -eq $ExecutionContext.SessionState.Module.Name)
                Aliases = @(Get-Alias -Scope Script | Where-Object ModuleName -eq $ExecutionContext.SessionState.Module.Name)
            }
        }
        foreach ($variable in $definitions.Variables) {
            if ($variable.Name -in $script:PdeAutomaticVariables) { continue }
            if (Test-PdeReservedName $variable.Name) { throw (Format-PdeMessage "reserved name: $($variable.Name)" -Error) }
            if ($variable.Options -band ([System.Management.Automation.ScopedItemOptions]::Constant -bor [System.Management.Automation.ScopedItemOptions]::AllScope)) {
                throw (Format-PdeMessage "cannot overlay Constant or AllScope variable: $($variable.Name)" -Error)
            }
            Set-PdeState Variable $variable.Name ([pscustomobject]@{ Value = $variable.Value; Options = $variable.Options; Description = $variable.Description })
        }
        foreach ($function in $definitions.Functions) {
            if (Test-PdeReservedName $function.Name) { throw (Format-PdeMessage "reserved name: $($function.Name)" -Error) }
            if ($function.Options -band ([System.Management.Automation.ScopedItemOptions]::Constant -bor [System.Management.Automation.ScopedItemOptions]::AllScope)) {
                throw (Format-PdeMessage "cannot overlay Constant or AllScope function: $($function.Name)" -Error)
            }
            Set-PdeState Function $function.Name ([pscustomobject]@{ ScriptBlock = $function.ScriptBlock; Options = $function.Options; Description = $function.Description })
        }
        foreach ($alias in $definitions.Aliases) {
            if (Test-PdeReservedName $alias.Name) { throw (Format-PdeMessage "reserved name: $($alias.Name)" -Error) }
            if ($alias.Options -band ([System.Management.Automation.ScopedItemOptions]::Constant -bor [System.Management.Automation.ScopedItemOptions]::AllScope)) {
                throw (Format-PdeMessage "cannot overlay Constant or AllScope alias: $($alias.Name)" -Error)
            }
            Set-PdeState Alias $alias.Name ([pscustomobject]@{ Definition = $alias.Definition; Options = $alias.Options; Description = $alias.Description })
        }
        $changes = @(Get-PdeChanges $before (Get-PdeSnapshot))
        foreach ($change in $changes) {
            if ($change.Kind -ne 'Environment' -and (Test-PdeReservedName $change.Name)) { throw (Format-PdeMessage "reserved name: $($change.Name)" -Error) }
        }
        $script:PdeLayers.Add([pscustomobject]@{ Path = $Profile.Path; Hash = $Profile.Hash; Changes = $changes; Module = $module })
    } catch {
        $failure = $_
        Undo-PdeChanges -Changes @(Get-PdeChanges $before (Get-PdeSnapshot)) -Unconditional
        throw $failure
    } finally {
        Set-Location -LiteralPath $location.Path -ErrorAction Stop
    }
}

function Remove-PdeLastLayer {
    $index = $script:PdeLayers.Count - 1
    $layer = $script:PdeLayers[$index]
    Undo-PdeChanges -Changes $layer.Changes
    $script:PdeLayers.RemoveAt($index)
    # Dynamic modules are not imported into the module table. Releasing the
    # layer lets them be collected; manually retained functions keep their binding.
}

function Write-PdeNotice {
    param([string]$Key, [string]$Message, [switch]$Quiet)
    if (!$Quiet -and $script:PdeNotices.Add($Key)) { Write-PdeLog $Message -Error }
}

function Update-DirEnv {
    <# .SYNOPSIS
    Refresh .ps.envrc overlays for the current directory. Use -Force to retry or reload.
    #>

    [CmdletBinding()]
    param([switch]$Force, [switch]$Quiet)
    if (!$script:PdeEnabled -or $script:PdeBusy) { return }
    $script:PdeBusy = $true
    try {
        if ($Force) { $script:PdeFailures.Clear(); $script:PdeNotices.Clear() }
        try {
            $profiles = @(Get-PdeProfiles)
            $approvals = Read-PdeApprovals
        } catch {
            $before = if ($script:PdeLayers.Count -and !$Quiet) { Get-PdeSnapshot } else { $null }
            if ($script:PdeLayers.Count) { Write-PdeLog 'unloading' -Quiet:$Quiet }
            while ($script:PdeLayers.Count) { Remove-PdeLastLayer }
            if ($null -ne $before) { Write-PdeDiff $before (Get-PdeSnapshot) }
            $script:PdeProfiles = @()
            throw
        }
        $allowed = [System.Collections.Generic.List[object]]::new()
        $blocked = $false
        foreach ($profile in $profiles) {
            $profile.Approved = $approvals.ContainsKey($profile.Path) -and $approvals[$profile.Path] -ceq $profile.Hash
            if ($blocked) { $profile.Status = 'Blocked'; continue }
            if (!$profile.Approved) {
                $blocked = $true
                $approveCommand = "Approve-DirEnv -Path '$($profile.Path.Replace("'", "''"))'"
                $message = '{0} is blocked. Run `{1}` to approve its content' -f $profile.Path, $approveCommand
                Write-PdeNotice -Key ($profile.Path + ':' + $profile.Hash) -Message $message -Quiet:$Quiet
                continue
            }
            if ($script:PdeFailures.ContainsKey($profile.Path) -and $script:PdeFailures[$profile.Path].Hash -ceq $profile.Hash) {
                $profile.Status = 'Failed'; $profile.Error = $script:PdeFailures[$profile.Path].Error; $blocked = $true
                Write-PdeNotice -Key ('error:' + $profile.Path + ':' + $profile.Hash) -Message $profile.Error -Quiet:$Quiet
                continue
            }
            $profile.Status = 'Approved'
            $allowed.Add($profile)
        }
        $common = 0
        if (!$Force) {
            while ($common -lt $script:PdeLayers.Count -and $common -lt $allowed.Count -and
                $script:PdePathComparer.Equals($script:PdeLayers[$common].Path, $allowed[$common].Path) -and
                $script:PdeLayers[$common].Hash -ceq $allowed[$common].Hash) { $common++ }
        }
        $changing = $script:PdeLayers.Count -gt $common -or $allowed.Count -gt $common
        $before = if ($changing -and !$Quiet) { Get-PdeSnapshot } else { $null }
        if ($script:PdeLayers.Count -gt $common) {
            $message = if ($Force -and $allowed.Count) { 'reloading' } else { 'unloading' }
            Write-PdeLog $message -Quiet:$Quiet
        }
        while ($script:PdeLayers.Count -gt $common) { Remove-PdeLastLayer }
        for ($index = 0; $index -lt $allowed.Count; $index++) {
            $profile = $allowed[$index]
            if ($index -ge $common) {
                Write-PdeLog ('loading ' + (Format-PdePath $profile.Path)) -Quiet:$Quiet
                try { Add-PdeLayer $profile }
                catch {
                    $profile.Status = 'Failed'; $profile.Error = $_.Exception.Message
                    $script:PdeFailures[$profile.Path] = @{ Hash = $profile.Hash; Error = $profile.Error }
                    Write-PdeNotice -Key ('error:' + $profile.Path + ':' + $profile.Hash) -Message $profile.Error -Quiet:$Quiet
                    foreach ($child in $profiles | Select-Object -Skip ($index + 1)) { $child.Status = 'Blocked' }
                    break
                }
            }
            $profile.Status = 'Loaded'
        }
        $script:PdeProfiles = $profiles
        if ($null -ne $before) { Write-PdeDiff $before (Get-PdeSnapshot) }
    } catch { throw (Format-PdeMessage $_.Exception.Message -Error) }
    finally { $script:PdeBusy = $false }
}

function Enable-DirEnv {
    <# .SYNOPSIS
    Enable automatic .ps.envrc loading before the existing prompt is displayed.
    #>

    [CmdletBinding()]
    param([string]$StatePath)
    if ($PSBoundParameters.ContainsKey('StatePath')) {
        $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($StatePath)
        if ($script:PdeEnabled -and !$script:PdePathComparer.Equals($resolved, $script:PdeStorePath)) {
            throw (Format-PdeMessage 'run Disable-DirEnv before changing StatePath' -Error)
        }
        $script:PdeStorePath = $resolved
    }
    if ($script:PdeEnabled) { Update-DirEnv; return }
    $script:PdeOriginalPrompt = (Get-PdeSnapshot).Function['prompt']
    $original = if ($null -ne $script:PdeOriginalPrompt) { $script:PdeOriginalPrompt.ScriptBlock } else { { 'PS> ' } }
    $update = Get-Command Update-DirEnv -CommandType Function
    $log = Get-Command Write-PdeLog -CommandType Function
    $script:PdePrompt = {
        $previousSuccess = $?
        $hadExitCode = Test-Path Variable:\global:LASTEXITCODE
        $previousExitCode = if ($hadExitCode) { $global:LASTEXITCODE } else { $null }
        try { & $update }
        catch { & $log $_.Exception.Message -Error }
        finally {
            if ($hadExitCode) { $global:LASTEXITCODE = $previousExitCode }
            else { Remove-Variable -Scope Global -Name LASTEXITCODE -ErrorAction SilentlyContinue }
        }
        # Ignore avoids adding a synthetic error to $Error while restoring the
        # status the original prompt expects to see on entry.
        if ($previousSuccess) { $null = 0 } else { Write-Error 'Napreth.PSDirEnv prompt status' -ErrorAction Ignore }
        & $original
    }.GetNewClosure()
    Set-Item Function:\global:prompt -Value $script:PdePrompt -Force
    $script:PdeEnabled = $true
    try { Update-DirEnv }
    catch { Disable-DirEnv; throw }
}

function Disable-DirEnv {
    <# .SYNOPSIS
    Unload all managed overlays and restore the original prompt.
    #>

    [CmdletBinding()]
    param([switch]$Quiet)
    if ($script:PdeBusy) { throw (Format-PdeMessage 'cannot disable while a .ps.envrc is being evaluated' -Error) }
    $before = if ($script:PdeLayers.Count -and !$Quiet) { Get-PdeSnapshot } else { $null }
    if ($script:PdeLayers.Count) { Write-PdeLog 'unloading' -Quiet:$Quiet }
    while ($script:PdeLayers.Count) { Remove-PdeLastLayer }
    if ($null -ne $before) { Write-PdeDiff $before (Get-PdeSnapshot) }
    $current = Get-Item Function:\prompt -ErrorAction SilentlyContinue
    if ($null -ne $current -and [object]::ReferenceEquals($current.ScriptBlock, $script:PdePrompt)) {
        Set-PdeState Function prompt $script:PdeOriginalPrompt
    }
    $script:PdeEnabled = $false
    $script:PdeProfiles = @()
    $script:PdeFailures.Clear()
    $script:PdeNotices.Clear()
    $script:PdePrompt = $null
    $script:PdeOriginalPrompt = $null
}

function Approve-DirEnv {
    <# .SYNOPSIS
    Grants Napreth.PSDirEnv permission to load the given .ps.envrc file.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([Parameter(Position = 0)][string]$Path = '.')
    if ($script:PdeBusy) { throw (Format-PdeMessage 'cannot change approvals while a .ps.envrc is being evaluated' -Error) }
    $file = Resolve-PdeProfilePath $Path
    $profile = Read-PdeProfile $file
    if ($PSCmdlet.ShouldProcess($file, 'Allow loading .ps.envrc')) {
        Write-PdeApproval $file $profile.Hash
        $script:PdeFailures.Remove($file)
        Update-DirEnv
    }
}

function Revoke-DirEnv {
    <# .SYNOPSIS
    Revokes the authorization of a given .ps.envrc file.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param([Parameter(Position = 0)][string]$Path = '.')
    if ($script:PdeBusy) { throw (Format-PdeMessage 'cannot change approvals while a .ps.envrc is being evaluated' -Error) }
    $file = Resolve-PdeProfilePath $Path
    if ($PSCmdlet.ShouldProcess($file, 'Revoke authorization of .ps.envrc')) {
        Write-PdeApproval $file $null
        $script:PdeFailures.Remove($file)
        Update-DirEnv
    }
}

function Get-DirEnv {
    <# .SYNOPSIS
    Show enabled state, active overlays and the last refresh results.
    #>

    [CmdletBinding()]
    param()
    [pscustomobject]@{
        Enabled = $script:PdeEnabled
        Path = (Get-Location).Path
        StatePath = $script:PdeStorePath
        LoadedProfiles = @($script:PdeLayers | ForEach-Object Path)
        Profiles = @($script:PdeProfiles | Select-Object Path, Hash, Approved, Status, Error)
    }
}

$ExecutionContext.SessionState.Module.OnRemove = { Disable-DirEnv }
Export-ModuleMember -Function Enable-DirEnv, Disable-DirEnv, Approve-DirEnv, Revoke-DirEnv, Update-DirEnv, Get-DirEnv