Private/Config.ps1

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Configuration, mirroring zoxide 0.10.0 src/config.rs. Every getter re-reads the environment on
# each call, like the binary, which reads it once per process (and each zoxide call is a process).
#
# Error messages follow anyhow's Debug rendering, which is what the binary prints after "zoxide: ":
# the context line, then a blank line, "Caused by:" and the indented cause(s). Format-ZoErrorChain
# builds that text; private functions throw it and the CLI (Phase 2) prints it verbatim.

function Format-ZoErrorChain {
    <#
    .SYNOPSIS
        Renders an error and its causes the way anyhow's Debug format does.
    .DESCRIPTION
        Mirrors `writeln!(io::stderr(), "zoxide: {e:?}")` in main.rs for an anyhow::Error: one
        cause is indented four spaces; several causes are numbered from 0.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Message,

        [string[]]$Cause
    )
    if (-not $Cause) { return $Message }
    $lines = if ($Cause.Count -eq 1) { " $($Cause[0])" }
    else { for ($k = 0; $k -lt $Cause.Count; $k++) { " ${k}: $($Cause[$k])" } }
    "$Message`n`nCaused by:`n" + ($lines -join "`n")
}

function New-ZoError {
    <#
    .SYNOPSIS
        Builds an exception for an anyhow-style chain of messages (outermost context first).
    .DESCRIPTION
        The message is Format-ZoErrorChain of the chain; the chain itself is kept in
        Data['ZoChain'] so an outer caller can add its own context, the way anyhow's .context()
        nests (util::write inside Database::save, for example). With -ErrorRecord, the chain of
        that caught error (Get-ZoErrorChain) is appended as the causes.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')]
    [CmdletBinding()]
    [OutputType([Exception])]
    param(
        [Parameter(Mandatory)]
        [string[]]$Chain,

        [Management.Automation.ErrorRecord]$ErrorRecord
    )
    if ($ErrorRecord) { $Chain = @($Chain) + @(Get-ZoErrorChain -ErrorRecord $ErrorRecord) }
    $cause = if ($Chain.Count -gt 1) { $Chain[1..($Chain.Count - 1)] } else { $null }
    $exception = [Exception]::new((Format-ZoErrorChain -Message $Chain[0] -Cause $cause))
    $exception.Data['ZoChain'] = $Chain
    $exception
}

function Get-ZoErrorChain {
    <#
    .SYNOPSIS
        The message chain of a caught error: Data['ZoChain'] when New-ZoError built it, else the
        message of the innermost exception PowerShell wrapped around a .NET call.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)]
        [Management.Automation.ErrorRecord]$ErrorRecord
    )
    $exception = $ErrorRecord.Exception
    while ($exception -is [Management.Automation.MethodInvocationException] -and $exception.InnerException) {
        $exception = $exception.InnerException
    }
    if ($exception.Data.Contains('ZoChain')) { return $exception.Data['ZoChain'] }
    $exception.Message
}

function Get-ZoLocalDataDir {
    <#
    .SYNOPSIS
        The platform's local data directory, or $null when it cannot be found.
    .DESCRIPTION
        Mirrors dirs::data_local_dir (dirs 6.0): %LOCALAPPDATA% (FOLDERID_LocalAppData) on Windows,
        ~/Library/Application Support on macOS, and $XDG_DATA_HOME when absolute else
        ~/.local/share elsewhere.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    if ($IsWindows) {
        $dir = [Environment]::GetFolderPath('LocalApplicationData')
    }
    else {
        $userHome = [Environment]::GetFolderPath('UserProfile')
        $xdg = [Environment]::GetEnvironmentVariable('XDG_DATA_HOME')
        $dir = if ($IsMacOS) { if ($userHome) { [IO.Path]::Join($userHome, 'Library/Application Support') } }
        elseif ($xdg -and $xdg.StartsWith('/')) { $xdg }
        elseif ($userHome) { [IO.Path]::Join($userHome, '.local/share') }
    }
    if ($dir) { $dir } else { $null }
}

function Get-ZoDataDir {
    <#
    .SYNOPSIS
        The directory that holds db.zo.
    .DESCRIPTION
        Mirrors config::data_dir: $env:_ZO_DATA_DIR, else the local data directory plus 'zoxide'.
        Throws '_ZO_DATA_DIR must be an absolute path' when the result is not fully qualified
        (on Windows, a drive or UNC root is required; '\x' is not absolute).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    $dir = [Environment]::GetEnvironmentVariable('_ZO_DATA_DIR')
    if ($null -eq $dir) {
        $local = Get-ZoLocalDataDir
        if (-not $local) { throw 'could not find data directory, please set _ZO_DATA_DIR manually' }
        $dir = [IO.Path]::Join($local, 'zoxide')
    }
    if (-not [IO.Path]::IsPathFullyQualified($dir)) {
        throw '_ZO_DATA_DIR must be an absolute path'
    }
    $dir
}

function Split-ZoPathList {
    <#
    .SYNOPSIS
        Splits a PATH-style list the way std::env::split_paths does.
    .DESCRIPTION
        On Unix the list splits on ':' and keeps empty entries. On Windows it splits on ';' outside
        double quotes, and the quotes are removed. One (possibly empty) entry is always returned.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value,

        [bool]$Windows = $IsWindows
    )
    if (-not $Windows) {
        return $Value.Split(':')
    }
    $current = [Text.StringBuilder]::new()
    $inQuote = $false
    foreach ($ch in $Value.ToCharArray()) {
        if ($ch -eq [char]'"') { $inQuote = -not $inQuote }
        elseif ($ch -eq [char]';' -and -not $inQuote) {
            $current.ToString()
            [void]$current.Clear()
        }
        else { [void]$current.Append($ch) }
    }
    $current.ToString()
}

function Get-ZoExcludeDirs {
    <#
    .SYNOPSIS
        The exclude globs, each with its text and its compiled regex.
    .DESCRIPTION
        Mirrors config::exclude_dirs. With _ZO_EXCLUDE_DIRS set, each entry of the list (split like
        std::env::split_paths: ';' on Windows, ':' elsewhere) is compiled with ConvertTo-ZoGlobRegex;
        an invalid glob throws 'invalid glob in _ZO_EXCLUDE_DIRS: <glob>' with the glob error as the
        cause. Unset, the default is the home directory as one escaped glob, so only the home
        directory itself is excluded. Outputs one PSZoxide.Glob object per glob: Glob (the text as
        the user wrote it) and Regex (the compiled [regex]). Test-ZoGlobMatch and New-ZoStream take
        these objects. Each regex has a 2-second match timeout, so a caller that runs
        .Regex.IsMatch() directly must catch RegexMatchTimeoutException and throw
        'glob match timed out: <Glob>' through New-ZoError, as Test-ZoGlobMatch does.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::exclude_dirs.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()
    $raw = [Environment]::GetEnvironmentVariable('_ZO_EXCLUDE_DIRS')
    if ($null -eq $raw) {
        $userHome = [Environment]::GetFolderPath('UserProfile')
        if ($userHome) {
            $glob = ConvertTo-ZoEscapedGlob -Value $userHome
            [pscustomobject]@{ PSTypeName = 'PSZoxide.Glob'; Glob = $glob; Regex = ConvertTo-ZoGlobRegex -Pattern $glob }
        }
        return
    }
    foreach ($glob in Split-ZoPathList -Value $raw) {
        try {
            [pscustomobject]@{ PSTypeName = 'PSZoxide.Glob'; Glob = $glob; Regex = ConvertTo-ZoGlobRegex -Pattern $glob }
        }
        catch {
            throw (New-ZoError -Chain "invalid glob in _ZO_EXCLUDE_DIRS: $glob", $_.Exception.Message)
        }
    }
}

function ConvertFrom-ZoMaxAge {
    <#
    .SYNOPSIS
        Parses a _ZO_MAXAGE value like Rust's u32::from_str and returns it as a double.
    .DESCRIPTION
        Mirrors the parse inside config::maxage: ASCII digits with an optional leading '+', no
        whitespace, at most 4294967295. Errors carry Rust's ParseIntError text as the cause.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value
    )
    $cause = $null
    $digits = if ($Value.StartsWith('+')) { $Value.Substring(1) } else { $Value }
    if ($Value.Length -eq 0) {
        $cause = 'cannot parse integer from empty string'
    }
    elseif ($digits.Length -eq 0) {
        $cause = 'invalid digit found in string'
    }
    else {
        [uint64]$number = 0
        foreach ($ch in $digits.ToCharArray()) {
            $digit = [int]$ch - [int][char]'0'
            if ($digit -lt 0 -or $digit -gt 9) { $cause = 'invalid digit found in string'; break }
            $number = $number * 10 + $digit
            if ($number -gt [uint32]::MaxValue) { $cause = 'number too large to fit in target type'; break }
        }
    }
    if ($cause) {
        throw (New-ZoError -Chain "unable to parse _ZO_MAXAGE as integer: $Value", $cause)
    }
    [double]$number
}

function Get-ZoMaxAge {
    <#
    .SYNOPSIS
        The aging threshold: $env:_ZO_MAXAGE as a double, default 10000.
    .DESCRIPTION
        Mirrors config::maxage.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param()
    $raw = [Environment]::GetEnvironmentVariable('_ZO_MAXAGE')
    if ($null -eq $raw) { return [double]10000 }
    ConvertFrom-ZoMaxAge -Value $raw
}

function Test-ZoEcho {
    <#
    .SYNOPSIS
        True when $env:_ZO_ECHO is exactly '1'.
    .DESCRIPTION
        Mirrors config::echo.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()
    [Environment]::GetEnvironmentVariable('_ZO_ECHO') -ceq '1'
}

function Test-ZoResolveSymlinks {
    <#
    .SYNOPSIS
        True when $env:_ZO_RESOLVE_SYMLINKS is exactly '1'.
    .DESCRIPTION
        Mirrors config::resolve_symlinks.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::resolve_symlinks.')]
    [CmdletBinding()]
    [OutputType([bool])]
    param()
    [Environment]::GetEnvironmentVariable('_ZO_RESOLVE_SYMLINKS') -ceq '1'
}

function Get-ZoFzfOpts {
    <#
    .SYNOPSIS
        The raw value of $env:_ZO_FZF_OPTS, or $null when unset.
    .DESCRIPTION
        Mirrors config::fzf_opts. When set, the fzf runner passes it to fzf as FZF_DEFAULT_OPTS
        instead of zoxide's default options.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors config::fzf_opts.')]
    [CmdletBinding()]
    [OutputType([string])]
    param()
    [Environment]::GetEnvironmentVariable('_ZO_FZF_OPTS')
}

function Get-ZoShellOption {
    <#
    .SYNOPSIS
        The shell options Import-Module applies, from _ZO_HOOK, _ZO_CMD and _ZO_NO_CMD.
    .DESCRIPTION
        The module's counterparts of the `init` flags (spec section 2), which the binary has no
        variables for: Hook is _ZO_HOOK when it is exactly none, prompt or pwd (clap's values),
        else pwd (--hook's default); Cmd is _ZO_CMD when set and not empty, else z (--cmd's
        default); NoCmd is true when _ZO_NO_CMD is exactly '1', like the other flag variables.
        Unlike --cmd, which the user types, an environment variable can be set by any parent
        process, so _ZO_CMD is accepted only as a plain command name (a letter or underscore, then
        letters, digits, underscores or hyphens). Anything else falls back to z with one
        `zoxide: ignoring _ZO_CMD ...` line on stderr, unless _ZO_NO_CMD makes the name unused.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()
    $hook = [Environment]::GetEnvironmentVariable('_ZO_HOOK')
    if (@('none', 'prompt', 'pwd') -cnotcontains $hook) { $hook = 'pwd' }
    $noCmd = [Environment]::GetEnvironmentVariable('_ZO_NO_CMD') -ceq '1'
    $cmd = [Environment]::GetEnvironmentVariable('_ZO_CMD')
    if (-not $cmd) { $cmd = 'z' }
    elseif ($cmd -cnotmatch '\A[A-Za-z_][A-Za-z0-9_-]*\z') {
        if (-not $noCmd) { Write-ZoError -Message 'zoxide: ignoring _ZO_CMD: not a valid command name, using z' }
        $cmd = 'z'
    }
    [pscustomobject]@{
        PSTypeName = 'PSZoxide.ShellOption'
        Hook       = $hook
        Cmd        = $cmd
        NoCmd      = $noCmd
    }
}