handlers/qconf/entry-types.ps1

#requires -version 7.0

function Get-ConfigEntryTemplate {
    param(
        $Entry
    )

    if ($Entry -isnot [System.Collections.IDictionary]) { return $null }
    if (-not $Entry.Contains('_template')) { return $null }

    $template = $Entry._template
    if ($template -is [System.Collections.IDictionary]) {
        return $template
    }

    if ($template -is [string]) {
        # Short form: "appsettings:{filepath}" → @{ type = 'appconfig'; file = ... }
        if ($template -match '^(?i)appsettings:(.+)$') {
            $file = $Matches[1].Trim()
            if ([string]::IsNullOrWhiteSpace($file)) {
                throw "Config entry template 'appsettings:' requires a file path."
            }
            return @{
                type = 'appconfig'
                file = $file
            }
        }

        # Short form: "dotenv:{filepath}" → @{ type = 'dotenv'; file = ... }
        if ($template -match '^(?i)dotenv:(.+)$') {
            $file = $Matches[1].Trim()
            if ([string]::IsNullOrWhiteSpace($file)) {
                throw "Config entry template 'dotenv:' requires a file path."
            }
            return @{
                type = 'dotenv'
                file = $file
            }
        }

        # Short form: "user-secrets:{csproj}" → @{ type = 'user-secrets'; csproj = ... }
        if ($template -match '^(?i)user-secrets:(.*)$') {
            $csproj = $Matches[1].Trim()
            if ([string]::IsNullOrWhiteSpace($csproj)) {
                throw "Config entry template 'user-secrets:' requires a csproj path."
            }
            return @{
                type   = 'user-secrets'
                csproj = $csproj
            }
        }

        throw "Unknown config entry template '$template'."
    }

    return $null
}

function Resolve-ConfigEntryFile {
    param(
        $Entry,
        [Parameter(Mandatory = $true)]
        [System.Collections.IDictionary]$Template,
        [Parameter(Mandatory = $true)]
        [string]$TypeName
    )

    $key = if ($TypeName -eq 'user-secrets') { 'csproj' } else { 'file' }
    $file = $Template[$key]
    if ($file -isnot [string] -or [string]::IsNullOrWhiteSpace($file)) {
        throw "Config entry type '$TypeName' requires a $key path."
    }

    if ([System.IO.Path]::IsPathRooted($file)) {
        return $file
    }

    $baseDir = if ($Entry._baseDir) { [string]$Entry._baseDir } else { (Get-Location).Path }
    return [System.IO.Path]::GetFullPath((Join-Path $baseDir $file))
}

function Get-ConfigEntryTemplateFile {
    param(
        [Parameter(Mandatory = $true)]
        $Entry,
        [Parameter(Mandatory = $true)]
        [string]$TypeName
    )

    $template = Get-ConfigEntryTemplate -Entry $Entry
    if ($null -eq $template) {
        throw "Config entry has no _template."
    }

    return Resolve-ConfigEntryFile -Entry $Entry -Template $template -TypeName $TypeName
}

function Find-MatchingOption {
    <#
    .SYNOPSIS
        Find the first option whose keys all match the current values.
 
    .PARAMETER GetValue
        Reads the current value of a key; returns $null when it is not set.
 
    .PARAMETER GetExpected
        Maps an option value to the value to compare against; $null means it cannot match.
    #>

    param(
        [Parameter(Mandatory = $true)]
        $Options,
        [Parameter(Mandatory = $true)]
        [scriptblock]$GetValue,
        [scriptblock]$GetExpected = { param($optionValue) $optionValue }
    )

    $active = $null
    $valueObject = [ordered]@{}

    foreach ($optionSet in $Options.Keys) {
        $option = $Options[$optionSet]
        if ($option -isnot [System.Collections.IDictionary]) { continue }

        $isMatch = $true
        foreach ($key in @($option.Keys)) {
            if ("$key".StartsWith('__')) { continue }

            if (-not $valueObject.Contains($key)) {
                $valueObject[$key] = & $GetValue $key
            }
            $currentValue = $valueObject[$key]
            $expectedValue = & $GetExpected $option[$key]

            if ($null -eq $currentValue) {
                $isMatch = $false
                continue
            }

            if ($isMatch -and $currentValue -is [string] -and $currentValue -ne $expectedValue -and -not (test-isSpecialValue $expectedValue)) {
                $isMatch = $false
            }
        }

        if ($isMatch) {
            $active = $optionSet
            break
        }
    }

    return @{ Value = $valueObject; Active = $active }
}

function Assert-UserSecretsProject {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Csproj
    )

    # `dotnet user-secrets --project` accepts a project file or the directory containing one.
    if (-not (Test-Path -LiteralPath $Csproj)) {
        throw "user-secrets csproj not found: $Csproj"
    }
}

function Get-ProjectUserSecret {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Csproj
    )

    $secrets = get-user-secrets $Csproj
    if ($LASTEXITCODE -ne 0) {
        throw "Failed to list user secrets for '$Csproj'. Does it define a UserSecretsId, and is 'dotnet user-secrets' available?"
    }

    return $secrets
}

function Find-ActiveSecretOption {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Csproj,
        [Parameter(Mandatory = $true)]
        $Options
    )

    Assert-UserSecretsProject -Csproj $Csproj
    $secrets = Get-ProjectUserSecret -Csproj $Csproj

    return Find-MatchingOption -Options $Options -GetValue { param($key) $secrets[$key] } -GetExpected {
        param($optionValue)

        if (-not "$optionValue".StartsWith('user-secrets:')) { return $optionValue }

        $resolved = resolve-value $optionValue -secrets $secrets 3>$null
        # Still a reference means the secret is missing, so this option cannot be active.
        if (test-isSpecialValue $resolved) { return $null }
        return $resolved
    }
}

function Set-UserSecretOption {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Csproj,
        $Values
    )

    if ($Values -isnot [System.Collections.IDictionary]) {
        throw "user-secrets option value must be a hashtable of secret keys."
    }

    Assert-UserSecretsProject -Csproj $Csproj

    # Listing spawns dotnet, so skip it unless a value references another secret.
    $hasReference = @($Values.Values | Where-Object { "$_".StartsWith('user-secrets:') }).Count -gt 0
    $secrets = if ($hasReference) { Get-ProjectUserSecret -Csproj $Csproj } else { @{} }

    # Resolve everything first so a missing reference writes nothing.
    $resolved = [ordered]@{}
    foreach ($key in @($Values.Keys)) {
        if ("$key".StartsWith('__')) { continue }

        $secretValue = resolve-value $Values[$key] -secrets $secrets 3>$null
        if ($null -eq $secretValue -or (test-isSpecialValue $secretValue)) {
            throw "Cannot set user secret '$key': '$($Values[$key])' was not found for '$Csproj'."
        }
        $resolved[$key] = $secretValue
    }

    # JSON on stdin merges every key into the store with a single dotnet spawn.
    # dotnet decodes stdin with the console code page (Windows) or locale charset (Unix),
    # so escape non-ASCII to keep the bytes identical under any of them.
    $null = $resolved | ConvertTo-Json -Compress -EscapeHandling EscapeNonAscii | dotnet user-secrets set --project $Csproj 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "Failed to set user secrets for '$Csproj'."
    }
}

function Set-AppConfigOption {
    param(
        [Parameter(Mandatory = $true)]
        [string]$File,
        $Values
    )

    if ($Values -isnot [System.Collections.IDictionary]) {
        throw "appconfig option value must be a hashtable of setting keys."
    }

    $directory = Split-Path -Parent $File
    foreach ($key in @($Values.Keys)) {
        if ("$key".StartsWith('__')) { continue }

        $settingValue = resolve-value $Values[$key] -dir $directory
        set-appsettings -file $File -path $key -value $settingValue
    }
}

function Find-ActiveEnvOption {
    param(
        [Parameter(Mandatory = $true)]
        [string]$File,
        [Parameter(Mandatory = $true)]
        $Options
    )

    return Find-MatchingOption -Options $Options -GetValue { param($key) get-envsetting $key $File }
}

function Set-DotEnvOption {
    param(
        [Parameter(Mandatory = $true)]
        [string]$File,
        $Values
    )

    if ($Values -isnot [System.Collections.IDictionary]) {
        throw "dotenv option value must be a hashtable of environment variable keys."
    }

    $directory = Split-Path -Parent $File
    foreach ($key in @($Values.Keys)) {
        if ("$key".StartsWith('__')) { continue }

        $settingValue = resolve-value $Values[$key] -dir $directory
        set-envsetting $key $settingValue $File
    }
}

function ConvertFrom-DotEnvLine {
    <#
    .SYNOPSIS
        Parse one dotenv line into @{ Key; Value }, or $null for blanks, comments and non-assignments.
    #>

    param([string]$Line)

    if ($Line -notmatch '^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*?)\s*$') { return $null }

    $key = $Matches[1]
    $value = $Matches[2]
    if ($value -match '^"(.*)"$' -or $value -match "^'(.*)'$") {
        $value = $Matches[1]
    }
    else {
        # An unquoted value ends at an inline comment.
        $value = ($value -replace '\s+#.*$', '')
    }

    return @{ Key = $key; Value = $value }
}

function Read-DotEnvOptionValues {
    param(
        [Parameter(Mandatory = $true)]
        [string]$File
    )

    $values = [ordered]@{}
    foreach ($line in @(Get-Content -LiteralPath $File)) {
        $parsed = ConvertFrom-DotEnvLine $line
        if ($parsed) { $values[$parsed.Key] = $parsed.Value }
    }
    return $values
}

function Get-DotEnvEntryOptions {
    <#
    .SYNOPSIS
        Derive options from the dotenv files that sit next to a dotenv _template entry's file.
 
    .DESCRIPTION
        For `_template = "dotenv:.env"` every sibling `.env.<name>` becomes option `<name>`.
        A `.example`, `.sample` or `.template` suffix is ignored (`.env.dev.example` is option
        `dev`); a bare `.env.example` becomes option `example`. When both `.env.dev` and
        `.env.dev.example` exist, the real file wins. Returns $null when the entry is not a
        dotenv template or nothing usable is found, so explicit `options` are never replaced.
    #>

    param(
        $Entry
    )

    if ($Entry -isnot [System.Collections.IDictionary] -or -not $Entry.Contains('_template')) { return $null }

    try {
        $template = Get-ConfigEntryTemplate -Entry $Entry
        if ($null -eq $template -or $template.type -ne 'dotenv') { return $null }
        $target = Resolve-ConfigEntryFile -Entry $Entry -Template $template -TypeName 'dotenv'
    }
    catch {
        Write-Verbose "dotenv option discovery skipped: $($_.Exception.Message)"
        return $null
    }

    $directory = Split-Path -Parent $target
    $leaf = Split-Path -Leaf $target
    if (-not (Test-Path -LiteralPath $directory -PathType Container)) { return $null }

    $templateSuffix = '\.(example|sample|template)$'
    $candidates = Get-ChildItem -LiteralPath $directory -File -Force |
        Where-Object { $_.Name -ne $leaf -and $_.Name.StartsWith("$leaf.", [System.StringComparison]::OrdinalIgnoreCase) } |
        Sort-Object { $_.Name -match $templateSuffix }, Name

    $options = [ordered]@{}
    foreach ($file in $candidates) {
        $name = $file.Name.Substring($leaf.Length + 1) -replace $templateSuffix, ''
        if ($options.Contains($name)) { continue }

        $values = Read-DotEnvOptionValues -File $file.FullName
        if ($values.Count -gt 0) { $options[$name] = $values }
    }

    if ($options.Count -eq 0) { return $null }
    return $options
}

function Read-DotEnvKeyInfo {
    <#
    .SYNOPSIS
        Read a dotenv file as key -> @{ Value; Description }, where Description is the
        comment line(s) directly above the key.
    #>

    param(
        [Parameter(Mandatory = $true)]
        [string]$File
    )

    $info = [ordered]@{}
    $comments = [System.Collections.Generic.List[string]]::new()
    foreach ($line in @(Get-Content -LiteralPath $File)) {
        if ($line -match '^\s*#\s?(.*)$') {
            $comments.Add($Matches[1].Trim())
            continue
        }

        $parsed = ConvertFrom-DotEnvLine $line
        if ($parsed) {
            $info[$parsed.Key] = @{ Value = $parsed.Value; Description = ($comments -join ' ') }
        }
        $comments.Clear()
    }
    return $info
}

function Resolve-DotEnvKeysFile {
    param(
        $Keys,
        [Parameter(Mandatory = $true)]
        [string]$Target
    )

    $directory = Split-Path -Parent $Target
    if ($Keys -is [string] -and $Keys -ne 'auto') {
        $candidates = @([System.IO.Path]::IsPathRooted($Keys) ? $Keys : (Join-Path $directory $Keys))
    }
    else {
        $candidates = 'example', 'sample', 'template' | ForEach-Object { "$Target.$_" }
    }

    return $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
}

function New-DotEnvKeyEntry {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Key,
        [Parameter(Mandatory = $true)]
        $Info,
        [Parameter(Mandatory = $true)]
        [string]$File,
        [string]$BaseDir
    )

    # No _template here: it would make the child discover the parent's .env.<name> presets as options.
    $description = $Info.Description ? $Info.Description : "$Key"
    $entry = [ordered]@{
        description = "$description (example: $($Info.Value))"
        _internal   = @{ envKey = $Key; envFile = $File }
        get         = {
            param($ctx, $options, $_context)
            get-envsetting $_context.self._internal.envKey $_context.self._internal.envFile
        }
        set         = {
            param($ctx, $key, $value, $_context)
            set-envsetting $_context.self._internal.envKey $key $_context.self._internal.envFile
        }
    }
    if ($BaseDir) { $entry._baseDir = $BaseDir }
    return $entry
}

function Expand-DotEnvKeyEntries {
    <#
    .SYNOPSIS
        Prepare dotenv _template entries when a map loads: attach lazily discovered
        options and add one child entry per key for entries that set `keys`.
 
    .DESCRIPTION
        `keys = 'auto'` reads `<file>.example` (or `.sample` / `.template`); any other string
        names the file to read keys from. Children are named after the key (`api.API_URL`),
        read and write just that key, and never replace an entry the map already defines.
        Explicit `options` are left alone; otherwise `options` becomes a scriptblock that
        reads the sibling `.env.*` files when asked, so completion sees file changes.
    #>

    param(
        [System.Collections.IDictionary]$Map
    )

    foreach ($name in @($Map.Keys)) {
        $entry = $Map[$name]
        if ($entry -isnot [System.Collections.IDictionary] -or "$name".StartsWith('_') -or $name -eq 'options') { continue }

        $template = $null
        try { $template = Get-ConfigEntryTemplate -Entry $entry } catch { Write-Verbose "dotenv key discovery skipped: $($_.Exception.Message)" }
        if ($template -and $template.type -eq 'dotenv' -and -not $entry.Contains('options')) {
            # A closure runs outside this module's scope, so it must carry the command object itself.
            $presetEntry = $entry
            $discover = Get-Command Get-DotEnvEntryOptions
            $entry.options = { $discovered = & $discover -Entry $presetEntry; $discovered ? $discovered : [ordered]@{} }.GetNewClosure()
        }
        if ($template -and $template.type -eq 'dotenv' -and $template.Contains('keys') -and $template.keys) {
            $target = Resolve-ConfigEntryFile -Entry $entry -Template $template -TypeName 'dotenv'
            $keysFile = Resolve-DotEnvKeysFile -Keys $template.keys -Target $target
            if ($keysFile) {
                $info = Read-DotEnvKeyInfo -File $keysFile
                foreach ($key in $info.Keys) {
                    if ($entry.Contains($key)) { continue }
                    $entry[$key] = New-DotEnvKeyEntry -Key $key -Info $info[$key] -File $target -BaseDir $entry._baseDir
                }
            }
        }

        Expand-DotEnvKeyEntries -Map $entry
    }
}

function Get-ConfigEntryTypeCommands {
    <#
    .SYNOPSIS
        Built-in get/set implementations for Entry._template types.
    #>

    return . "$PSScriptRoot\entry-types.map.ps1"
}

function Test-ConfigEntrySensitive {
    <#
    .SYNOPSIS
        True when the entry's _template type keeps values that `list` must not read.
    #>

    param(
        $Entry
    )

    $template = Get-ConfigEntryTemplate -Entry $Entry
    if ($null -eq $template -or -not $template.type) { return $false }

    $commands = (Get-ConfigEntryTypeCommands)[[string]$template.type]
    return [bool]($commands -and $commands.sensitive)
}

function Add-ConfigEntryTypeCommands {
    <#
    .SYNOPSIS
        Fill in get and set from Entry._template when the map did not supply them.
 
    .DESCRIPTION
        Accepts `_template` as a hashtable (`type`/`file`, or `type`/`csproj` for
        `user-secrets`) or short string (`appsettings:{filepath}`, `dotenv:{filepath}`
        or `user-secrets:{csproj}`). Leaves an author-supplied
        get or set in place. Always stashes the template get/set on
        Entry._internal.base so overrides can call them via $_context.base.
    #>

    param(
        $Entry
    )

    if ($Entry -isnot [System.Collections.IDictionary]) { return }

    $template = Get-ConfigEntryTemplate -Entry $Entry
    if ($null -eq $template -or -not $template.Contains('type') -or -not $template.type) { return }

    $typeName = [string]$template.type
    $typeCommands = Get-ConfigEntryTypeCommands
    if (-not $typeCommands.Contains($typeName)) {
        throw "Unknown config entry type '$typeName'."
    }

    $null = Resolve-ConfigEntryFile -Entry $Entry -Template $template -TypeName $typeName
    $commands = $typeCommands[$typeName]

    if (-not $Entry.Contains('_internal') -or $Entry._internal -isnot [System.Collections.IDictionary]) {
        $Entry._internal = @{}
    }
    $Entry._internal.base = @{
        get = $commands.get
        set = $commands.set
    }

    if ($Entry.get -isnot [scriptblock]) {
        $Entry.get = $commands.get
    }
    if ($Entry.set -isnot [scriptblock]) {
        $Entry.set = $commands.set
    }
}