Private/Database.ps1

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

# The zoxide database, mirroring zoxide 0.10.0 src/db/mod.rs, src/db/dir.rs and util::write.
#
# Data structures (Phase 2 builds on these):
#
# Database [pscustomobject] PSTypeName 'PSZoxide.Database'
# DataDir [string] the data directory (Get-ZoDataDir by default)
# Path [string] DataDir joined with 'db.zo'
# Dirs [System.Collections.Generic.List[object]] of Dir, in file order
# Dirty [bool] true once anything changed; Save-ZoDatabase writes only when true
#
# Dir [pscustomobject] PSTypeName 'PSZoxide.Dir' (built by New-ZoDir)
# Path [string] exact-case; compared with StringComparison.Ordinal
# Rank [double]
# LastAccessed [uint64] unix seconds
#
# Every mutating function takes -Database and changes it in place, setting Dirty the way the Rust
# method sets `dirty`. Note that both sorts set Dirty (sort_by_path and sort_by_score do), so a
# query, which sorts by score, always rewrites db.zo in the binary.
#
# db.zo is bincode 1.3 with fixint encoding, little-endian: u32 version (3), u64 entry count, then
# per entry a u64 byte length plus the UTF-8 path, an f64 rank and a u64 last_accessed. BitConverter
# reads use the host byte order; every platform .NET runs PowerShell 7 on is little-endian.

$script:ZoDbVersion = [uint32]3
# A UTF-8 decoder that throws on invalid input instead of substituting U+FFFD.
$script:ZoStrictUtf8 = [Text.UTF8Encoding]::new($false, $true)

function New-ZoDir {
    <#
    .SYNOPSIS
        Creates one database entry.
    .DESCRIPTION
        Mirrors db::dir::Dir { path, rank, last_accessed }. ConvertFrom-ZoBytes builds the same
        object inline for speed; keep the two in sync.
    #>

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

        [Parameter(Mandatory)]
        [double]$Rank,

        [Parameter(Mandatory)]
        [uint64]$LastAccessed
    )
    [pscustomobject]@{
        PSTypeName   = 'PSZoxide.Dir'
        Path         = $Path
        Rank         = $Rank
        LastAccessed = $LastAccessed
    }
}

function Get-ZoEpoch {
    <#
    .SYNOPSIS
        The current time in unix seconds.
    .DESCRIPTION
        Mirrors util::current_time.
    #>

    [CmdletBinding()]
    [OutputType([uint64])]
    param()
    [uint64][DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
}

function Get-ZoScore {
    <#
    .SYNOPSIS
        The frecency score of an entry at a given time.
    .DESCRIPTION
        Mirrors Dir::score in db/dir.rs: rank x4 when accessed within the hour, x2 within the day,
        x0.5 within the week, x0.25 otherwise. A last_accessed in the future counts as now
        (saturating_sub). Invoke-ZoSort inlines the same arithmetic for speed; keep the two in
        sync with Dir::score.
    #>

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

        [Parameter(Mandatory)]
        [uint64]$Now
    )
    $last = [uint64]$Dir.LastAccessed
    $duration = if ($Now -gt $last) { [uint64]($Now - $last) } else { [uint64]0 }
    if ($duration -lt 3600) { $Dir.Rank * 4.0 }
    elseif ($duration -lt 86400) { $Dir.Rank * 2.0 }
    elseif ($duration -lt 604800) { $Dir.Rank * 0.5 }
    else { $Dir.Rank * 0.25 }
}

function ConvertTo-ZoBytes {
    <#
    .SYNOPSIS
        Serialises entries to the db.zo byte layout.
    .DESCRIPTION
        Mirrors Database::serialize: bincode fixint little-endian, version 3. An empty list gives
        exactly 12 bytes, 03000000 then eight zero bytes. Returns a single [byte[]].
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; the output is a byte array.')]
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [object[]]$Dirs
    )
    $stream = [IO.MemoryStream]::new()
    # BinaryWriter is little-endian on every platform.
    $writer = [IO.BinaryWriter]::new($stream)
    $writer.Write($script:ZoDbVersion)
    $writer.Write([uint64]$Dirs.Count)
    foreach ($dir in $Dirs) {
        $pathBytes = [Text.Encoding]::UTF8.GetBytes([string]$dir.Path)
        $writer.Write([uint64]$pathBytes.Length)
        $writer.Write($pathBytes)
        $writer.Write([double]$dir.Rank)
        $writer.Write([uint64]$dir.LastAccessed)
    }
    $writer.Flush()
    , $stream.ToArray()
}

function Get-ZoUtf8Error {
    <#
    .SYNOPSIS
        Describes the first UTF-8 error in a byte range the way Rust's Utf8Error displays it.
    .DESCRIPTION
        Mirrors core::str::from_utf8 validation: returns 'invalid utf-8 sequence of N bytes from
        index I' or 'incomplete utf-8 byte sequence from index I', or $null when the range is valid.
        Only called after the strict .NET decoder has already rejected the range.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [byte[]]$Bytes,

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

        [Parameter(Mandatory)]
        [int]$Count
    )
    $i = 0
    while ($i -lt $Count) {
        $first = $Bytes[$Offset + $i]
        if ($first -lt 0x80) { $i++; continue }
        $width = if ($first -ge 0xC2 -and $first -le 0xDF) { 2 }
        elseif ($first -ge 0xE0 -and $first -le 0xEF) { 3 }
        elseif ($first -ge 0xF0 -and $first -le 0xF4) { 4 }
        else { 0 }
        if ($width -eq 0) { return "invalid utf-8 sequence of 1 bytes from index $i" }
        # The second byte's range excludes overlong forms, surrogates and code points past U+10FFFF.
        $low = 0x80
        $high = 0xBF
        if ($first -eq 0xE0) { $low = 0xA0 }
        elseif ($first -eq 0xED) { $high = 0x9F }
        elseif ($first -eq 0xF0) { $low = 0x90 }
        elseif ($first -eq 0xF4) { $high = 0x8F }
        for ($k = 1; $k -lt $width; $k++) {
            if ($i + $k -ge $Count) { return "incomplete utf-8 byte sequence from index $i" }
            $next = $Bytes[$Offset + $i + $k]
            $ok = if ($k -eq 1) { $next -ge $low -and $next -le $high } else { $next -ge 0x80 -and $next -le 0xBF }
            if (-not $ok) { return "invalid utf-8 sequence of $k bytes from index $i" }
        }
        $i += $width
    }
    $null
}

function ConvertFrom-ZoBytes {
    <#
    .SYNOPSIS
        Parses db.zo bytes into a list of entries.
    .DESCRIPTION
        Mirrors Database::deserialize with bincode::options().with_fixint_encoding(). Errors use the
        binary's wording: fewer than 4 bytes is 'could not deserialize database: corrupted data';
        another version is 'unsupported version (got N, supports 3)'; anything else wrong in the
        body is 'could not deserialize database' with bincode's error as the cause. bincode replaces
        the 32 MiB limit with no limit when reading from a slice, so there is no size check here.
        Returns a single [System.Collections.Generic.List[object]].
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Spec name; the input is a byte array.')]
    [CmdletBinding()]
    [OutputType([Collections.Generic.List[object]])]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [byte[]]$Bytes
    )
    if ($Bytes.Length -lt 4) {
        throw 'could not deserialize database: corrupted data'
    }
    $version = [BitConverter]::ToUInt32($Bytes, 0)
    if ($version -ne $script:ZoDbVersion) {
        throw "unsupported version (got $version, supports $($script:ZoDbVersion))"
    }

    $context = 'could not deserialize database'
    # A fixed-size read past the end fails in Read::read_exact; a string past the end fails in
    # bincode's SliceReader with an empty io::Error message.
    $eof = 'io error: unexpected end of file'
    $length = $Bytes.Length
    $pos = 4
    if ($length - $pos -lt 8) { throw (New-ZoError -Chain $context, $eof) }
    $count = [BitConverter]::ToUInt64($Bytes, $pos)
    $pos += 8

    $dirs = [Collections.Generic.List[object]]::new()
    for ([uint64]$n = 0; $n -lt $count; $n++) {
        if ($length - $pos -lt 8) { throw (New-ZoError -Chain $context, $eof) }
        $pathLength = [BitConverter]::ToUInt64($Bytes, $pos)
        $pos += 8
        if ($pathLength -gt [uint64]($length - $pos)) { throw (New-ZoError -Chain $context, 'io error: ') }
        try {
            $path = $script:ZoStrictUtf8.GetString($Bytes, $pos, [int]$pathLength)
        }
        catch {
            $detail = Get-ZoUtf8Error -Bytes $Bytes -Offset $pos -Count ([int]$pathLength)
            throw (New-ZoError -Chain $context, "string is not valid utf8: $detail")
        }
        $pos += [int]$pathLength
        if ($length - $pos -lt 16) { throw (New-ZoError -Chain $context, $eof) }
        $rank = [BitConverter]::ToDouble($Bytes, $pos)
        $lastAccessed = [BitConverter]::ToUInt64($Bytes, $pos + 8)
        $pos += 16
        # New-ZoDir's object, written inline (keep in sync): one function call per entry would cost
        # several times the decoding itself.
        $dirs.Add([pscustomobject]@{ PSTypeName = 'PSZoxide.Dir'; Path = $path; Rank = $rank; LastAccessed = $lastAccessed })
    }
    if ($pos -ne $length) {
        throw (New-ZoError -Chain $context, 'Slice had bytes remaining after deserialization')
    }
    , $dirs
}

function Open-ZoDatabase {
    <#
    .SYNOPSIS
        Reads <DataDir>/db.zo, or returns an empty database when the file does not exist.
    .DESCRIPTION
        Mirrors Database::open_dir. When db.zo is missing the data directory is created (but no
        file is written) and the database starts empty and clean. Read failures throw
        'could not read from database: <path>'; directory creation failures throw
        'unable to create data directory: <dir>'; both carry the .NET error as the cause.
    .PARAMETER DataDir
        The data directory. Defaults to Get-ZoDataDir.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string]$DataDir = (Get-ZoDataDir)
    )
    $path = [IO.Path]::Join($DataDir, 'db.zo')
    $bytes = $null
    try {
        $bytes = [IO.File]::ReadAllBytes($path)
    }
    catch [IO.FileNotFoundException], [IO.DirectoryNotFoundException] {
        $bytes = $null
    }
    catch {
        throw (New-ZoError -Chain "could not read from database: $path" -ErrorRecord $_)
    }

    if ($null -eq $bytes) {
        Initialize-ZoDataDir -DataDir $DataDir
        $dirs = [Collections.Generic.List[object]]::new()
    }
    else {
        $dirs = ConvertFrom-ZoBytes -Bytes $bytes
    }
    [pscustomobject]@{
        PSTypeName = 'PSZoxide.Database'
        DataDir    = $DataDir
        Path       = $path
        Dirs       = $dirs
        Dirty      = $false
    }
}

function Initialize-ZoDataDir {
    <#
    .SYNOPSIS
        Creates the data directory if it is missing.
    .DESCRIPTION
        Mirrors the fs::create_dir_all call in Database::open_dir, including its error context.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$DataDir
    )
    try {
        [void][IO.Directory]::CreateDirectory($DataDir)
    }
    catch {
        throw (New-ZoError -Chain "unable to create data directory: $DataDir" -ErrorRecord $_)
    }
}

function Save-ZoDatabase {
    <#
    .SYNOPSIS
        Writes the database to db.zo atomically, only when it is dirty.
    .DESCRIPTION
        Mirrors Database::save and util::write: the bytes go to a new tmp_<12 random alphanumerics>
        file beside db.zo, are flushed to disk, and the file is renamed over db.zo (retried on
        Windows when access is denied). On any failure the tmp file is deleted and the error reads
        'could not write to database' with the step that failed as the cause. Dirty is cleared
        only after a successful write. Unlike the binary, a data directory that vanished after
        Open-ZoDatabase is created again first.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Database
    )
    if (-not $Database.Dirty) { return }
    $bytes = ConvertTo-ZoBytes -Dirs $Database.Dirs
    Initialize-ZoDataDir -DataDir $Database.DataDir
    try {
        Write-ZoFileAtomic -Path $Database.Path -Bytes $bytes
    }
    catch {
        throw (New-ZoError -Chain 'could not write to database' -ErrorRecord $_)
    }
    $Database.Dirty = $false
}

function New-ZoTmpName {
    <#
    .SYNOPSIS
        A tmp file name: 'tmp_' plus 12 random ASCII letters and digits (16 characters).
    .DESCRIPTION
        Mirrors the name built in util::tmpfile with fastrand::alphanumeric.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Returns a string only.')]
    [CmdletBinding()]
    [OutputType([string])]
    param()
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
    $name = [Text.StringBuilder]::new('tmp_')
    while ($name.Length -lt 16) {
        [void]$name.Append($alphabet[[Security.Cryptography.RandomNumberGenerator]::GetInt32($alphabet.Length)])
    }
    $name.ToString()
}

function Write-ZoFileAtomic {
    <#
    .SYNOPSIS
        Writes bytes to a file through a tmp file and a rename.
    .DESCRIPTION
        Mirrors util::write, util::tmpfile and util::rename: create a new tmp file in the target's
        directory (5 attempts on a name clash), write, flush to disk, close, rename over the
        target (up to 5 retries when access is denied, Windows only). The tmp file is deleted on
        any failure after it was created. The Unix fchown of the tmp file to the old db.zo owner is
        not mirrored.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [byte[]]$Bytes
    )
    $dir = [IO.Path]::GetDirectoryName($Path)
    $stream = $null
    for ($attempt = 1; $null -eq $stream; $attempt++) {
        $tmp = [IO.Path]::Join($dir, (New-ZoTmpName))
        try {
            $stream = [IO.FileStream]::new($tmp, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
        }
        catch {
            if ($attempt -lt 5 -and [IO.File]::Exists($tmp)) { continue }
            throw (New-ZoError -Chain "could not create file: $tmp" -ErrorRecord $_)
        }
    }

    try {
        try { $stream.SetLength($Bytes.Length) } catch { Write-Debug "set_len failed: $_" }
        try {
            $stream.Write($Bytes, 0, $Bytes.Length)
        }
        catch {
            throw (New-ZoError -Chain "could not write to file: $tmp" -ErrorRecord $_)
        }
        try {
            $stream.Flush($true)
        }
        catch {
            throw (New-ZoError -Chain "could not sync writes to file: $tmp" -ErrorRecord $_)
        }
        $stream.Dispose()

        $retries = if ($IsWindows) { 5 } else { 0 }
        for ($attempt = 0; ; $attempt++) {
            try {
                [IO.File]::Move($tmp, $Path, $true)
                break
            }
            catch [UnauthorizedAccessException] {
                if ($attempt -lt $retries) { continue }
                throw (New-ZoError -Chain "could not rename file: $tmp -> $Path" -ErrorRecord $_)
            }
            catch {
                throw (New-ZoError -Chain "could not rename file: $tmp -> $Path" -ErrorRecord $_)
            }
        }
    }
    catch {
        $stream.Dispose()
        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
        throw
    }
}

function Find-ZoDirIndex {
    <#
    .SYNOPSIS
        The index of the entry whose path equals -Path exactly (ordinal), or -1.
    .DESCRIPTION
        Mirrors `dirs.iter().position(|dir| dir.path == path)`.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path
    )
    $dirs = $Database.Dirs
    for ($i = 0; $i -lt $dirs.Count; $i++) {
        if ([string]::Equals($dirs[$i].Path, $Path, [StringComparison]::Ordinal)) { return $i }
    }
    -1
}

function Get-ZoNonNegative {
    <#
    .SYNOPSIS
        Returns the value, or 0.0 when it is negative or NaN, like Rust's f64::max(x, 0.0).
    #>

    [CmdletBinding()]
    [OutputType([double])]
    param(
        [Parameter(Mandatory)]
        [double]$Value
    )
    if ($Value -gt 0) { $Value } else { 0.0 }
}

function Add-ZoDir {
    <#
    .SYNOPSIS
        Adds -By to an entry's rank, or creates the entry; last_accessed is kept for an existing entry.
    .DESCRIPTION
        Mirrors Database::add. The rank never drops below 0.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [Parameter(Mandatory)]
        [double]$By,

        [Parameter(Mandatory)]
        [uint64]$Now
    )
    $index = Find-ZoDirIndex -Database $Database -Path $Path
    if ($index -ge 0) {
        $dir = $Database.Dirs[$index]
        $dir.Rank = Get-ZoNonNegative -Value ($dir.Rank + $By)
    }
    else {
        $Database.Dirs.Add((New-ZoDir -Path $Path -Rank (Get-ZoNonNegative -Value $By) -LastAccessed $Now))
    }
    $Database.Dirty = $true
}

function Add-ZoDirUpdate {
    <#
    .SYNOPSIS
        Adds -By to an entry's rank and sets its last_accessed to -Now, or creates the entry.
    .DESCRIPTION
        Mirrors Database::add_update, which `zoxide add` uses. The rank never drops below 0.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [Parameter(Mandatory)]
        [double]$By,

        [Parameter(Mandatory)]
        [uint64]$Now
    )
    $index = Find-ZoDirIndex -Database $Database -Path $Path
    if ($index -ge 0) {
        $dir = $Database.Dirs[$index]
        $dir.Rank = Get-ZoNonNegative -Value ($dir.Rank + $By)
        $dir.LastAccessed = $Now
    }
    else {
        $Database.Dirs.Add((New-ZoDir -Path $Path -Rank (Get-ZoNonNegative -Value $By) -LastAccessed $Now))
    }
    $Database.Dirty = $true
}

function Add-ZoDirUnchecked {
    <#
    .SYNOPSIS
        Appends an entry without looking for an existing one and without clamping the rank.
    .DESCRIPTION
        Mirrors Database::add_unchecked, which import uses before Invoke-ZoDedup.
    #>

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

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path,

        [Parameter(Mandatory)]
        [double]$Rank,

        [Parameter(Mandatory)]
        [uint64]$Now
    )
    $Database.Dirs.Add((New-ZoDir -Path $Path -Rank $Rank -LastAccessed $Now))
    $Database.Dirty = $true
}

function Remove-ZoDirAt {
    <#
    .SYNOPSIS
        Removes the entry at -Index by moving the last entry into its place.
    .DESCRIPTION
        Mirrors Database::swap_remove (Vec::swap_remove): O(1), does not keep order.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'In-memory list mutation inside a zoxide clone; confirmation would diverge from the binary.')]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Database,

        [Parameter(Mandatory)]
        [int]$Index
    )
    $dirs = $Database.Dirs
    $last = $dirs.Count - 1
    $dirs[$Index] = $dirs[$last]
    $dirs.RemoveAt($last)
    $Database.Dirty = $true
}

function Remove-ZoDir {
    <#
    .SYNOPSIS
        Removes the entry with exactly -Path (ordinal); returns $true when one was removed.
    .DESCRIPTION
        Mirrors Database::remove, which swap-removes.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'In-memory list mutation inside a zoxide clone; confirmation would diverge from the binary.')]
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [object]$Database,

        [Parameter(Mandatory)]
        [AllowEmptyString()]
        [string]$Path
    )
    $index = Find-ZoDirIndex -Database $Database -Path $Path
    if ($index -lt 0) { return $false }
    Remove-ZoDirAt -Database $Database -Index $index
    $true
}

function Invoke-ZoAge {
    <#
    .SYNOPSIS
        Scales every rank down when their sum exceeds -MaxAge, dropping entries that fall below 1.
    .DESCRIPTION
        Mirrors Database::age: when the rank total is greater than MaxAge, each rank is multiplied
        by 0.9 * MaxAge / total, walking from the last entry to the first, and an entry whose rank
        is then below 1.0 is swap-removed. Dirty is set only when aging happened.
    #>

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

        [Parameter(Mandatory)]
        [double]$MaxAge
    )
    $dirs = $Database.Dirs
    $total = 0.0
    foreach ($dir in $dirs) { $total += $dir.Rank }
    if (-not ($total -gt $MaxAge)) { return }
    $factor = 0.9 * $MaxAge / $total
    for ($index = $dirs.Count - 1; $index -ge 0; $index--) {
        $dir = $dirs[$index]
        $dir.Rank = $dir.Rank * $factor
        if ($dir.Rank -lt 1.0) {
            Remove-ZoDirAt -Database $Database -Index $index
        }
    }
    $Database.Dirty = $true
}

function Invoke-ZoDedup {
    <#
    .SYNOPSIS
        Merges entries with equal paths: ranks are summed and the newest last_accessed is kept.
    .DESCRIPTION
        Mirrors Database::dedup: sort by path, then walk from the end, merging each entry into the
        one before it when the paths are equal and swap-removing it. Always sets Dirty, because
        the sort does.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [object]$Database
    )
    Invoke-ZoSort -Database $Database -By Path
    $dirs = $Database.Dirs
    for ($index = $dirs.Count - 1; $index -ge 1; $index--) {
        $current = $dirs[$index]
        $previous = $dirs[$index - 1]
        if (-not [string]::Equals($previous.Path, $current.Path, [StringComparison]::Ordinal)) { continue }
        if ($current.LastAccessed -gt $previous.LastAccessed) { $previous.LastAccessed = $current.LastAccessed }
        $previous.Rank = $previous.Rank + $current.Rank
        Remove-ZoDirAt -Database $Database -Index $index
    }
}

function Invoke-ZoSort {
    <#
    .SYNOPSIS
        Sorts the entries in place by path or by ascending score, and sets Dirty.
    .DESCRIPTION
        -By Path mirrors Database::sort_by_path: Rust's str ordering, which is UTF-8 byte order
        (not UTF-16 ordinal order). -By Score mirrors Database::sort_by_score: ascending
        f64::total_cmp of Get-ZoScore at -Now. This sort is stable. Rust's sort_unstable_by is
        stable only for tie groups of up to 16 entries (the insertion-sort threshold), so the
        order inside larger equal-score groups can differ from the binary.
    .PARAMETER By
        Path or Score.
    .PARAMETER Now
        The time for scoring. Required with -By Score.
    #>

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

        [Parameter(Mandatory)]
        [ValidateSet('Path', 'Score')]
        [string]$By,

        [uint64]$Now
    )
    $dirs = $Database.Dirs
    $count = $dirs.Count
    $Database.Dirty = $true
    if ($count -lt 2) { return }
    $indexes = [int[]](0..($count - 1))

    if ($By -eq 'Path') {
        # Latin-1 maps each UTF-8 byte to one char, so ordinal order on the result is byte order.
        $keys = [string[]]::new($count)
        for ($i = 0; $i -lt $count; $i++) {
            $keys[$i] = [Text.Encoding]::Latin1.GetString([Text.Encoding]::UTF8.GetBytes([string]$dirs[$i].Path))
        }
        $order = [Linq.Enumerable]::OrderBy($indexes, [Func[int, string]] { param($i) $keys[$i] }, [StringComparer]::Ordinal)
    }
    else {
        if (-not $PSBoundParameters.ContainsKey('Now')) { throw 'Invoke-ZoSort -By Score needs -Now.' }
        # f64::total_cmp: flip every bit but the sign of negative values, then compare as i64.
        $keys = [long[]]::new($count)
        for ($i = 0; $i -lt $count; $i++) {
            # Dir::score inline (keep in sync with Get-ZoScore): a PowerShell function call per
            # entry costs more than the arithmetic it wraps, and every query sorts.
            $dir = $dirs[$i]
            $last = [uint64]$dir.LastAccessed
            $duration = if ($Now -gt $last) { [uint64]($Now - $last) } else { [uint64]0 }
            $score = if ($duration -lt 3600) { $dir.Rank * 4.0 }
            elseif ($duration -lt 86400) { $dir.Rank * 2.0 }
            elseif ($duration -lt 604800) { $dir.Rank * 0.5 }
            else { $dir.Rank * 0.25 }
            $bits = [BitConverter]::DoubleToInt64Bits($score)
            $keys[$i] = if ($bits -lt 0) { $bits -bxor [long]::MaxValue } else { $bits }
        }
        $order = [Linq.Enumerable]::OrderBy($indexes, [Func[int, long]] { param($i) $keys[$i] })
    }

    $sorted = foreach ($i in [Linq.Enumerable]::ToArray($order)) { $dirs[$i] }
    $dirs.Clear()
    $dirs.AddRange([object[]]$sorted)
}