Private/Resolve-AppConfig.ps1

#Requires -Version 5.1
<#
.SYNOPSIS
    Merges catalog app definitions with environment app references into a flat resolved array.
 
.DESCRIPTION
    Accepts the raw config PSCustomObject (from ConvertFrom-Json) and an environment
    name string. Looks up the environment definition, iterates its apps array, and for
    each app reference resolves the final per-app properties by merging the catalog
    entry with any environment-level overrides.
 
    Merge rules:
      - Name = app name (catalog key)
      - Path = catalog.path
      - ProcessName = catalog.processName
      - Arguments = environment override if the 'arguments' property is present on
                      the env app reference (even if empty string), else catalog.arguments,
                      else '' (empty string). The check uses PSObject.Properties to
                      distinguish a present-but-empty-string override from an absent property.
      - SkipMaximize = catalog.skipMaximize if present, else $false
      - Priority = environment app ref priority if present (integer), else 0
 
    Apps referenced in the environment but missing from the catalog are skipped with
    an ERROR log entry. They are not included in the returned array. Processing
    continues for remaining apps.
 
    Throws a terminating error if the requested environment name is not found in the
    config. This is a terminal condition -- the caller must validate the environment
    name before calling this function.
 
.PARAMETER Config
    The parsed config object returned by ConvertFrom-Json. Must have .apps and
    .environments properties matching the Envoke config schema.
 
.PARAMETER EnvironmentName
    The name of the environment to resolve. Must match a key in $Config.environments.
    Case-sensitive (PSCustomObject property access is case-sensitive on lookup).
 
.OUTPUTS
    PSCustomObject[]
    Array of resolved app objects. Each object has: Name, Path, ProcessName,
    Arguments, SkipMaximize, Priority. Empty array if the environment has no apps
    or all references were missing from the catalog.
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
 
    The PSObject.Properties[$appName] access pattern is required for ConvertFrom-Json
    output. Direct dot notation ($Config.apps.$appName) returns $null for missing keys
    in most cases, but PSObject.Properties provides a reliable existence check.
 
    The 'arguments' override rule uses PSObject.Properties['arguments'] to distinguish:
      - Property absent (no override key in JSON) -> fall back to catalog.arguments
      - Property present with empty string ("") -> use override (replaces catalog)
    This matches the config schema description: "Replaces (does not append to) the
    catalog default."
#>


# PSUseOutputTypeCorrectly: the unary comma operator forces a PowerShell Object[] wrapper
# around the List.ToArray() result to prevent single-element unboxing through the pipeline.
# The element type is always PSCustomObject; the Object[] container is an implementation detail.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
param ()

function Resolve-AppConfig {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject]$Config,

        [Parameter(Mandatory)]
        [string]$EnvironmentName
    )

    # Guard: environment must exist in config.
    $envDef = $Config.environments.$EnvironmentName
    if ($null -eq $envDef) {
        throw "Resolve-AppConfig: Environment '$EnvironmentName' not found in config. Available environments: $($Config.environments.PSObject.Properties.Name -join ', ')"
    }

    # Use List[PSCustomObject] to avoid O(n^2) array concatenation.
    $resolved = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($appRef in $envDef.apps) {
        $appName = $appRef.name

        # Look up the catalog entry by name using PSObject.Properties for reliable
        # existence checking on ConvertFrom-Json PSCustomObject output.
        $catalogProp = $Config.apps.PSObject.Properties[$appName]

        if ($null -eq $catalogProp) {
            Write-EnvkLog -Level 'ERROR' -Message "Resolve-AppConfig: app '$appName' not found in catalog -- skipping"
            continue
        }

        $catalog = $catalogProp.Value

        # Arguments override rule:
        # - If the env app reference has an 'arguments' property (even if empty string): use it.
        # - Otherwise: use catalog.arguments if present, else '' (empty string).
        $argsProp = $appRef.PSObject.Properties['arguments']
        if ($null -ne $argsProp) {
            $arguments = $argsProp.Value
        }
        elseif ($null -ne $catalog.arguments) {
            $arguments = $catalog.arguments
        }
        else {
            $arguments = ''
        }

        # SkipMaximize defaults to $false when omitted from the catalog entry.
        $skipMaximizeProp = $catalog.PSObject.Properties['skipMaximize']
        $skipMaximize = if ($null -ne $skipMaximizeProp) { [bool]$skipMaximizeProp.Value } else { $false }

        # Priority defaults to 0 when omitted from the environment app reference.
        # Uses PSObject.Properties to distinguish absent property from present-zero.
        $priorityProp = $appRef.PSObject.Properties['priority']
        $priority = if ($null -ne $priorityProp) { [int]$priorityProp.Value } else { 0 }

        $resolved.Add([PSCustomObject]@{
            Name         = $appName
            Path         = $catalog.path
            ProcessName  = $catalog.processName
            Arguments    = $arguments
            SkipMaximize = $skipMaximize
            Priority     = $priority
        })
    }

    # Use the unary comma operator to force a PowerShell array return even when
    # $resolved contains a single element. Without this, PowerShell unwraps a
    # single-element ToArray() result from the pipeline, returning a bare
    # PSCustomObject instead of PSCustomObject[]. Callers that check .Count
    # against an integer require a true array return.
    # Note: the unary comma forces an Object[] wrapper; PSUseOutputTypeCorrectly
    # reports this as Information but the element type is always PSCustomObject.
    return , $resolved.ToArray()
}