Private/Cli.ps1

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

# The command line, mirroring zoxide 0.10.0 src/main.rs, src/cmd/cmd.rs (the clap 4 derive) and
# src/cmd/{add,query,remove}.rs. ConvertFrom-ZoArgs is a hand-written clap parser over the raw
# tokens of Invoke-Zoxide's $args; its errors reproduce clap's text, tips, usage lines and exit
# code 2, checked against the binary. Invoke-ZoMain is main(): it dispatches, writes results to
# the pipeline, writes errors through Write-ZoError and sets $global:LASTEXITCODE.
#
# Command model (built once by Get-ZoCliRoot):
# Command Name, Args (declaration order, then help and version), Subcommands,
# SubcommandRequired, HelpWhenEmpty (clap's arg_required_else_help), Hidden
# Arg Id (the Rust field name), Long, Short, Aliases, Kind (Flag, Value, Positional, Help,
# Version), ValueName, Multiple, Required, PossibleValues, ValueAliases, Default,
# Float, PathBuf (clap rejects an empty value), Conflicts
#
# Parse result (ConvertFrom-ZoArgs): Command and Subcommand names, Values keyed by Rust field name
# (flags [bool], options [string] or [double] or $null, multi-value positionals [string[]]), and
# Exit: $null, or Text/Stderr/Code when clap would print help, a version or an error and stop.

# The single stderr seam. Tests swap this scriptblock to capture what would reach the host.
$script:ZoErrorWriter = { param([string]$Text) $host.UI.WriteErrorLine($Text) }
$script:ZoCliRoot = $null
# The script fzf's edit bindings run: edit-helper.ps1 at the module root, beside the manifest, so
# every installed copy carries its own. Tests swap it.
$script:ZoEditHelper = [IO.Path]::GetFullPath([IO.Path]::Join($PSScriptRoot, '..', 'edit-helper.ps1'))

function Write-ZoError {
    <#
    .SYNOPSIS
        Writes one message to stderr through the host, exactly as given.
    .DESCRIPTION
        Mirrors the `writeln!(io::stderr(), ...)` in main.rs and clap's error printing. The text is
        passed to $script:ZoErrorWriter, which calls $host.UI.WriteErrorLine (the host adds the
        newline). Callers include the 'zoxide: ' prefix themselves; clap errors and help have none.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Message
    )
    & $script:ZoErrorWriter $Message
}

function New-ZoSilentExit {
    <#
    .SYNOPSIS
        An exception that makes Invoke-ZoMain exit with a code and print nothing.
    .DESCRIPTION
        Mirrors error::SilentExit (fzf's interrupt exits 130 this way). Reserved for Phase 3.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory exception only.')]
    [CmdletBinding()]
    [OutputType([Exception])]
    param(
        [Parameter(Mandatory)]
        [int]$Code
    )
    $exception = [Exception]::new("silent exit $Code")
    $exception.Data['ZoSilentExit'] = $Code
    $exception
}

function New-ZoCliArg {
    <#
    .SYNOPSIS
        One argument of the command model: a #[clap(...)] field of cmd/cmd.rs.
    #>

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

        [string]$Long,

        [string]$Short,

        [string[]]$Alias = @(),

        [ValidateSet('Flag', 'Value', 'Positional', 'Help', 'Version')]
        [string]$Kind = 'Flag',

        [string]$ValueName,

        [switch]$Multiple,

        [switch]$Required,

        [string[]]$PossibleValue = @(),

        [hashtable]$ValueAlias = @{},

        [string]$Default,

        [switch]$Float,

        [switch]$PathBuf,

        [string[]]$ConflictsWith = @()
    )
    [pscustomobject]@{
        PSTypeName     = 'PSZoxide.CliArg'
        Id             = $Id
        Long           = $Long
        Short          = $Short
        Aliases        = [string[]]$Alias
        Kind           = $Kind
        ValueName      = $ValueName
        Multiple       = [bool]$Multiple
        Required       = [bool]$Required
        PossibleValues = [string[]]$PossibleValue
        ValueAliases   = $ValueAlias
        Default        = if ($PSBoundParameters.ContainsKey('Default')) { $Default } else { $null }
        Float          = [bool]$Float
        PathBuf        = [bool]$PathBuf
        Conflicts      = [string[]]$ConflictsWith
    }
}

function New-ZoCliCommand {
    <#
    .SYNOPSIS
        One command of the command model; clap's auto-generated help and version args are appended.
    #>

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

        [object[]]$Arguments = @(),

        [object[]]$Subcommands = @(),

        [switch]$SubcommandRequired,

        [switch]$HelpWhenEmpty,

        [switch]$Hidden
    )
    $builtIn = @(
        (New-ZoCliArg -Id 'help' -Long 'help' -Short 'h' -Kind Help)
        (New-ZoCliArg -Id 'version' -Long 'version' -Short 'V' -Kind Version)
    )
    [pscustomobject]@{
        PSTypeName         = 'PSZoxide.CliCommand'
        Name               = $Name
        Args               = [object[]](@($Arguments) + $builtIn)
        Subcommands        = [object[]]@($Subcommands)
        SubcommandRequired = [bool]$SubcommandRequired
        HelpWhenEmpty      = [bool]$HelpWhenEmpty
        Hidden             = [bool]$Hidden
    }
}

function Get-ZoCliRoot {
    <#
    .SYNOPSIS
        The command model of zoxide 0.10.0, built once.
    .DESCRIPTION
        Mirrors the clap derive in cmd/cmd.rs: Cmd (add, edit, import, init, query, remove, with
        subcommand_required and arg_required_else_help from the derive), the hidden EditCommand
        subcommands, the ImportFrom sources with the global --merge flag, Init's value enums and
        aliases (--no-aliases, ksh), and Query's -i/-l conflict.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()
    if ($null -ne $script:ZoCliRoot) { return $script:ZoCliRoot }

    $merge = New-ZoCliArg -Id 'merge' -Long 'merge'
    $sources = foreach ($name in 'atuin', 'autojump', 'fasd', 'z', 'z.lua', 'zsh-z') {
        New-ZoCliCommand -Name $name -Arguments @($merge)
    }
    $editCommands = @(
        foreach ($name in 'decrement', 'delete', 'increment') {
            New-ZoCliCommand -Name $name -Hidden -Arguments @(New-ZoCliArg -Id 'path' -Kind Positional -ValueName 'PATH' -Required)
        }
        New-ZoCliCommand -Name 'reload' -Hidden
    )

    $add = New-ZoCliCommand -Name 'add' -Arguments @(
        New-ZoCliArg -Id 'paths' -Kind Positional -ValueName 'PATHS' -Multiple -Required -PathBuf
        New-ZoCliArg -Id 'score' -Long 'score' -Short 's' -Kind Value -ValueName 'SCORE' -Float
    )
    $edit = New-ZoCliCommand -Name 'edit' -Subcommands $editCommands
    $import = New-ZoCliCommand -Name 'import' -Arguments @($merge) -Subcommands $sources -SubcommandRequired -HelpWhenEmpty
    $init = New-ZoCliCommand -Name 'init' -Arguments @(
        New-ZoCliArg -Id 'shell' -Kind Positional -ValueName 'SHELL' -Required -ValueAlias @{ ksh = 'posix' } -PossibleValue @(
            'bash', 'elvish', 'fish', 'nushell', 'posix', 'powershell', 'tcsh', 'xonsh', 'zsh')
        New-ZoCliArg -Id 'no_cmd' -Long 'no-cmd' -Alias 'no-aliases'
        New-ZoCliArg -Id 'cmd' -Long 'cmd' -Kind Value -ValueName 'CMD' -Default 'z'
        New-ZoCliArg -Id 'hook' -Long 'hook' -Kind Value -ValueName 'HOOK' -Default 'pwd' -PossibleValue @('none', 'prompt', 'pwd')
    )
    $query = New-ZoCliCommand -Name 'query' -Arguments @(
        New-ZoCliArg -Id 'keywords' -Kind Positional -ValueName 'KEYWORDS' -Multiple
        New-ZoCliArg -Id 'all' -Long 'all' -Short 'a'
        New-ZoCliArg -Id 'interactive' -Long 'interactive' -Short 'i' -ConflictsWith 'list'
        New-ZoCliArg -Id 'list' -Long 'list' -Short 'l' -ConflictsWith 'interactive'
        New-ZoCliArg -Id 'score' -Long 'score' -Short 's'
        New-ZoCliArg -Id 'exclude' -Long 'exclude' -Kind Value -ValueName 'path'
        New-ZoCliArg -Id 'base_dir' -Long 'base-dir' -Kind Value -ValueName 'path'
    )
    $remove = New-ZoCliCommand -Name 'remove' -Arguments @(
        New-ZoCliArg -Id 'paths' -Kind Positional -ValueName 'PATHS' -Multiple
    )

    $script:ZoCliRoot = New-ZoCliCommand -Name 'zoxide' -SubcommandRequired -HelpWhenEmpty -Subcommands @(
        $add, $edit, $import, $init, $query, $remove)
    $script:ZoCliRoot
}

function Get-ZoJaro {
    <#
    .SYNOPSIS
        The Jaro similarity of two strings, 0.0 to 1.0.
    .DESCRIPTION
        Mirrors strsim::jaro (generic_jaro), which clap uses for its "similar ... exists" tips. Two
        empty strings score 1.0; one empty string scores 0.0. Characters are compared exactly.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$B
    )
    if ($A.Length -eq 0 -and $B.Length -eq 0) { return 1.0 }
    if ($A.Length -eq 0 -or $B.Length -eq 0) { return 0.0 }
    $range = [Math]::Max([int][Math]::Floor([Math]::Max($A.Length, $B.Length) / 2) - 1, 0)
    $aFlags = [bool[]]::new($A.Length)
    $bFlags = [bool[]]::new($B.Length)
    $common = 0
    for ($i = 0; $i -lt $A.Length; $i++) {
        $low = [Math]::Max($i - $range, 0)
        $high = [Math]::Min($B.Length, $i + $range + 1)
        for ($j = $low; $j -lt $high; $j++) {
            if (-not $bFlags[$j] -and $A[$i] -ceq $B[$j]) {
                $aFlags[$i] = $true
                $bFlags[$j] = $true
                $common++
                break
            }
        }
    }
    if ($common -eq 0) { return 0.0 }
    $transpositions = 0
    $k = 0
    for ($i = 0; $i -lt $A.Length; $i++) {
        if (-not $aFlags[$i]) { continue }
        while (-not $bFlags[$k]) { $k++ }
        if ($A[$i] -cne $B[$k]) { $transpositions++ }
        $k++
    }
    $transpositions = [Math]::Floor($transpositions / 2)
    ($common / $A.Length + $common / $B.Length + ($common - $transpositions) / $common) / 3.0
}

function Get-ZoSuggestion {
    <#
    .SYNOPSIS
        The candidates similar to a value, least similar first.
    .DESCRIPTION
        Mirrors clap's suggestions::did_you_mean: candidates whose Jaro similarity is above 0.7,
        sorted by ascending similarity, ties in candidate order. clap shows all of them for
        subcommands and only the last (the best) for arguments and values.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]]$Candidates
    )
    $scored = for ($i = 0; $i -lt $Candidates.Count; $i++) {
        $score = Get-ZoJaro -A $Value -B $Candidates[$i]
        if ($score -gt 0.7) { [pscustomobject]@{ Score = $score; Index = $i; Name = $Candidates[$i] } }
    }
    @($scored) | Sort-Object -Property Score, Index | ForEach-Object Name
}

function ConvertFrom-ZoFloat {
    <#
    .SYNOPSIS
        Parses a number the way Rust's f64::from_str does, or throws Rust's error text.
    .DESCRIPTION
        Mirrors clap's f64 value parser (str::parse::<f64>): optional sign, digits with an optional
        fraction ('5', '5.', '.5'), optional exponent, or inf/infinity/nan in any case. No spaces,
        hex, or underscores. An empty string throws 'cannot parse float from empty string'; anything
        else invalid throws 'invalid float literal'.
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Text
    )
    if ($Text -eq '') { throw 'cannot parse float from empty string' }
    # Rust's NaN is the positive quiet NaN (0x7FF8...), '-nan' sets the sign bit; .NET's
    # [double]::NaN has the sign bit set, which would change db.zo bytes after an import.
    if ($Text -match '^([+-]?)nan$') {
        if ($Matches[1] -eq '-') { return [BitConverter]::Int64BitsToDouble([long]-2251799813685248) }
        return [BitConverter]::Int64BitsToDouble([long]0x7FF8000000000000)
    }
    if ($Text -match '^([+-]?)(inf|infinity)$') {
        if ($Matches[1] -eq '-') { return [double]::NegativeInfinity }
        return [double]::PositiveInfinity
    }
    if ($Text -cnotmatch '^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$') { throw 'invalid float literal' }
    [double]::Parse($Text, [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture)
}

function Format-ZoCliArg {
    <#
    .SYNOPSIS
        An argument as clap names it in messages: '--all', '--exclude <path>', '<PATHS>...'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Arg
    )
    switch ($Arg.Kind) {
        'Positional' { "<$($Arg.ValueName)>" + $(if ($Arg.Multiple) { '...' } else { '' }) }
        'Value' { "--$($Arg.Long) <$($Arg.ValueName)>" }
        default { "--$($Arg.Long)" }
    }
}

function Get-ZoCliCommandName {
    <#
    .SYNOPSIS
        The command's bin name as clap prints it: 'zoxide.exe import z'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$State
    )
    (@(Get-ZoBinName) + @($State.Names)) -join ' '
}

function Get-ZoCliUsage {
    <#
    .SYNOPSIS
        The usage line clap prints after 'Usage: ' in an error.
    .DESCRIPTION
        Mirrors clap's Usage::create_usage_with_title. With no used arguments (and without
        -Smart) it is the full usage: name, '[OPTIONS]' when the command has options, each
        positional ('<X>' required, '[X]' optional, '...' when repeated), then '<COMMAND>' or
        '[COMMAND]' for visible subcommands. Otherwise it is the smart usage: name, each used
        option in the order given, every positional ('<X>' when required or used), then
        '<COMMAND>' when a subcommand is required.
    .PARAMETER Used
        Argument ids, in the order the parser met them.
    .PARAMETER Smart
        Force the smart form even with no used arguments (clap does this for missing arguments).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$State,

        [AllowEmptyCollection()]
        [string[]]$Used = @(),

        [switch]$Smart
    )
    $spec = $State.Spec
    $parts = [Collections.Generic.List[string]]::new()
    $parts.Add((Get-ZoCliCommandName -State $State))
    $positionals = @($spec.Args | Where-Object Kind -EQ 'Positional')
    if (-not $Smart -and $Used.Count -eq 0) {
        if (@($spec.Args | Where-Object { $_.Kind -in 'Flag', 'Value' }).Count) { $parts.Add('[OPTIONS]') }
        foreach ($arg in $positionals) {
            $name = if ($arg.Required) { "<$($arg.ValueName)>" } else { "[$($arg.ValueName)]" }
            $parts.Add($name + $(if ($arg.Multiple) { '...' } else { '' }))
        }
        if (@($spec.Subcommands | Where-Object { -not $_.Hidden }).Count) {
            $parts.Add($(if ($spec.SubcommandRequired) { '<COMMAND>' } else { '[COMMAND]' }))
        }
        return $parts -join ' '
    }
    foreach ($id in ($Used | Select-Object -Unique)) {
        $arg = $spec.Args | Where-Object Id -EQ $id | Select-Object -First 1
        if ($null -ne $arg -and $arg.Kind -ne 'Positional') { $parts.Add((Format-ZoCliArg -Arg $arg)) }
    }
    foreach ($arg in $positionals) {
        $name = if ($arg.Required -or $Used -contains $arg.Id) { "<$($arg.ValueName)>" } else { "[$($arg.ValueName)]" }
        $parts.Add($name + $(if ($arg.Multiple) { '...' } else { '' }))
    }
    if ($spec.SubcommandRequired) { $parts.Add('<COMMAND>') }
    $parts -join ' '
}

function New-ZoCliExit {
    <#
    .SYNOPSIS
        The exception the parser throws to stop with output and an exit code (help, version, error).
    #>

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

        [Parameter(Mandatory)]
        [int]$Code,

        [switch]$Stderr
    )
    $exception = [Exception]::new($Text)
    $exception.Data['ZoClap'] = [pscustomobject]@{ Text = $Text; Stderr = [bool]$Stderr; Code = $Code }
    $exception
}

function New-ZoCliError {
    <#
    .SYNOPSIS
        A clap usage error (exit 2, stderr) in clap's rich format.
    .DESCRIPTION
        Mirrors clap's RichFormatter: 'error: <message>', context lines, a blank line and one
        ' tip: ...' line per tip, then a blank line and 'Usage: <usage>' when there is a usage,
        then a blank line and "For more information, try '--help'.".
    #>

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

        [AllowEmptyCollection()]
        [string[]]$Context = @(),

        [AllowEmptyCollection()]
        [string[]]$Tip = @(),

        [string]$Usage
    )
    # Callers may pass $null for "none" (an if-expression that yields @() collapses to $null).
    $Context = @($Context | Where-Object { $null -ne $_ })
    $Tip = @($Tip | Where-Object { $null -ne $_ })
    $lines = [Collections.Generic.List[string]]::new()
    $lines.Add("error: $Message")
    foreach ($line in $Context) { $lines.Add($line) }
    if ($Tip.Count) {
        $lines.Add('')
        foreach ($line in $Tip) { $lines.Add(" tip: $line") }
    }
    if ($Usage) {
        $lines.Add('')
        $lines.Add("Usage: $Usage")
    }
    $lines.Add('')
    $lines.Add("For more information, try '--help'.")
    New-ZoCliExit -Text ($lines -join "`n") -Code 2 -Stderr
}

function Find-ZoCliArg {
    <#
    .SYNOPSIS
        The argument of a command with a given long name or alias, short name, or id; $null if none.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Spec,

        [string]$Long,

        [string]$Short,

        [string]$Id
    )
    foreach ($arg in $Spec.Args) {
        if ($PSBoundParameters.ContainsKey('Long') -and $arg.Long -and ($arg.Long -ceq $Long -or $arg.Aliases -ccontains $Long)) { return $arg }
        if ($PSBoundParameters.ContainsKey('Short') -and $arg.Short -and $arg.Short -ceq $Short) { return $arg }
        if ($PSBoundParameters.ContainsKey('Id') -and $arg.Id -eq $Id) { return $arg }
    }
    $null
}

function Get-ZoCliLongName {
    <#
    .SYNOPSIS
        Every long name and alias of a command, in declaration order (clap's keymap order).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Spec
    )
    foreach ($arg in $Spec.Args) {
        if ($arg.Long) { $arg.Long }
        foreach ($alias in $arg.Aliases) { $alias }
    }
}

function Resolve-ZoCliPending {
    <#
    .SYNOPSIS
        Fails when an option is still waiting for its value.
    .DESCRIPTION
        Mirrors clap's Parser::resolve_pending: an option given without a value at the end of the
        line, before '--', or before another known argument is an error, with the possible values
        listed when there are any.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State
    )
    $arg = $State.Pending
    if ($null -eq $arg) { return }
    $State.Pending = $null
    $context = if ($arg.PossibleValues.Count) { " [possible values: $($arg.PossibleValues -join ', ')]" } else { @() }
    throw (New-ZoCliError -Message "a value is required for '$(Format-ZoCliArg -Arg $arg)' but none was supplied" -Context $context)
}

function ConvertTo-ZoCliValue {
    <#
    .SYNOPSIS
        Validates and converts one value for an argument, or throws clap's invalid-value error.
    .DESCRIPTION
        Mirrors the value parsers in cmd/cmd.rs: f64 for add --score, the ValueEnum lists for the
        init shell (with the hidden alias ksh for posix) and --hook, and plain strings elsewhere.
        clap's PathBufValueParser (add's paths) and EnumValueParser reject an empty value with
        Error::empty_value, the same text as a missing value; the String and f64 parsers do not.
    #>

    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory)]
        [object]$Arg,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value
    )
    $display = Format-ZoCliArg -Arg $Arg
    if ($Arg.Float) {
        try {
            return ConvertFrom-ZoFloat -Text $Value
        }
        catch {
            throw (New-ZoCliError -Message "invalid value '$Value' for '$display': $($_.Exception.Message)")
        }
    }
    if ($Value -eq '' -and ($Arg.PathBuf -or $Arg.PossibleValues.Count)) {
        $context = if ($Arg.PossibleValues.Count) { " [possible values: $($Arg.PossibleValues -join ', ')]" } else { @() }
        throw (New-ZoCliError -Message "a value is required for '$display' but none was supplied" -Context $context)
    }
    if ($Arg.PossibleValues.Count -eq 0) { return $Value }
    if ($Arg.PossibleValues -ccontains $Value) { return $Value }
    foreach ($alias in $Arg.ValueAliases.Keys) {
        if ($alias -ceq $Value) { return $Arg.ValueAliases[$alias] }
    }
    $best = @(Get-ZoSuggestion -Value $Value -Candidates $Arg.PossibleValues) | Select-Object -Last 1
    $tip = if ($null -ne $best) { "a similar value exists: '$best'" } else { @() }
    throw (New-ZoCliError -Message "invalid value '$Value' for '$display'" -Context " [possible values: $($Arg.PossibleValues -join ', ')]" -Tip $tip)
}

function Add-ZoCliValue {
    <#
    .SYNOPSIS
        Records a value for an option or positional in the parse state.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State,

        [Parameter(Mandatory)]
        [object]$Arg,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Value
    )
    $converted = ConvertTo-ZoCliValue -Arg $Arg -Value $Value
    if (-not $Arg.Multiple) {
        $State.Values[$Arg.Id] = $converted
        return
    }
    if (-not $State.Values.Contains($Arg.Id)) { $State.Values[$Arg.Id] = [Collections.Generic.List[string]]::new() }
    $State.Values[$Arg.Id].Add($converted)
}

function Invoke-ZoCliReact {
    <#
    .SYNOPSIS
        Applies one flag or option occurrence to the parse state.
    .DESCRIPTION
        Mirrors clap's Parser::react: a pending option is resolved first (so '--exclude -l' is a
        missing value), help and version stop parsing at once, a second occurrence of the same
        argument is an error, and an option without an attached value waits for the next token.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State,

        [Parameter(Mandatory)]
        [object]$Arg,

        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Value = @()
    )
    Resolve-ZoCliPending -State $State
    if ($Arg.Kind -eq 'Help') {
        $key = if ($State.Names.Count) { $State.Names -join ' ' } else { 'root' }
        throw (New-ZoCliExit -Text (Get-ZoHelp -Command $key) -Code 0)
    }
    if ($Arg.Kind -eq 'Version') {
        throw (New-ZoCliExit -Text (Get-ZoVersion -Command ($State.Names -join ' ')) -Code 0)
    }
    if ($State.Values.Contains($Arg.Id)) {
        throw (New-ZoCliError -Message "the argument '$(Format-ZoCliArg -Arg $Arg)' cannot be used multiple times" -Usage (Get-ZoCliUsage -State $State))
    }
    if ($Arg.Kind -ne 'Value') {
        $State.Values[$Arg.Id] = $true
        return
    }
    if ($Value.Count -eq 0) {
        $State.Pending = $Arg
        return
    }
    Add-ZoCliValue -State $State -Arg $Arg -Value $Value[0]
}

function Read-ZoCliLong {
    <#
    .SYNOPSIS
        Parses one '--name' or '--name=value' token.
    .DESCRIPTION
        Mirrors clap's Parser::parse_long_arg and did_you_mean_error. An unknown name is an error
        whose tip names the most similar long argument of this command, else a similar argument of
        a subcommand named later on the line, else (when the command has positionals) how to pass
        the token as a value. A value attached to a flag is an error.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State,

        [Parameter(Mandatory)]
        [string]$Token,

        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Remaining = @()
    )
    $body = $Token.Substring(2)
    $equals = $body.IndexOf('=')
    $name = if ($equals -ge 0) { $body.Substring(0, $equals) } else { $body }
    $attached = @()
    if ($equals -ge 0) { $attached = @($body.Substring($equals + 1)) }
    $spec = $State.Spec
    $arg = Find-ZoCliArg -Spec $spec -Long $name

    if ($null -eq $arg) {
        $State.Pending = $null
        $used = [Collections.Generic.List[string]]@($State.Values.Keys)
        $candidate = @(Get-ZoSuggestion -Value $name -Candidates @(Get-ZoCliLongName -Spec $spec)) | Select-Object -Last 1
        $tip = @()
        if ($null -ne $candidate) {
            $tip = "a similar argument exists: '--$candidate'"
            $used.Add((Find-ZoCliArg -Spec $spec -Long $candidate).Id)
        }
        else {
            $best = [int]::MaxValue
            foreach ($child in $spec.Subcommands) {
                $childCandidate = @(Get-ZoSuggestion -Value $name -Candidates @(Get-ZoCliLongName -Spec $child)) | Select-Object -Last 1
                $position = [Array]::IndexOf([string[]]$Remaining, $child.Name)
                if ($null -eq $childCandidate -or $position -lt 0 -or $position -ge $best) { continue }
                $best = $position
                $tip = "'$($child.Name) --$childCandidate' exists"
            }
            if (-not $tip -and -not $State.Trailing -and @($spec.Args | Where-Object Kind -EQ 'Positional').Count) {
                $tip = "to pass '--$name' as a value, use '-- --$name'"
            }
        }
        throw (New-ZoCliError -Message "unexpected argument '--$name' found" -Tip $tip -Usage (Get-ZoCliUsage -State $State -Used $used))
    }

    if ($arg.Kind -eq 'Value') {
        Invoke-ZoCliReact -State $State -Arg $arg -Value $attached
        return
    }
    if ($attached.Count) {
        $State.Pending = $null
        $used = @($State.Values.Keys) + $arg.Id
        throw (New-ZoCliError -Message "unexpected value '$($attached[0])' for '$(Format-ZoCliArg -Arg $arg)' found; no more were expected" -Usage (Get-ZoCliUsage -State $State -Used $used))
    }
    Invoke-ZoCliReact -State $State -Arg $arg
}

function Read-ZoCliShort {
    <#
    .SYNOPSIS
        Parses one '-abc' token: a cluster of short flags, possibly ending in an option and its value.
    .DESCRIPTION
        Mirrors clap's Parser::parse_short_arg: flags react one by one; an option takes the rest
        of the token as its value ('-s5', '-s=5'), or the next token when nothing is left. An
        unknown character is an error naming '-<char>', with the full usage.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State,

        [Parameter(Mandatory)]
        [string]$Token
    )
    for ($k = 1; $k -lt $Token.Length; $k++) {
        $char = [string]$Token[$k]
        $arg = Find-ZoCliArg -Spec $State.Spec -Short $char
        if ($null -eq $arg) {
            $State.Pending = $null
            $tip = if (-not $State.Trailing -and @($State.Spec.Args | Where-Object Kind -EQ 'Positional').Count) { "to pass '-$char' as a value, use '-- -$char'" } else { @() }
            throw (New-ZoCliError -Message "unexpected argument '-$char' found" -Tip $tip -Usage (Get-ZoCliUsage -State $State))
        }
        if ($arg.Kind -ne 'Value') {
            Invoke-ZoCliReact -State $State -Arg $arg
            continue
        }
        $rest = $Token.Substring($k + 1)
        $hasEquals = $rest.StartsWith('=')
        if ($hasEquals) { $rest = $rest.Substring(1) }
        $attached = @()
        if ($rest.Length -gt 0 -or $hasEquals) { $attached = @($rest) }
        Invoke-ZoCliReact -State $State -Arg $arg -Value $attached
        return
    }
}

function New-ZoCliMatchError {
    <#
    .SYNOPSIS
        The error for a token that is neither an argument nor a subcommand.
    .DESCRIPTION
        Mirrors clap's Parser::match_arg_error: a subcommand name after '--' gets the "remove the
        '--'" tip; a command with subcommands reports an unrecognized subcommand (with similar
        names as tips) unless it also takes positionals; anything else is an unexpected argument.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Token
    )
    $spec = $State.Spec
    $usage = Get-ZoCliUsage -State $State
    if ($State.Trailing -and $null -ne (Find-ZoCliSubcommand -Spec $spec -Name $Token)) {
        return New-ZoCliError -Message "unexpected argument '$Token' found" -Tip "subcommand '$Token' exists; to use it, remove the '--' before it" -Usage $usage
    }
    $hasPositionals = @($spec.Args | Where-Object Kind -EQ 'Positional').Count -gt 0
    if ($spec.Subcommands.Count) {
        $candidates = @(Get-ZoSuggestion -Value $Token -Candidates @($spec.Subcommands | ForEach-Object Name))
        $tip = switch ($candidates.Count) {
            0 { @() }
            1 { "a similar subcommand exists: '$($candidates[0])'" }
            default { "some similar subcommands exist: $(($candidates | ForEach-Object { "'$_'" }) -join ', ')" }
        }
        if ($candidates.Count -or -not $hasPositionals) {
            return New-ZoCliError -Message "unrecognized subcommand '$Token'" -Tip $tip -Usage $usage
        }
    }
    New-ZoCliError -Message "unexpected argument '$Token' found" -Usage $usage
}

function Find-ZoCliSubcommand {
    <#
    .SYNOPSIS
        The subcommand with exactly this name, hidden ones included; $null if none.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Spec,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Name
    )
    foreach ($sub in $Spec.Subcommands) {
        if ($sub.Name -ceq $Name) { return $sub }
    }
    $null
}

function Test-ZoCliCommand {
    <#
    .SYNOPSIS
        Validates a parsed command once its tokens are consumed.
    .DESCRIPTION
        Mirrors clap's Validator::validate: help on stderr (exit 2) when a command that needs a
        subcommand got no arguments at all, the missing-subcommand error, argument conflicts
        (query -i with -l), then missing required arguments.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$State
    )
    $spec = $State.Spec
    if ($spec.Subcommands.Count -and $null -eq $State.Child) {
        if ($spec.HelpWhenEmpty -and $State.Values.Count -eq 0) {
            $key = if ($State.Names.Count) { $State.Names -join ' ' } else { 'root' }
            throw (New-ZoCliExit -Text (Get-ZoHelp -Command $key) -Code 2 -Stderr)
        }
        if ($spec.SubcommandRequired) {
            $names = @($spec.Subcommands | Where-Object { -not $_.Hidden } | ForEach-Object Name) -join ', '
            throw (New-ZoCliError -Message "'$(Get-ZoCliCommandName -State $State)' requires a subcommand but one was not provided" -Context " [subcommands: $names]" -Usage (Get-ZoCliUsage -State $State))
        }
    }
    foreach ($id in @($State.Values.Keys)) {
        $arg = Find-ZoCliArg -Spec $spec -Id $id
        foreach ($other in $arg.Conflicts) {
            if (-not $State.Values.Contains($other)) { continue }
            $used = @($State.Values.Keys | Where-Object { $_ -ne $other })
            $otherArg = Find-ZoCliArg -Spec $spec -Id $other
            throw (New-ZoCliError -Message "the argument '$(Format-ZoCliArg -Arg $arg)' cannot be used with '$(Format-ZoCliArg -Arg $otherArg)'" -Usage (Get-ZoCliUsage -State $State -Used $used))
        }
    }
    $missing = @($spec.Args | Where-Object { $_.Required -and -not $State.Values.Contains($_.Id) })
    if ($missing.Count) {
        $context = foreach ($arg in $missing) { " $(Format-ZoCliArg -Arg $arg)" }
        throw (New-ZoCliError -Message 'the following required arguments were not provided:' -Context $context -Usage (Get-ZoCliUsage -State $State -Used @($State.Values.Keys) -Smart))
    }
}

function Read-ZoCommandLine {
    <#
    .SYNOPSIS
        Parses the tokens of one command level, recursing into a subcommand, and validates it.
    .DESCRIPTION
        Mirrors clap's Parser::get_matches_with. Before '--', a subcommand name starts the
        subcommand, '--' ends option parsing, '--x' and '-x' are arguments, and anything else is a
        pending option's value or the next positional. After '--' every token is a positional.
        Returns the parse state: Spec, Names, Values (ordered by first use), Child.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [object]$Spec,

        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Tokens = @(),

        [int]$Start = 0,

        [AllowEmptyCollection()]
        [string[]]$Names = @()
    )
    $state = [pscustomobject]@{
        PSTypeName = 'PSZoxide.CliState'
        Spec       = $Spec
        Names      = [string[]]$Names
        Values     = [ordered]@{}
        Pending    = $null
        Position   = 0
        Trailing   = $false
        Child      = $null
    }
    $positionals = @($Spec.Args | Where-Object Kind -EQ 'Positional')
    for ($i = $Start; $i -lt $Tokens.Count; $i++) {
        $token = $Tokens[$i]
        if (-not $state.Trailing) {
            $sub = Find-ZoCliSubcommand -Spec $Spec -Name $token
            if ($null -ne $sub) {
                Resolve-ZoCliPending -State $state
                $state.Child = Read-ZoCommandLine -Spec $sub -Tokens $Tokens -Start ($i + 1) -Names (@($Names) + $sub.Name)
                break
            }
            if ($token -ceq '--') {
                Resolve-ZoCliPending -State $state
                $state.Trailing = $true
                continue
            }
            if ($token.StartsWith('--')) {
                $remaining = @()
                if ($i + 1 -lt $Tokens.Count) { $remaining = @($Tokens[($i + 1)..($Tokens.Count - 1)]) }
                Read-ZoCliLong -State $state -Token $token -Remaining $remaining
                continue
            }
            if ($token.Length -gt 1 -and $token[0] -eq [char]'-') {
                Read-ZoCliShort -State $state -Token $token
                continue
            }
        }
        if ($null -ne $state.Pending) {
            $arg = $state.Pending
            $state.Pending = $null
            Add-ZoCliValue -State $state -Arg $arg -Value $token
            continue
        }
        if ($state.Position -lt $positionals.Count) {
            $arg = $positionals[$state.Position]
            Add-ZoCliValue -State $state -Arg $arg -Value $token
            if (-not $arg.Multiple) { $state.Position++ }
            continue
        }
        throw (New-ZoCliMatchError -State $state -Token $token)
    }
    Resolve-ZoCliPending -State $state
    Test-ZoCliCommand -State $state
    $state
}

function ConvertFrom-ZoArgs {
    <#
    .SYNOPSIS
        Parses raw zoxide arguments the way the binary's clap parser does.
    .DESCRIPTION
        Mirrors Cmd::parse() for the derive in cmd/cmd.rs. Returns Command (add, edit, import,
        init, query, remove), Subcommand (the edit action or import source, else $null), Values
        keyed by the Rust field names with defaults applied (init --cmd z, --hook pwd), and Exit.
        Exit is $null for a command to run; otherwise it holds the Text clap would print, whether
        it goes to stderr, and the exit code: 0 for help and version, 2 for usage errors and for
        the help printed when a command that needs a subcommand gets no arguments.
    .PARAMETER Arguments
        The raw tokens, as typed after 'zoxide'.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; mirrors clap parsing a list of arguments.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Arguments = @()
    )
    try {
        $top = Read-ZoCommandLine -Spec (Get-ZoCliRoot) -Tokens $Arguments
    }
    catch {
        if (-not $_.Exception.Data.Contains('ZoClap')) { throw }
        return [pscustomobject]@{
            PSTypeName = 'PSZoxide.ParsedArgs'
            Command    = $null
            Subcommand = $null
            Values     = @{}
            Exit       = $_.Exception.Data['ZoClap']
        }
    }

    $levels = [Collections.Generic.List[object]]::new()
    for ($level = $top.Child; $null -ne $level; $level = $level.Child) { $levels.Add($level) }
    $values = @{}
    foreach ($level in $levels) {
        foreach ($arg in $level.Spec.Args) {
            if ($arg.Kind -in 'Help', 'Version') { continue }
            $present = $level.Values.Contains($arg.Id)
            if ($arg.Kind -eq 'Flag') {
                # A global flag (import --merge) set at any level stays set.
                $values[$arg.Id] = $present -or ($values.ContainsKey($arg.Id) -and $values[$arg.Id])
            }
            elseif ($arg.Multiple) {
                $list = [string[]]@()
                if ($present) { $list = [string[]]$level.Values[$arg.Id].ToArray() }
                $values[$arg.Id] = $list
            }
            elseif ($present) { $values[$arg.Id] = $level.Values[$arg.Id] }
            else { $values[$arg.Id] = $arg.Default }
        }
    }
    [pscustomobject]@{
        PSTypeName = 'PSZoxide.ParsedArgs'
        Command    = $levels[0].Spec.Name
        Subcommand = if ($levels.Count -gt 1) { $levels[1].Spec.Name } else { $null }
        Values     = $values
        Exit       = $null
    }
}

function Invoke-ZoAdd {
    <#
    .SYNOPSIS
        Adds directories to the database or increments their rank.
    .DESCRIPTION
        Mirrors Add::run in cmd/add.rs. The exclude globs, _ZO_MAXAGE and the time are read
        before the database is opened. Each path is resolved (canonically when
        _ZO_RESOLVE_SYMLINKS=1); a path containing a line break or matching an exclude glob is
        skipped silently; a path that is not a directory throws 'not a directory: <path>' before
        anything is saved. The rank grows by --score (default 1). When anything changed the
        database is aged, then saved.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Values
    )
    $exclude = @(Get-ZoExcludeDirs)
    $maxAge = Get-ZoMaxAge
    $now = Get-ZoEpoch
    $database = Open-ZoDatabase
    $resolveSymlinks = Test-ZoResolveSymlinks
    $by = if ($null -ne $Values.score) { [double]$Values.score } else { 1.0 }

    foreach ($typed in $Values.paths) {
        $path = if ($resolveSymlinks) { Resolve-ZoCanonicalPath -Path $typed } else { Resolve-ZoPath -Path $typed }
        if ($path.Contains("`n") -or $path.Contains("`r")) { continue }
        $excluded = $false
        foreach ($glob in $exclude) {
            if (Test-ZoGlobMatch -Pattern $glob -Path $path) { $excluded = $true; break }
        }
        if ($excluded) { continue }
        # Path::is_dir follows links, as Directory.Exists does.
        if (-not [IO.Directory]::Exists($path)) { throw "not a directory: $path" }
        Add-ZoDirUpdate -Database $database -Path $path -By $by -Now $now
    }

    if ($database.Dirty) { Invoke-ZoAge -Database $database -MaxAge $maxAge }
    Save-ZoDatabase -Database $database
}

function Invoke-ZoQuery {
    <#
    .SYNOPSIS
        Searches the database and writes the result lines to the pipeline.
    .DESCRIPTION
        Mirrors Query::run in cmd/query.rs: `self.query(&mut db).and(db.save())`, so the database is
        saved whether or not the query succeeded (the score sort always makes it dirty), and the
        query's error wins over a save error. See Invoke-ZoQueryStream for the search itself.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Values
    )
    $database = Open-ZoDatabase
    $queryError = $null
    try {
        Invoke-ZoQueryStream -Database $database -Values $Values
    }
    catch {
        $queryError = $_
    }
    try {
        Save-ZoDatabase -Database $database
    }
    catch {
        if ($null -eq $queryError) { throw }
    }
    if ($null -ne $queryError) { throw $queryError }
}

function Invoke-ZoQueryStream {
    <#
    .SYNOPSIS
        Runs one query over an open database: the first match, a list, or an interactive pick.
    .DESCRIPTION
        Mirrors Query::query, query_first, query_list and get_stream. The stream gets the keywords,
        the exclude globs and --base-dir; without --all it also checks that directories exist.
        --exclude <path> skips that exact path. The first-match form throws 'no match found', or
        'you are already in the only match' when only the excluded path matched; the list form
        prints nothing when nothing matches. -s prefixes each path with its score. -i mirrors
        query_interactive: the entries (without the --exclude path) go to fzf, and its output is
        printed whole with -s, else from the eighth character on (selection.get(7..)). Without
        fzf the console picker (Select-ZoConsolePicker) chooses instead, a deliberate deviation.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [object]$Database,

        [Parameter(Mandatory)]
        [hashtable]$Values
    )
    $now = Get-ZoEpoch
    $options = @{
        Database = $Database
        Now      = $now
        Keywords = [string[]]$Values.keywords
        Exclude  = @(Get-ZoExcludeDirs)
        BaseDir  = [string]$Values.base_dir
    }
    if (-not $Values.all) {
        $options.Exists = $true
        $options.ResolveSymlinks = Test-ZoResolveSymlinks
    }
    $stream = New-ZoStream @options
    $exclude = $Values.exclude
    $format = @{}
    if ($Values.score) { $format.Now = $now }

    if ($Values.interactive) {
        $fzf = Find-ZoExecutable -Name 'fzf'
        $feed = New-ZoFeed -Stream $stream -Now $now -Exclude $exclude
        $selection = if ($fzf) { Invoke-ZoFzf -Command (New-ZoQueryFzfCommand -Path $fzf) -Feed $feed }
        else { Select-ZoConsolePicker -Feed $feed }
        # print!("{selection}") or print!("{}", selection.get(7..)): fzf's output keeps its newline.
        if (-not $Values.score) {
            if ($selection.Length -lt 7) { throw 'could not read selection from fzf' }
            $selection = $selection.Substring(7)
        }
        if ($selection.EndsWith("`n")) { $selection = $selection.Substring(0, $selection.Length - 1) }
        if ($selection -ne '') { $selection.Split("`n") }
        return
    }
    if ($Values.list) {
        while ($null -ne ($dir = Get-ZoStreamNext -Stream $stream)) {
            if ($null -ne $exclude -and $dir.Path -ceq $exclude) { continue }
            Format-ZoDir -Dir $dir @format
        }
        return
    }
    $dir = Get-ZoStreamNext -Stream $stream
    if ($null -eq $dir) { throw 'no match found' }
    while ($null -ne $exclude -and $dir.Path -ceq $exclude) {
        $dir = Get-ZoStreamNext -Stream $stream
        if ($null -eq $dir) { throw 'you are already in the only match' }
    }
    Format-ZoDir -Dir $dir @format
}

function Invoke-ZoRemove {
    <#
    .SYNOPSIS
        Removes directories from the database.
    .DESCRIPTION
        Mirrors Remove::run in cmd/remove.rs: each path is removed by exact match, else by its
        resolved absolute form; when neither is in the database it throws
        'path not found in database: <path as typed>' before anything is saved.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Values
    )
    $database = Open-ZoDatabase
    foreach ($path in $Values.paths) {
        if (Remove-ZoDir -Database $database -Path $path) { continue }
        $absolute = Resolve-ZoPath -Path $path
        if ($absolute -ceq $path -or -not (Remove-ZoDir -Database $database -Path $absolute)) {
            throw "path not found in database: $path"
        }
    }
    Save-ZoDatabase -Database $database
}

function Invoke-ZoInit {
    <#
    .SYNOPSIS
        Prints the init script for `zoxide init powershell`, one line at a time.
    .DESCRIPTION
        Mirrors Init::run in cmd/init.rs: Opts.cmd is --cmd unless --no-cmd, Opts.hook is --hook,
        and Opts.echo and Opts.resolve_symlinks come from _ZO_ECHO and _ZO_RESOLVE_SYMLINKS. The
        rendered template plus writeln!'s newline is written to the pipeline as lines. Every other
        shell throws 'PSZoxide only supports init powershell, not <shell>', a deliberate deviation
        (spec section 2: other shells are out of scope); ksh arrives here already mapped to posix.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Values
    )
    if ($Values.shell -cne 'powershell') { throw "PSZoxide only supports init powershell, not $($Values.shell)" }
    $cmd = if ($Values.no_cmd) { $null } else { [string]$Values.cmd }
    $script = Get-ZoInitScript -Cmd $cmd -Hook $Values.hook -Echo (Test-ZoEcho) -ResolveSymlinks (Test-ZoResolveSymlinks)
    $script.Split("`n")
}

function Invoke-ZoEdit {
    <#
    .SYNOPSIS
        Edits the database: the hidden subcommands change one entry, plain `edit` opens fzf.
    .DESCRIPTION
        Mirrors Edit::run in cmd/edit.rs. With a subcommand, increment and decrement add +1 or -1
        to the path's rank (Database::add: a new entry starts at that rank and now, the rank never
        drops below 0), delete removes the exact path, reload changes nothing; the database is saved
        when dirty, then every entry is printed from last to first, unsorted, as the fzf feed
        ("{score:>6.1}\t{path}" each followed by NUL, no newline) in one string. The path is used
        as given, without resolving it. Without a subcommand the entries are sorted by score and
        saved, and fzf runs with the edit bindings (New-ZoEditFzfCommand), whose reload actions
        call the module's edit-helper.ps1; fzf's `enter:abort` makes the usual end a silent exit 130.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Values,

        [string]$Subcommand
    )
    $now = Get-ZoEpoch
    $database = Open-ZoDatabase
    if (-not $Subcommand) {
        Invoke-ZoSort -Database $database -By Score -Now $now
        Save-ZoDatabase -Database $database
        $fzf = Find-ZoFzf
        if (-not [IO.File]::Exists($script:ZoEditHelper)) { throw "could not find the edit helper: $($script:ZoEditHelper)" }
        $null = Invoke-ZoFzf -Command (New-ZoEditFzfCommand -Path $fzf -Helper $script:ZoEditHelper)
        return
    }

    switch ($Subcommand) {
        'decrement' { Add-ZoDir -Database $database -Path $Values.path -By -1.0 -Now $now }
        'delete' { $null = Remove-ZoDir -Database $database -Path $Values.path }
        'increment' { Add-ZoDir -Database $database -Path $Values.path -By 1.0 -Now $now }
    }
    Save-ZoDatabase -Database $database
    $feed = [Text.StringBuilder]::new()
    for ($i = $database.Dirs.Count - 1; $i -ge 0; $i--) {
        [void]$feed.Append((Format-ZoDir -Dir $database.Dirs[$i] -Now $now -Separator "`t")).Append([char]0)
    }
    if ($feed.Length) { $feed.ToString() }
}

function Invoke-ZoMain {
    <#
    .SYNOPSIS
        Runs one zoxide command line: parse, dispatch, print, and set $global:LASTEXITCODE.
    .DESCRIPTION
        Mirrors main() in main.rs. Help and version text go to the pipeline one line at a time
        (exit 0); clap errors and the no-arguments help go to stderr (exit 2). A command's output
        lines go to the pipeline. A thrown error is written as 'zoxide: <message>' through
        Write-ZoError (multi-line anyhow chains unchanged) and sets exit code 1, except a silent
        exit (New-ZoSilentExit), which only sets its code. Never throws.
    .PARAMETER Arguments
        The raw tokens after 'zoxide'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyCollection()]
        [AllowEmptyString()]
        [string[]]$Arguments = @()
    )
    try {
        $parsed = ConvertFrom-ZoArgs -Arguments $Arguments
        if ($null -ne $parsed.Exit) {
            if ($parsed.Exit.Stderr) { Write-ZoError -Message $parsed.Exit.Text }
            else { $parsed.Exit.Text.Split("`n") }
            $global:LASTEXITCODE = $parsed.Exit.Code
            return
        }
        switch ($parsed.Command) {
            'add' { Invoke-ZoAdd -Values $parsed.Values }
            'edit' { Invoke-ZoEdit -Values $parsed.Values -Subcommand $parsed.Subcommand }
            'import' { Invoke-ZoImport -Values $parsed.Values -Subcommand $parsed.Subcommand }
            'init' { Invoke-ZoInit -Values $parsed.Values }
            'query' { Invoke-ZoQuery -Values $parsed.Values }
            'remove' { Invoke-ZoRemove -Values $parsed.Values }
        }
        $global:LASTEXITCODE = 0
    }
    catch {
        $exception = $_.Exception
        while ($exception -is [Management.Automation.MethodInvocationException] -and $null -ne $exception.InnerException) {
            $exception = $exception.InnerException
        }
        if ($exception.Data.Contains('ZoSilentExit')) {
            $global:LASTEXITCODE = [int]$exception.Data['ZoSilentExit']
            return
        }
        Write-ZoError -Message "zoxide: $($exception.Message)"
        $global:LASTEXITCODE = 1
    }
}