Modules/IdLE.Core/Private/Test-IdleConditionSchema.ps1

function Test-IdleConditionSchema {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable] $Condition,

        [Parameter()]
        [AllowNull()]
        [string] $StepName
    )

    # NOTE:
    # This validator is intentionally strict:
    # - Unknown keys are errors (keeps configuration deterministic and toolable).
    # - A node must be either a group (All/Any/None) OR an operator (Equals/NotEquals/Exists/In).
    # - ScriptBlocks are validated elsewhere (Assert-IdleNoScriptBlock). We assume data-only input here.
    #
    # Supported operator shapes:
    # - Equals = @{ Path = '<path>'; Value = <value> }
    # - NotEquals = @{ Path = '<path>'; Value = <value> }
    # - Exists = '<path>' OR @{ Path = '<path>' }
    # - In = @{ Path = '<path>'; Values = <array|scalar> }

    $errors = [System.Collections.Generic.List[string]]::new()
    $prefix = if ([string]::IsNullOrWhiteSpace($StepName)) { 'Step' } else { "Step '$StepName'" }

    function Add-IdleConditionError {
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $List,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Message
        )

        if ($List -is [System.Collections.Generic.List[string]]) {
            $null = $List.Add($Message)
            return
        }

        if ($List -is [System.Collections.ArrayList]) {
            $null = $List.Add($Message)
            return
        }

        throw [System.InvalidOperationException]::new(
            ("Add-IdleConditionError expected a mutable list type but got '{0}'." -f $List.GetType().FullName)
        )
    }

    function Test-IdleConditionNodeSchema {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [object] $Node,

            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $NodePath
        )

        $nodeErrors = [System.Collections.Generic.List[string]]::new()

        if (-not ($Node -is [System.Collections.IDictionary])) {
            Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be a hashtable/dictionary." -f $NodePath)
            return ,$nodeErrors
        }

        $allowedGroupKeys = @('All', 'Any', 'None')
        $allowedOpKeys    = @('Equals', 'NotEquals', 'Exists', 'In')
        $allowedKeys      = @($allowedGroupKeys + $allowedOpKeys)

        $presentGroupKeys = @($allowedGroupKeys | Where-Object { $Node.Contains($_) })
        $presentOpKeys    = @($allowedOpKeys | Where-Object { $Node.Contains($_) })

        # Enforce: either group OR operator, never both.
        if ($presentGroupKeys.Count -gt 0 -and $presentOpKeys.Count -gt 0) {
            Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must be either a group (All/Any/None) or an operator (Equals/NotEquals/Exists/In), not both." -f $NodePath)
            return ,$nodeErrors
        }

        # Enforce: at least one recognized key.
        if ($presentGroupKeys.Count -eq 0 -and $presentOpKeys.Count -eq 0) {
            Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify one group (All/Any/None) or one operator (Equals/NotEquals/Exists/In)." -f $NodePath)
            return ,$nodeErrors
        }

        # Enforce: exactly one key at this level (avoids ambiguous evaluation).
        if (($presentGroupKeys.Count + $presentOpKeys.Count) -ne 1) {
            Add-IdleConditionError -List $nodeErrors -Message ("{0}: Condition node must specify exactly one group/operator key." -f $NodePath)
            return ,$nodeErrors
        }

        # Unknown keys are errors.
        foreach ($k in @($Node.Keys)) {
            if ($allowedKeys -notcontains [string]$k) {
                Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}' in condition node." -f $NodePath, [string]$k)
            }
        }

        if ($nodeErrors.Count -gt 0) {
            return ,$nodeErrors
        }

        # GROUP: All/Any/None must be a non-empty array/list of condition nodes.
        if ($presentGroupKeys.Count -eq 1) {
            $groupKey = [string]$presentGroupKeys[0]
            $children = $Node[$groupKey]
            $groupPath = ("{0}.{1}" -f $NodePath, $groupKey)

            if ($null -eq $children) {
                Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must not be null and must contain at least one condition." -f $groupPath)
                return ,$nodeErrors
            }

            if (-not ($children -is [System.Collections.IEnumerable]) -or ($children -is [string])) {
                Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group value must be an array/list of condition nodes." -f $groupPath)
                return ,$nodeErrors
            }

            $i = 0
            $count = 0
            foreach ($child in @($children)) {
                $count++
                foreach ($e in (Test-IdleConditionNodeSchema -Node $child -NodePath ("{0}[{1}]" -f $groupPath, $i))) {
                    Add-IdleConditionError -List $nodeErrors -Message $e
                }
                $i++
            }

            if ($count -lt 1) {
                Add-IdleConditionError -List $nodeErrors -Message ("{0}: Group must contain at least one condition node." -f $groupPath)
            }

            return ,$nodeErrors
        }

        # OPERATOR: Exactly one of Equals/NotEquals/Exists/In.
        $opKey  = [string]$presentOpKeys[0]
        $opVal  = $Node[$opKey]
        $opPath = ("{0}.{1}" -f $NodePath, $opKey)

        switch ($opKey) {
            'Equals' {
                if (-not ($opVal -is [System.Collections.IDictionary])) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Equals must be a hashtable with keys Path and Value." -f $opPath)
                    return ,$nodeErrors
                }

                foreach ($k in @($opVal.Keys)) {
                    if (@('Path', 'Value') -notcontains [string]$k) {
                        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k)
                    }
                }

                if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath)
                }

                if (-not $opVal.Contains('Value')) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath)
                }

                return ,$nodeErrors
            }

            'NotEquals' {
                if (-not ($opVal -is [System.Collections.IDictionary])) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: NotEquals must be a hashtable with keys Path and Value." -f $opPath)
                    return ,$nodeErrors
                }

                foreach ($k in @($opVal.Keys)) {
                    if (@('Path', 'Value') -notcontains [string]$k) {
                        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Value." -f $opPath, [string]$k)
                    }
                }

                if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath)
                }

                if (-not $opVal.Contains('Value')) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Value." -f $opPath)
                }

                return ,$nodeErrors
            }

            'Exists' {
                # Exists operator supports two forms:
                # Exists = 'context.Attributes.mail'
                # Exists = @{ Path = 'context.Attributes.mail' }
                if ($opVal -is [string]) {
                    if ([string]::IsNullOrWhiteSpace([string]$opVal)) {
                        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists path must be a non-empty string." -f $opPath)
                    }
                    return ,$nodeErrors
                }

                if (-not ($opVal -is [System.Collections.IDictionary])) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Exists must be a string path or a hashtable with key Path." -f $opPath)
                    return ,$nodeErrors
                }

                foreach ($k in @($opVal.Keys)) {
                    if (@('Path') -notcontains [string]$k) {
                        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path." -f $opPath, [string]$k)
                    }
                }

                if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath)
                }

                return ,$nodeErrors
            }

            'In' {
                # In operator:
                # In = @{ Path = 'context.Identity.Type'; Values = @('Joiner','Mover') }
                if (-not ($opVal -is [System.Collections.IDictionary])) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: In must be a hashtable with keys Path and Values." -f $opPath)
                    return ,$nodeErrors
                }

                foreach ($k in @($opVal.Keys)) {
                    if (@('Path', 'Values') -notcontains [string]$k) {
                        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unknown key '{1}'. Allowed: Path, Values." -f $opPath, [string]$k)
                    }
                }

                if (-not $opVal.Contains('Path') -or [string]::IsNullOrWhiteSpace([string]$opVal.Path)) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing or empty Path." -f $opPath)
                }

                if (-not $opVal.Contains('Values')) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Missing Values." -f $opPath)
                    return ,$nodeErrors
                }

                $values = $opVal.Values
                if ($null -eq $values) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must not be null." -f $opPath)
                    return ,$nodeErrors
                }

                # Values should be list/array (or scalar) but must not be a dictionary (ambiguous).
                if ($values -is [System.Collections.IDictionary]) {
                    Add-IdleConditionError -List $nodeErrors -Message ("{0}: Values must be a list/array (or scalar), not a dictionary." -f $opPath)
                }

                return ,$nodeErrors
            }
        }

        Add-IdleConditionError -List $nodeErrors -Message ("{0}: Unsupported operator '{1}'." -f $NodePath, $opKey)
        return ,$nodeErrors
    }

    foreach ($e in (Test-IdleConditionNodeSchema -Node $Condition -NodePath ("{0}: Condition" -f $prefix))) {
        Add-IdleConditionError -List $errors -Message $e
    }

    return ,$errors
}