Modules/DotForge.Catalog/Private/DFCatalog.Base.ps1

#Requires -Version 7.0

# The catalog provider registry. Each Private/DFCatalog.<Stem>.ps1 registers one
# provider with Register-DFCatalogProvider when it is loaded, and everything that
# needs "which catalogs exist, in what order" asks this registry (Get-DFCatalogName)
# instead of keeping its own list.
#
# Load order: DotForge.psm1 dot-sources Private/*.ps1 alphabetically, and this
# file's name sorts before every DFCatalog.<Stem>.ps1, so the registry exists when
# they register. Invoke-DFCatalogInstalledFetch loads this file first in its
# runspaces for the same reason.

if (-not (Get-Variable -Name DFCatalogProviders -Scope Script -ErrorAction Ignore)) { $script:DFCatalogProviders = @{} }

function Register-DFCatalogProvider {
    <#
    .SYNOPSIS
        Adds a catalog provider to the registry, binding its hooks by naming convention.
    .DESCRIPTION
        A provider defined in DFCatalog.<Stem>.ps1 implements
        Search-DFCatalog<Stem> (-Query, -Fresh), Get-DFCatalog<Stem>Installed and
        Get-DFCatalog<Stem>Detail (-PackageId, -Fresh). This builds the
        provider record's hooks from those names, so a provider file only states
        what is particular to it. -Test, -Refresh and -Detail override the
        defaults: Test is "always available", and Refresh re-runs the search
        live (the query-cache behavior).

        The record also lists the files the provider needs (its own plus
        -ExtraFiles), which Invoke-DFCatalogInstalledFetch dot-sources in its
        parallel runspaces.
    .PARAMETER Name
        The catalog name users type with -Source, e.g. 'choco'.
    .PARAMETER Kind
        'query-cache' (web API, per-query cache) or 'snapshot' (local index).
    .PARAMETER Order
        Position in the canonical catalog order; lower comes first.
    .PARAMETER SourceFile
        The provider's own file; pass $PSCommandPath.
    .PARAMETER ExtraFiles
        Other Private files the provider's functions need, by file name.
    .PARAMETER Test
        Availability probe. Default: always available.
    .PARAMETER Refresh
        Re-warm hook, param($Query). Default: re-run the search with -Fresh.
    .PARAMETER Detail
        Detail hook, param($PackageId, $Fresh). Default: Get-DFCatalog<Stem>Detail.
    .OUTPUTS
        None.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][ValidateSet('query-cache', 'snapshot')][string]$Kind,
        [Parameter(Mandatory)][int]$Order,
        [Parameter(Mandatory)][string]$SourceFile,
        [string[]]$ExtraFiles = @(),
        [scriptblock]$Test = { $true },
        [scriptblock]$Refresh,
        [scriptblock]$Detail
    )
    $stem      = [IO.Path]::GetFileNameWithoutExtension($SourceFile) -replace '^DFCatalog\.'
    $search    = "Search-DFCatalog$stem"
    $installed = "Get-DFCatalog${stem}Installed"
    # Built from strings so they bind where they're invoked (module scope), like
    # the literal scriptblocks they replace, and reach the private functions.
    $script:DFCatalogProviders[$Name] = @{
        Name              = $Name
        Kind              = $Kind
        Order             = $Order
        # This file first, so a runspace that loads only these files can register.
        Files             = @('DFCatalog.Base.ps1', (Split-Path $SourceFile -Leaf)) + $ExtraFiles
        InstalledFunction = $installed
        Test              = $Test
        Search            = [scriptblock]::Create("param(`$Query, `$Fresh) $search -Query `$Query -Fresh:`$Fresh")
        GetInstalled      = [scriptblock]::Create($installed)
        Refresh           = $Refresh ?? [scriptblock]::Create("param(`$Query) if (`$Query) { `$null = $search -Query `$Query -Fresh }")
        Detail            = $Detail ?? [scriptblock]::Create("param(`$PackageId, `$Fresh) Get-DFCatalog${stem}Detail -PackageId `$PackageId -Fresh:`$Fresh")
    }
}

function ConvertTo-DFCatalogSource {
    <#
    .SYNOPSIS
        Normalizes a packages-block key or catalog name to the catalog source name.
    .DESCRIPTION
        Tool records key packages by source (scoop, winget, choco, npm, crates,
        psgallery), the same names the catalog providers use, so this only
        lowercases. Every identity index built from a packages block goes
        through it, so the two vocabularies can't drift apart again.
    .PARAMETER Name
        The packages-block key or catalog name.
    .EXAMPLE
        ConvertTo-DFCatalogSource Crates

        Returns 'crates'.
    .OUTPUTS
        System.String.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][string]$Name)
    $Name.ToLowerInvariant()
}

function Get-DFIdentityKeys {
    <#
    .SYNOPSIS
        Returns the identity-map keys to try for one catalog package, most specific first.
    .DESCRIPTION
        Keys are 'source:packageid', lowercased. Scoop ids are bucket-qualified
        at runtime ('main/fd') while tool records and the identity guide store
        bare names ('fd'), so a scoop id also yields the bare-name key. Only
        scoop: in other catalogs a '/' is part of the name (npm's '@scope/pkg'),
        and stripping it would match a different package.
    .PARAMETER Source
        Catalog name.
    .PARAMETER PackageId
        The package id as that catalog reports it.
    .OUTPUTS
        System.String[].
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$PackageId
    )
    "$Source`:$PackageId".ToLowerInvariant()
    if ($Source -eq 'scoop' -and $PackageId.Contains('/')) {
        "scoop:$(($PackageId -split '/')[-1])".ToLowerInvariant()
    }
}

function Get-DFCatalogName {
    <#
    .SYNOPSIS
        Returns the registered catalog names in canonical order.
    .DESCRIPTION
        The one list of catalogs: parameter validation, tab completion, the
        qualified source:id syntax and every per-catalog loop use it. A record
        without an Order (a test stand-in) sorts last, by name.
    .OUTPUTS
        System.String[].
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param()
    @($script:DFCatalogProviders.Values |
        Sort-Object { $o = $_['Order']; if ($null -eq $o) { [int]::MaxValue } else { $o } }, { $_['Name'] } |
        ForEach-Object { $_['Name'] })
}