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() } |