Private/Assets/Read-AvmAssetConfig.ps1

function Read-AvmAssetConfig {
    <#
    .SYNOPSIS
        Load, merge, and validate the Avm pinned-asset configuration.

    .DESCRIPTION
        Resolves the pinned-asset config that applies to a given on-disk
        location by combining (in increasing precedence):

          1. The per-user config at <Get-AvmFolder Config>/avm.config.json,
             when present.
          2. The nearest .avm/config.json found by walking upward from
             -Path (mirrors Read-AvmContextOverride's directory walk).

        The merge is per-asset: when both layers declare an asset of the
        same name, the per-repo descriptor wins outright (no deep merge).
        Assets unique to either layer pass through unchanged. The result
        is validated against Test-AvmAssetConfig; schema violations are
        rethrown as AvmConfigurationException carrying file context.

        Both files are JSON (ConvertFrom-Json -AsHashtable). When neither
        file is present, returns an empty assets map and an empty source
        map (no exception). The returned shape is:

            [pscustomobject]@{
              Assets = [ordered]@{ <name> = [pscustomobject] @{ Source = ...; Ref = ...; ... } ; ... }
              Sources = [ordered]@{ <name> = '<full-path-to-config-file>' ; ... }
            }

        Each descriptor pscustomobject has properties (PascalCase to match
        wider module convention): Source, Ref, Sha256, Path, Type. Missing
        optional fields surface as $null.

    .PARAMETER Path
        A directory (or file) to begin the upward walk from. Required.
        Pass the repo root, the working directory, or the path of a module
        under inspection; the function will find the first ancestor that
        contains a .avm/config.json.

    .PARAMETER AllowFileUrls
        Forwarded to Test-AvmAssetConfig. When set, source URLs may also
        start with file:// (used by fixture configs).
    #>

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

        [switch] $AllowFileUrls
    )

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

    $userConfigPath = $null
    $userAssets = $null
    try {
        $configDir = Get-AvmFolder -Kind Config -NoCreate
        $candidate = Join-Path $configDir 'avm.config.json'
        if (Test-Path -LiteralPath $candidate -PathType Leaf) {
            $userConfigPath = (Resolve-Path -LiteralPath $candidate).ProviderPath
            $userAssets = Read-AvmAssetConfigFile -ConfigPath $userConfigPath -AllowFileUrls:$AllowFileUrls
        }
    }
    catch [AvmConfigurationException] {
        throw
    }
    catch {
        # Folder resolution should not be fatal when no per-user config
        # has ever been written; only the parse path uses
        # AvmConfigurationException and that is rethrown above.
        Write-Verbose "Read-AvmAssetConfig: per-user config not loaded: $($_.Exception.Message)"
    }

    $repoConfigPath = $null
    $repoAssets = $null
    $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop
    $dir = $resolved.ProviderPath
    if ((Get-Item -LiteralPath $dir).PSIsContainer -eq $false) {
        $dir = Split-Path -Parent $dir
    }

    while ($dir) {
        $candidate = Join-Path (Join-Path $dir '.avm') 'config.json'
        if (Test-Path -LiteralPath $candidate -PathType Leaf) {
            $repoConfigPath = (Resolve-Path -LiteralPath $candidate).ProviderPath
            $repoAssets = Read-AvmAssetConfigFile -ConfigPath $repoConfigPath -AllowFileUrls:$AllowFileUrls
            break
        }
        $parent = Split-Path -Parent $dir
        if (-not $parent -or $parent -eq $dir) { break }
        $dir = $parent
    }

    $mergedAssets = [ordered]@{}
    $mergedSources = [ordered]@{}

    if ($null -ne $userAssets) {
        foreach ($name in $userAssets.Keys) {
            $mergedAssets[$name] = $userAssets[$name]
            $mergedSources[$name] = $userConfigPath
        }
    }
    if ($null -ne $repoAssets) {
        foreach ($name in $repoAssets.Keys) {
            $mergedAssets[$name] = $repoAssets[$name]
            $mergedSources[$name] = $repoConfigPath
        }
    }

    # Re-validate the merged shape so that per-asset overrides cannot
    # accidentally introduce an invalid descriptor that neither layer
    # produced on its own.
    if ($mergedAssets.Count -gt 0) {
        $whole = @{ schemaVersion = 1; assets = $mergedAssets }
        try {
            Test-AvmAssetConfig -Config $whole -AllowFileUrls:$AllowFileUrls | Out-Null
        }
        catch [System.Data.DataException] {
            throw [AvmConfigurationException]::new(
                "Read-AvmAssetConfig: merged config failed validation: $($_.Exception.Message)",
                $_.Exception)
        }
    }

    $assetObjects = [ordered]@{}
    foreach ($name in $mergedAssets.Keys) {
        $d = $mergedAssets[$name]
        $assetObjects[$name] = [pscustomobject][ordered]@{
            Source = [string]$d.source
            Ref    = if ($d.ContainsKey('ref')) { [string]$d.ref } else { $null }
            Sha256 = if ($d.ContainsKey('sha256')) { [string]$d.sha256 } else { $null }
            Path   = if ($d.ContainsKey('path')) { [string]$d.path } else { $null }
            Type   = if ($d.ContainsKey('type')) { [string]$d.type } else { $null }
        }
    }

    return [pscustomobject][ordered]@{
        Assets  = $assetObjects
        Sources = $mergedSources
    }
}

function Read-AvmAssetConfigFile {
    <#
    .SYNOPSIS
        Internal: parse and validate one avm.config.json file.

    .DESCRIPTION
        Loads $ConfigPath as JSON (ConvertFrom-Json -AsHashtable),
        validates it with Test-AvmAssetConfig, and returns the validated
        assets hashtable. Wraps any failure in AvmConfigurationException
        with the file path prefixed for diagnostics.
    #>

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

        [switch] $AllowFileUrls
    )

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

    try {
        $raw = Get-Content -LiteralPath $ConfigPath -Raw -Encoding utf8
    }
    catch {
        throw [AvmConfigurationException]::new(
            "${ConfigPath}: unable to read: $($_.Exception.Message)",
            $_.Exception)
    }

    try {
        $parsed = $raw | ConvertFrom-Json -AsHashtable -Depth 16
    }
    catch {
        throw [AvmConfigurationException]::new(
            "${ConfigPath}: invalid JSON: $($_.Exception.Message)",
            $_.Exception)
    }

    if ($parsed -isnot [System.Collections.IDictionary]) {
        throw [AvmConfigurationException]::new(
            "${ConfigPath}: top-level value must be a JSON object.")
    }

    try {
        Test-AvmAssetConfig -Config $parsed -AllowFileUrls:$AllowFileUrls | Out-Null
    }
    catch [System.Data.DataException] {
        throw [AvmConfigurationException]::new(
            "${ConfigPath}: $($_.Exception.Message)",
            $_.Exception)
    }

    if (-not $parsed.Contains('assets')) {
        return @{}
    }
    return $parsed.assets
}