Shared/Import-DFToolDb.ps1

#Requires -Version 7.0

$script:DFToolDb = $null

function Import-DFToolDb {
    <#
    .SYNOPSIS
        Loads Tools/*.json files into a cached hashtable keyed by tool name.
        Validates each file with Test-DFToolSchema; invalid files are skipped with a warning.
        Valid records are normalized with ConvertTo-DFToolRecord, so every known
        field exists and callers use plain property access.
    .PARAMETER ToolsPath
        Path to the tools directory. Defaults to the module's Tools/ folder.
        Pass an explicit path in tests to control which JSON files are loaded.
        Supplying this parameter always forces a fresh, uncached read and never
        populates the shared cache -- only calls using the default location
        participate in caching, so a caller with its own directory never sees
        (or clobbers) another caller's registry.
    .PARAMETER Force
        Clears the cache and reloads from disk. Has no effect when -ToolsPath is also
        supplied -- that call is always uncached regardless of -Force.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string]$ToolsPath = (Join-Path $PSScriptRoot '../Tools'),
        [string[]]$Name,
        [switch]$Force
    )

    $explicitToolsPath = $PSBoundParameters.ContainsKey('ToolsPath')

    if ($Name) {
        # Only the requested records: one file read each (record name = file name,
        # enforced by tests). Default-location records are cached per name.
        $db = @{}
        foreach ($n in $Name) {
            $record = $null
            if (-not $explicitToolsPath -and -not $Force) {
                if ($script:DFToolDb -and $script:DFToolDb.ContainsKey($n)) { $record = $script:DFToolDb[$n] }
                elseif ($script:DFToolRecordCache.ContainsKey($n)) { $record = $script:DFToolRecordCache[$n] }
            }
            if (-not $record) {
                $file = Join-Path $ToolsPath "$n.json"
                if (-not (Test-Path $file -PathType Leaf)) {
                    Write-Warning "DotForge: no tool record for '$n' ($file)."
                    continue
                }
                $record = Read-DFToolRecordFile -Path $file
                if (-not $record) { continue }
                if (-not $explicitToolsPath) { $script:DFToolRecordCache[$record.name] = $record }
            }
            $db[$record.name] = $record
        }
        return $db
    }

    if (-not $explicitToolsPath -and -not $Force -and $script:DFToolDb) { return $script:DFToolDb }

    $db = @{}

    if (Test-Path $ToolsPath -PathType Container) {
        foreach ($file in Get-ChildItem $ToolsPath -Filter '*.json' -ErrorAction Ignore) {
            $record = Read-DFToolRecordFile -Path $file.FullName
            if ($record) { $db[$record.name] = $record }
        }
    }

    if (-not $explicitToolsPath) { $script:DFToolDb = $db }
    return $db
}

$script:DFToolRecordCache = @{}

function Read-DFToolRecordFile {
    <#
    .SYNOPSIS
        Reads, validates and normalizes one tool JSON file; warns and returns $null when it's invalid.
    .PARAMETER Path
        The tool JSON file.
    .OUTPUTS
        pscustomobject (a ConvertTo-DFToolRecord record), or $null.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    $leaf = Split-Path $Path -Leaf
    try {
        $text = [IO.File]::ReadAllText($Path)
        # Unchanged shipped record: already validated and normalized at build time.
        $entry = (Get-DFToolRegistry)[[IO.Path]::GetFileNameWithoutExtension($Path)]
        if ($entry -and $entry.sha256 -eq (Get-DFToolRecordHash -Text $text)) { return $entry.record }
        $tool = $text | ConvertFrom-Json
    } catch {
        Write-Warning "DotForge: Failed to parse $leaf`: $($_.Exception.Message)"
        return $null
    }
    $errors = @(); $warnings = @()
    if (-not (Test-DFToolSchema -Tool $tool -Errors ([ref]$errors) -Warnings ([ref]$warnings))) {
        Write-Warning "DotForge: $leaf schema errors: $($errors -join '; ')"
        return $null
    }
    foreach ($w in $warnings) { Write-Warning "DotForge: $leaf`: $w" }
    ConvertTo-DFToolRecord $tool
}

function Get-DFToolNames {
    <#
    .SYNOPSIS
        Lists the names of every tool DotForge has a record for, from file names alone.
    .DESCRIPTION
        Reads no file: a record's name equals its file name (tests enforce it),
        so request resolution can validate names without loading records.
    .PARAMETER ToolsPath
        The tools folder. Default: the module's Tools/.
    .OUTPUTS
        System.String[].
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([string]$ToolsPath = (Join-Path $PSScriptRoot '../Tools'))
    @(Get-ChildItem $ToolsPath -Filter '*.json' -File -ErrorAction Ignore | ForEach-Object BaseName)
}

function ConvertTo-DFToolRecord {
    <#
    .SYNOPSIS
        Returns a tool record (parsed Tools/<name>.json) with every known field
        present and defaulted, so callers can use plain property access.
    .DESCRIPTION
        The boundary between the loosely shaped JSON a tool author writes and
        the record the rest of DotForge reads. Every known top-level field
        exists on the result; absent ones get their default:

            type 'exe', description '', tags @(), after @(), requires @(), prewarm $true,
            packages / xdg / env / aliases / picker / themeMap /
            settings / setup $null, roles an empty object, executableExclude @()

        roles is an object keyed by role name; each value is { priority
        (default 0); optIn (default $false); aliases (same shape as top-level
        aliases, or $null); env; legacy }. An opt-in member is considered only
        when $DFConfig.Defaults selects it for that role. A legacy "role": "x"
        string becomes roles.x with priority 0 and legacy $true.

        Nested shapes are normalized too: xdg always has method, vars, dirs,
        instructions and compliance; setup, when present, has seed (an object
        mapping destination path template to a file under Tools/, or $null);
        installs, when present, is a list of blocks (a single object in the
        JSON becomes a one-block list), each with from, command (string[] or
        $null), function, args, batch, elevate and reactivate ($false by
        default) and feeds ($null, or { list; add; id = '{feed}/{id}' }); install, when
        present, has prefer (string[]); each alias is
        { command; args[] } with a missing args meaning none; an object picker
        has every field, with preview '', preview_window 'right:60%',
        header '', ansi and list_accepts_path $false. A non-object picker (the
        "custom" marker) is kept as is, and so is the free-form settings
        object, whose shape each tool's companion owns. Unknown fields are
        kept. Reading any known field of the result is safe under
        Set-StrictMode -Version Latest.

        Validation (required fields, allowed values) is Test-DFToolSchema's
        job and happens before this.
    .PARAMETER Tool
        The parsed JSON object.
    .OUTPUTS
        PSCustomObject. A new object; the input is not modified.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory, Position = 0)][pscustomobject]$Tool)

    $get = { param($obj, $name, $default) $p = $obj.PSObject.Properties[$name]; if ($p) { $p.Value } else { $default } }

    $xdg = & $get $Tool 'xdg' $null
    if ($xdg) {
        $xdg = [pscustomobject]@{
            method         = & $get $xdg 'method' $null
            vars           = & $get $xdg 'vars' $null
            dirs           = @(& $get $xdg 'dirs' @())
            instructions   = & $get $xdg 'instructions' $null
            compliance     = & $get $xdg 'compliance' $null
        }
    }

    $normalizeAliases = {
        param($raw)
        if (-not $raw) { return $null }
        $normalized = [ordered]@{}
        foreach ($a in $raw.PSObject.Properties) {
            $rawArgs = & $get $a.Value 'args' $null
            $normalized[$a.Name] = [pscustomobject]@{
                command = & $get $a.Value 'command' $null
                # @() around the whole if: a one-element array assigned from an
                # if-expression would otherwise unwrap to a bare string.
                args    = [object[]]@(if ($null -ne $rawArgs) { $rawArgs })
            }
        }
        [pscustomobject]$normalized
    }
    $aliases = & $normalizeAliases (& $get $Tool 'aliases' $null)

    $roles = [ordered]@{}
    $rawRoles = & $get $Tool 'roles' $null
    if ($rawRoles) {
        foreach ($r in $rawRoles.PSObject.Properties) {
            $roles[$r.Name] = [pscustomobject]@{
                priority = [int](& $get $r.Value 'priority' 0)
                optIn    = [bool](& $get $r.Value 'optIn' $false)
                aliases  = & $normalizeAliases (& $get $r.Value 'aliases' $null)
                env      = & $get $r.Value 'env' $null
                legacy   = $false
            }
        }
    }
    # Role v1 declared a single "role" string; read it as a priority-0 membership.
    # legacy marks it so registration keeps v1 behavior: no hook expected, and a
    # loser's top-level aliases that the role reserves are left out.
    $legacyRole = & $get $Tool 'role' $null
    if ($legacyRole -and -not $roles.Contains($legacyRole)) {
        $roles[$legacyRole] = [pscustomobject]@{ priority = 0; optIn = $false; aliases = $null; env = $null; legacy = $true }
    }

    $picker = & $get $Tool 'picker' $null
    if ($picker -is [pscustomobject]) {
        $picker = [pscustomobject]@{
            function          = & $get $picker 'function' $null
            alias             = & $get $picker 'alias' $null
            list              = & $get $picker 'list' $null
            list_accepts_path = [bool](& $get $picker 'list_accepts_path' $false)
            preview           = & $get $picker 'preview' ''
            preview_window    = & $get $picker 'preview_window' 'right:60%'
            ansi              = [bool](& $get $picker 'ansi' $false)
            header            = & $get $picker 'header' ''
            action            = & $get $picker 'action' $null
            parse             = & $get $picker 'parse' $null
        }
    }

    $setup = & $get $Tool 'setup' $null
    if ($setup) { $setup = [pscustomobject]@{ seed = & $get $setup 'seed' $null } }

    # A package manager's installs blocks (install spec section 1): one object,
    # or a list when it installs from several sources (uv: PyPI tools and Python).
    $installs = & $get $Tool 'installs' $null
    if ($null -ne $installs) {
        $installs = [object[]]@(foreach ($blk in @($installs)) {
            $feeds = & $get $blk 'feeds' $null
            if ($feeds) {
                $feeds = [pscustomobject]@{
                    list = [string[]]@(& $get $feeds 'list' @())
                    add  = [string[]]@(& $get $feeds 'add' @())
                    id   = & $get $feeds 'id' '{feed}/{id}'
                }
            }
            $command = & $get $blk 'command' $null
            [pscustomobject]@{
                from       = & $get $blk 'from' $null
                command    = $(if ($null -ne $command) { [string[]]@($command) })
                function   = & $get $blk 'function' $null
                args       = & $get $blk 'args' $null
                batch      = [bool](& $get $blk 'batch' $false)
                elevate    = [bool](& $get $blk 'elevate' $false)
                reactivate = [bool](& $get $blk 'reactivate' $false)
                feeds      = $feeds
            }
        })
    }
    $install = & $get $Tool 'install' $null
    if ($install) { $install = [pscustomobject]@{ prefer = [string[]]@(& $get $install 'prefer' @()) } }

    $record = [ordered]@{
        name        = $Tool.name
        executable  = $Tool.executable
        type        = & $get $Tool 'type' 'exe'
        description = & $get $Tool 'description' ''
        tags        = [object[]]@(& $get $Tool 'tags' @())
        packages    = & $get $Tool 'packages' $null
        xdg         = $xdg
        env         = & $get $Tool 'env' $null
        aliases     = $aliases
        picker      = $picker
        after       = [object[]]@(& $get $Tool 'after' @())
        requires    = [object[]]@(& $get $Tool 'requires' @())
        roles       = [pscustomobject]$roles
        themeMap    = & $get $Tool 'themeMap' $null
        settings    = & $get $Tool 'settings' $null
        executableExclude = [object[]]@(& $get $Tool 'executableExclude' @())
        prewarm     = [bool](& $get $Tool 'prewarm' $true)
        setup       = $setup
        installs    = $installs
        install     = $install
    }
    # Keep fields DotForge doesn't model, so tool authors can carry extra data.
    foreach ($p in $Tool.PSObject.Properties) {
        if (-not $record.Contains($p.Name) -and $p.Name -ne 'role') { $record[$p.Name] = $p.Value }
    }
    [pscustomobject]$record
}