handlers/qconf/config-helpers.ps1

#Requires -Version 7.0

# Helpers used by the qconf entry types (appsettings, dotenv and user-secrets templates).
# Map scriptblocks run in module scope, so these names are also part of the documented
# map contract (skills/qbuild-templates/SKILL.md, samples/dotenv). Keep the names.

function get-appsettings(
    [Parameter(Mandatory = $true, ParameterSetName = "file")][string]$file,
    [Parameter(Mandatory = $true, ParameterSetName = "settings")][object]$appSettings,
    $path = ""
) {
    return get-appsetting -file:$file -appSettings:$appSettings -path:$path
}

function Get-AppSetting(
    [Parameter(Mandatory = $false)][string]$file,
    [Parameter(Mandatory = $false)][object]$appSettings,
    [Parameter(Mandatory = $true)][string] $path
) {
    try {
        if (!$appSettings) {
            if (!$file) {
                throw "Either appSettings or file (or both) must be provided"
            }
            if (!(Test-Path $file)) {
                throw "File not found: $file"
            }
            $appSettings = get-content $file | convertfrom-json
        }

        $json = $appSettings

        $components = $path.split(":")
        $node = $json
        foreach ($component in $components) {
            if (!$component) {
                continue
            }

            $node = $node.$component
        }

        return $node
    }
    catch {
        Write-Error "Failed to get app settings from $file at path '$path': $($_.Exception.Message)"
        throw
    }
}

function set-appsettings(
    [Parameter(Mandatory = $true)] $file,
    [Parameter(Mandatory = $true)] $path,
    [Parameter(Mandatory = $true)] $value
) {
    try {
        if (!(Test-Path $file)) {
            throw "File not found: $file"
        }
        
        $json = get-content $file | convertfrom-json -AsHashtable

        $components = $path.split(":", [System.StringSplitOptions]::RemoveEmptyEntries)
        $node = $json
        for ($i = 0; $i -lt $components.Count - 1; $i++) {
            $component = $components[$i]
            if ($node.$component -eq $null) {
                $node.$component = @{}
            }
            $node = $node.$component
        }
        $leaf = $components[$components.Count - 1]
        
        $node.$($leaf) = $value
        $json | convertto-json -Depth 100 | set-content $file
    }
    catch {
        Write-Error "Failed to set app settings in $file at path '$path': $($_.Exception.Message)"
        throw
    }
}

function test-isSpecialValue($v) {
    if (!$v) { return $false }
    if ($v -is [string]) {
        return $v.StartsWith("user-secrets:") -or $v.StartsWith("keyvault:")
    }
    return $false
}

function Get-AppsettingsObject(
    [Parameter(Mandatory = $true, ParameterSetName = "file")][string]$file,
    [Parameter(Mandatory = $true, ParameterSetName = "settings")][object]$appSettings,
    [Parameter(Mandatory = $true)]$options
) {
    return Find-ActiveOption -File:$file -AppSettings:$appSettings -Options $options
}

function Find-ActiveOption(
    [Parameter(Mandatory = $true, ParameterSetName = "file")][string]$file,
    [Parameter(Mandatory = $true, ParameterSetName = "settings")][object]$appSettings,
    [Parameter(Mandatory = $true)]$options
) {
    if (!$appSettings) {
        $appSettings = get-content $file | convertfrom-json -AsHashtable
    }

    $keys = new-object System.Collections.Generic.HashSet[string]
    $active = $null
    $valueObject = [ordered]@{}

    foreach ($optionSet in $options.keys) {
        $option = $options.$optionSet
        $isMatch = $true
        foreach ($key in $option.keys) {
            if ($key.StartsWith("__")) {
                continue
            }
            if (!$valueObject.Contains($key)) {
                $keys.Add($key) | Out-Null
                $currentValue = Get-AppSetting -appSettings:$appSettings -file:$file -path:$key
                $valueObject[$key] = $currentValue
            } else {
                $currentValue = $valueObject[$key]
            }

            $optionValue = $option.$key

            Write-Verbose "checking key $key=$currentValue"

            # Keep walking after a mismatch so Value still contains every key read from the file.
            if ($isMatch -and $currentValue -is [string] -and $currentValue -ne $optionValue -and !(test-isSpecialValue $optionValue)) {
                Write-Verbose "key $key=$currentValue does not match option '$optionSet' value: $optionValue"
                $isMatch = $false
            }
        }
        if ($isMatch) {
            $active = $optionSet
            break
        }
    }

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

function resolve-value($v, [switch]$recurse, [string]$dir, [hashtable]$secrets) {
    if (!$v) { return $v }

    if ($v -is [string]) {
        if (!(test-isSpecialValue $v)) { return $v }

        if ($v.StartsWith("user-secrets:")) {
            $secretName = $v.Substring("user-secrets:".Length)

            if ($null -eq $secrets) {
                $secrets = get-user-secrets $dir
                if ($LASTEXITCODE -ne 0) {
                    write-warning "cannot set '$secretName': failed to list user secrets for project in '$dir'. Do you have 'dotnet user-secrets' installed?"
                    return $v
                }
            }
            if (!$secrets.ContainsKey($secretName)) {
                write-warning "secret '$secretName' not found in user secrets. Please run 'dotnet user-secrets -p $dir set $secretName <value>' to set the secret."
                return $v
            }
            $v = $secrets[$secretName]

            return $v
        }

        if ($v.StartsWith("keyvault:")) {
            $splits = $v.Substring("keyvault:".Length).Split("/")
            $v = get-keyvaultSecret $splits[0] $splits[1]

            return $v
        }
    }
    elseif ($v -is [hashtable]) {
        if ($recurse) {
            $result = [ordered]@{}
            foreach ($key in $v.keys) {
                $result[$key] = resolve-value $v[$key] -recurse:$recurse -dir $dir -secrets $secrets
            }
            return $result
        }
        return $v
    }

    return $v
}

function get-user-secrets($dir) {
    $secrets = @{}
    
    try {
        $output = dotnet user-secrets list --project $dir 2>&1
        if ($LASTEXITCODE -ne 0) {
            return $secrets
        }
        
        foreach ($line in $output) {
            if ($line -match "^(.+?)\s*=\s*(.*)$") {
                $secrets[$matches[1]] = $matches[2]
            }
        }
    }
    catch {
        Write-Warning "Failed to retrieve user secrets: $($_.Exception.Message)"
    }
    
    return $secrets
}

function get-keyvaultSecret($vaultName, $secretName) {
    try {
        $secret = az keyvault secret show --vault-name $vaultName --name $secretName --query value -o tsv 2>$null
        if ($LASTEXITCODE -eq 0) {
            return $secret
        }
        else {
            Write-Warning "Failed to retrieve secret '$secretName' from vault '$vaultName'"
            return $null
        }
    }
    catch {
        Write-Warning "Error accessing Key Vault: $($_.Exception.Message)"
        return $null
    }
}

function get-envsetting($key, $envFile = ".env") {
    if (!(Test-Path $envFile)) {
        return $null
    }
    
    $content = Get-Content $envFile
    foreach ($line in $content) {
        if ($line -match "^$key\s*=\s*(.*)$") {
            return $matches[1].Trim('"')
        }
    }
    return $null
}

function set-envsetting($key, $value, $envFile = ".env") {
    $lines = @()
    $found = $false
    
    if (Test-Path $envFile) {
        # Get-Content returns a string for a one-line file. Force an array so
        # index assignment updates that line instead of concatenating onto it.
        $lines = @(Get-Content $envFile)
    }
    
    for ($i = 0; $i -lt $lines.Count; $i++) {
        if ($lines[$i] -match "^$key\s*=") {
            $lines[$i] = "$key=$value"
            $found = $true
            break
        }
    }
    
    if (!$found) {
        $lines += "$key=$value"
    }
    
    $lines | Set-Content $envFile
}

function get-envsettingsObject($envFile = ".env") {
    $settings = @{}
    
    if (!(Test-Path $envFile)) {
        return $settings
    }
    
    $content = Get-Content $envFile
    foreach ($line in $content) {
        if ($line -match "^([^=]+)\s*=\s*(.*)$") {
            $key = $matches[1].Trim()
            $value = $matches[2].Trim('"')
            $settings[$key] = $value
        }
    }
    
    return $settings
}

function set-envsettingsObject($settings, $envFile = ".env") {
    $lines = @()
    
    foreach ($key in $settings.Keys) {
        $lines += "$key=$($settings[$key])"
    }
    
    $lines | Set-Content $envFile
}