Private/Test-StartupConfig.ps1

function Test-StartupConfig {
    <#
    .SYNOPSIS
        Validates a parsed Envoke config object and returns all validation errors.
 
    .DESCRIPTION
        Performs collect-all validation on a PSCustomObject produced by ConvertFrom-Json.
        All validation issues are accumulated and returned together so the user can fix
        everything in a single pass. No fail-fast behavior.
 
        Validation rules applied:
          - Top-level required sections: apps, environments
          - App catalog: each app requires path and processName (non-empty strings);
            skipMaximize, if present, must be a boolean
          - Environment list: each environment requires an apps array; each app
            reference requires a name that exists in the apps catalog
          - Schedule block (optional): when present, days (array), startHour, and
            endHour are all required; hour values must be integers 0-23; day names
            must be valid weekday strings
          - Unknown top-level keys produce WARNING strings (prefixed with "WARNING: ")
            rather than errors. VS Code schema is the strict guardrail at authoring time.
 
        App path existence is NOT checked here. That is deferred to launch time (Phase 6).
 
    .PARAMETER Config
        The parsed config object returned by ConvertFrom-Json. Mandatory.
 
    .OUTPUTS
        System.String[]
        An array of validation strings. Empty array means the config is valid.
        Error format: "field.path: what is wrong" (JSON-path style)
        Warning format: "WARNING: message" (distinguishable by the WARNING: prefix)
 
    .NOTES
        Author: Aaron AlAnsari
        Created: 2026-02-24
 
        Known valid top-level keys: '$schema', 'apps', 'environments'
        Known valid schedule fields: 'days', 'startHour', 'endHour'
        Known valid day names: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
    #>


    [CmdletBinding()]
    [OutputType([string[]])]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject]$Config
    )

    $errors    = [System.Collections.Generic.List[string]]::new()
    $validDays = @('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday')

    # ---------------------------------------------------------------------------
    # Unknown top-level keys (WARNING, not error)
    # ---------------------------------------------------------------------------

    $knownTopLevelKeys = @('$schema', 'apps', 'environments', 'settings')
    foreach ($key in $Config.PSObject.Properties.Name) {
        if ($knownTopLevelKeys -notcontains $key) {
            $errors.Add("WARNING: Unknown top-level key '$key' - will be ignored")
        }
    }

    # ---------------------------------------------------------------------------
    # Top-level required sections
    # ---------------------------------------------------------------------------

    if ($null -eq $Config.apps) {
        $errors.Add('apps: Required section missing')
    }
    if ($null -eq $Config.environments) {
        $errors.Add('environments: Required section missing')
    }

    # ---------------------------------------------------------------------------
    # App catalog validation
    # ---------------------------------------------------------------------------

    if ($null -ne $Config.apps) {
        $Config.apps.PSObject.Properties | ForEach-Object {
            $appName = $_.Name
            $app     = $_.Value
            $prefix  = "apps.$appName"

            if ([string]::IsNullOrWhiteSpace($app.path)) {
                $errors.Add("${prefix}.path: Required field missing or empty")
            }
            if ([string]::IsNullOrWhiteSpace($app.processName)) {
                $errors.Add("${prefix}.processName: Required field missing or empty")
            }
            if ($null -ne $app.skipMaximize -and $app.skipMaximize -isnot [bool]) {
                $errors.Add("${prefix}.skipMaximize: Must be a boolean (true or false)")
            }
        }
    }

    # ---------------------------------------------------------------------------
    # Environment validation
    # ---------------------------------------------------------------------------

    if ($null -ne $Config.environments) {
        $Config.environments.PSObject.Properties | ForEach-Object {
            $envName = $_.Name
            $env     = $_.Value
            $prefix  = "environments.$envName"

            # Each environment requires an apps array.
            if ($null -eq $env.apps) {
                $errors.Add("${prefix}.apps: Required field missing")
            }
            elseif ($env.apps -isnot [array] -and $env.apps -isnot [System.Object[]]) {
                $errors.Add("${prefix}.apps: Must be an array")
            }
            else {
                $i = 0
                foreach ($ref in @($env.apps)) {
                    $refPrefix = "${prefix}.apps[$i]"

                    if ([string]::IsNullOrWhiteSpace($ref.name)) {
                        $errors.Add("${refPrefix}.name: Required field missing")
                    }
                    elseif ($null -ne $Config.apps -and $null -eq $Config.apps.($ref.name)) {
                        $errors.Add("${refPrefix}.name: '$($ref.name)' not found in apps catalog")
                    }

                    $i++
                }
            }

            # Priority field validation (optional - integer when present).
            if ($null -ne $env.priority -and $env.priority -isnot [int] -and $env.priority -isnot [long]) {
                $errors.Add("${prefix}.priority: Must be an integer")
            }

            # Schedule block validation (optional - omitting means fallback environment).
            if ($null -ne $env.schedule) {
                $sched  = $env.schedule
                $schPfx = "${prefix}.schedule"

                if ($null -eq $sched.days) {
                    $errors.Add("${schPfx}.days: Required field missing")
                }
                elseif ($sched.days -isnot [array] -and $sched.days -isnot [System.Object[]]) {
                    $errors.Add("${schPfx}.days: Must be an array")
                }
                else {
                    $di = 0
                    foreach ($day in @($sched.days)) {
                        if ($validDays -notcontains $day) {
                            $errors.Add("${schPfx}.days[$di]: '$day' is not a valid day name")
                        }
                        $di++
                    }
                }

                if ($null -eq $sched.startHour) {
                    $errors.Add("${schPfx}.startHour: Required field missing")
                }
                elseif (($sched.startHour -isnot [int] -and $sched.startHour -isnot [long]) -or $sched.startHour -lt 0 -or $sched.startHour -gt 23) {
                    $errors.Add("${schPfx}.startHour: Must be an integer between 0 and 23")
                }

                if ($null -eq $sched.endHour) {
                    $errors.Add("${schPfx}.endHour: Required field missing")
                }
                elseif (($sched.endHour -isnot [int] -and $sched.endHour -isnot [long]) -or $sched.endHour -lt 0 -or $sched.endHour -gt 23) {
                    $errors.Add("${schPfx}.endHour: Must be an integer between 0 and 23")
                }
            }
        }
    }

    # ---------------------------------------------------------------------------
    # Cross-environment validation (runs after per-environment loop)
    # ---------------------------------------------------------------------------

    if ($null -ne $Config.environments) {

        # Multiple fallback detection (ERROR).
        # A fallback environment has no schedule block. At most one is allowed.
        $fallbackEnvs = @($Config.environments.PSObject.Properties |
            Where-Object { $null -eq $_.Value.schedule })

        if ($fallbackEnvs.Count -gt 1) {
            $names = ($fallbackEnvs | ForEach-Object { $_.Name }) -join ', '
            $errors.Add("environments: More than one fallback environment (no schedule) found: $names. At most one is allowed.")
        }

        # Schedule overlap detection (WARNING).
        # Two environments overlap when they share a day and their hour ranges intersect.
        $scheduledEnvs = @($Config.environments.PSObject.Properties |
            Where-Object { $null -ne $_.Value.schedule } |
            ForEach-Object { [PSCustomObject]@{ Name = $_.Name; Schedule = $_.Value.schedule } })

        for ($i = 0; $i -lt $scheduledEnvs.Count; $i++) {
            for ($j = $i + 1; $j -lt $scheduledEnvs.Count; $j++) {
                $a          = $scheduledEnvs[$i]
                $b          = $scheduledEnvs[$j]
                $aSchedule  = $a.Schedule
                $bSchedule  = $b.Schedule

                # Only compare when both schedules have valid days arrays.
                if ($null -eq $aSchedule.days -or $null -eq $bSchedule.days) { continue }

                $sharedDays = @($aSchedule.days | Where-Object { $bSchedule.days -contains $_ })

                if ($sharedDays.Count -gt 0) {
                    # Hour overlap: [aStart, aEnd) intersects [bStart, bEnd) when aStart < bEnd AND bStart < aEnd.
                    # Only compare when both have valid integer hour values.
                    $aStart = $aSchedule.startHour
                    $aEnd   = $aSchedule.endHour
                    $bStart = $bSchedule.startHour
                    $bEnd   = $bSchedule.endHour

                    $hoursValid = ($null -ne $aStart -and $null -ne $aEnd -and $null -ne $bStart -and $null -ne $bEnd)

                    if ($hoursValid) {
                        $hoursOverlap = ($aStart -lt $bEnd) -and ($bStart -lt $aEnd)
                        if ($hoursOverlap) {
                            $dayList = $sharedDays -join ', '
                            $errors.Add("WARNING: Schedule overlap between '$($a.Name)' and '$($b.Name)' on days: $dayList. Use priority to resolve.")
                        }
                    }
                }
            }
        }
    }

    return $errors.ToArray()
}