Modules/DotForge.Catalog/Private/DFReleaseData.ps1

#Requires -Version 7.0

# "Release data": a JSON file that ships in data/, can be superseded by a newer
# copy downloaded from the latest DotForge release into <XDG data>\dotforge\,
# is schema-validated, falls back to the shipped copy if the downloaded one is
# unusable, and warns once per session if neither loads. tool-categories.json
# and tool-identities.json are both release data; each has its own validator
# (Test-DFCategoryDbSchema, Test-DFToolIdentityGuideSchema) and its own index
# builder (Get-DFCategoryDb, Get-DFToolIdentityGuide), and shares everything
# else here.

$script:DFReleaseDataWarned = @{}

function Invoke-DFReleaseAssetDownload {
    <#
    .SYNOPSIS
        Downloads and parses one JSON asset from the latest DotForge release.
    .DESCRIPTION
        The network seam for release data, so tests mock this one function.
    .PARAMETER FileName
        The asset name, e.g. 'tool-categories.json'.
    .OUTPUTS
        PSCustomObject. The parsed JSON.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory, Position = 0)][string]$FileName)
    Invoke-RestMethod -Uri "https://github.com/simsrw73/DotForge/releases/latest/download/$FileName" -TimeoutSec 15
}

function Read-DFReleaseData {
    <#
    .SYNOPSIS
        Loads a release data file: the downloaded copy if it is newer and valid, otherwise the shipped one.
    .DESCRIPTION
        The downloaded copy (<XDG data>\dotforge\<FileName>) is used when its
        "updated" date is later than the shipped copy's. A downloaded copy that
        fails to parse or validate falls back to the shipped copy. When neither
        loads, warns once per session per file and returns $null. Never throws.
    .PARAMETER FileName
        The data file name, e.g. 'tool-categories.json'.
    .PARAMETER ShippedPath
        The shipped copy. Default: data\<FileName> in the module.
    .PARAMETER Validator
        Name of the schema validator: a command taking -Database and -Errors ([ref]).
    .PARAMETER Label
        Human name for messages, e.g. 'category database'.
    .PARAMETER UnavailableMessage
        The once-per-session warning when nothing loads.
    .OUTPUTS
        PSCustomObject (the raw document), or $null.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FileName,
        [string]$ShippedPath = (Join-Path $PSScriptRoot '..' '..' '..' 'data' $FileName),   # Modules/DotForge.Catalog/Private -> repo root
        [Parameter(Mandatory)][string]$Validator,
        [Parameter(Mandatory)][string]$Label,
        [Parameter(Mandatory)][string]$UnavailableMessage
    )

    $tryLoad = {
        param($p)
        if (-not (Test-Path $p)) { return $null }
        try {
            $doc = Get-Content $p -Raw | ConvertFrom-Json
            $errs = $null
            if (& $Validator -Database $doc -Errors ([ref]$errs)) { return $doc }
            Write-Verbose "DotForge: $Label at '$p' failed schema validation: $($errs -join '; ')"
        } catch {
            Write-Verbose "DotForge: unreadable $Label '$p': $_"
        }
        $null
    }

    # A downloaded copy wins only when it's newer than the shipped one; if it
    # then turns out unusable, fall back to the shipped copy.
    $downloaded = Join-Path (Get-DFXdgPath Data) 'dotforge' $FileName
    $raw = $null
    if (Test-Path $downloaded) {
        try {
            $shippedUpdated = (Test-Path $ShippedPath) ? (Get-Content $ShippedPath -Raw | ConvertFrom-Json).updated : $null
            $downloadedUpdated = (Get-Content $downloaded -Raw | ConvertFrom-Json).updated
            if (-not $shippedUpdated -or [datetime]$downloadedUpdated -gt [datetime]$shippedUpdated) {
                $raw = & $tryLoad $downloaded
            }
        } catch {
            Write-Verbose "DotForge: unreadable downloaded $Label '$downloaded', using shipped: $_"
        }
    }
    if (-not $raw) { $raw = & $tryLoad $ShippedPath }

    if (-not $raw -and -not $script:DFReleaseDataWarned[$FileName]) {
        Write-Warning "DotForge: $UnavailableMessage"
        $script:DFReleaseDataWarned[$FileName] = $true
    }
    $raw
}

function Update-DFReleaseData {
    <#
    .SYNOPSIS
        Downloads the latest copy of a release data file, validates it, and writes it atomically.
    .DESCRIPTION
        Shared body of Update-DFCategoryDb and Update-DFToolIdentityGuide. A
        failed download or validation warns and leaves any existing copy
        untouched. Asks the calling cmdlet's ShouldProcess, so -WhatIf and
        -Confirm on the public command apply.
    .PARAMETER FileName
        The asset and file name, e.g. 'tool-categories.json'.
    .PARAMETER Validator
        Name of the schema validator: a command taking -Database and -Errors ([ref]).
    .PARAMETER Label
        Human name for messages, e.g. 'category database'.
    .PARAMETER Cmdlet
        The public cmdlet's $PSCmdlet, for ShouldProcess.
    .OUTPUTS
        None. Writes one line to the host on success.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FileName,
        [Parameter(Mandatory)][string]$Validator,
        [Parameter(Mandatory)][string]$Label,
        [Parameter(Mandatory)][System.Management.Automation.PSCmdlet]$Cmdlet
    )
    try {
        $doc = Invoke-DFReleaseAssetDownload $FileName
    } catch {
        Write-Warning "DotForge: failed to download $Label`: $_"
        return
    }
    $errs = $null
    if (-not (& $Validator -Database $doc -Errors ([ref]$errs))) {
        Write-Warning "DotForge: downloaded $Label failed validation, keeping the existing copy: $($errs -join '; ')"
        return
    }
    $destPath = Join-Path (Get-DFXdgPath Data) 'dotforge' $FileName
    if ($Cmdlet.ShouldProcess($destPath, "Update $Label")) {
        Write-DFFileAtomic -Path $destPath -Value ($doc | ConvertTo-Json -Depth 8)
        Write-Host "Updated $Label at $destPath ($(@($doc.tools.PSObject.Properties.Name).Count) tools)."
    }
}