Private/Test-IdleCondition.ps1

function Test-IdleCondition {
    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Context', Justification = 'Used for path resolution within nested helper functions.')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable] $Condition,

        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [object] $Context
    )

    # Evaluates a declarative Condition (data-only) against the provided context.
    #
    # Supported schema (validated by Test-IdleConditionSchema):
    # - Groups: All | Any | None (each contains an array/list of condition nodes)
    # - Operators:
    # - Equals = @{ Path = '<path>'; Value = <value> }
    # - NotEquals = @{ Path = '<path>'; Value = <value> }
    # - Exists = '<path>' OR @{ Path = '<path>' }
    # - In = @{ Path = '<path>'; Values = <array|scalar> }
    # - Contains = @{ Path = '<path>'; Value = <value> }
    # - NotContains = @{ Path = '<path>'; Value = <value> }
    # - Like = @{ Path = '<path>'; Pattern = <pattern> }
    # - NotLike = @{ Path = '<path>'; Pattern = <pattern> }
    #
    # Paths are resolved via Get-IdleValueByPath against the provided $Context.
    # For readability in configuration, a leading "context." prefix is ignored.

    $schemaErrors = Test-IdleConditionSchema -Condition $Condition -StepName $null
    if (@($schemaErrors).Count -gt 0) {
        $msg = "Condition schema validation failed: {0}" -f ([string]::Join(' ', @($schemaErrors)))
        throw [System.ArgumentException]::new($msg, 'Condition')
    }

    function Resolve-IdleConditionPathValue {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNullOrEmpty()]
            [string] $Path
        )

        # Allow "context." prefix for readability in config files.
        $effectivePath = if ($Path.StartsWith('context.')) { $Path.Substring(8) } else { $Path }

        return Get-IdleValueByPath -Object $Context -Path $effectivePath
    }

    function Test-IdleConditionNode {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [ValidateNotNull()]
            [System.Collections.IDictionary] $Node
        )

        # GROUPS
        if ($Node.Contains('All')) {
            foreach ($child in @($Node.All)) {
                if (-not (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child))) {
                    return $false
                }
            }
            return $true
        }

        if ($Node.Contains('Any')) {
            foreach ($child in @($Node.Any)) {
                if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) {
                    return $true
                }
            }
            return $false
        }

        if ($Node.Contains('None')) {
            foreach ($child in @($Node.None)) {
                if (Test-IdleConditionNode -Node ([System.Collections.IDictionary]$child)) {
                    return $false
                }
            }
            return $true
        }

        # OPERATORS
        if ($Node.Contains('Equals')) {
            $op = $Node.Equals

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $expected = $op.Value

            # Stable semantics: compare as strings (keeps config predictable across providers/types).
            return ([string]$actual -eq [string]$expected)
        }

        if ($Node.Contains('NotEquals')) {
            $op = $Node.NotEquals

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $expected = $op.Value

            return ([string]$actual -ne [string]$expected)
        }

        if ($Node.Contains('Exists')) {
            $existsVal = $Node.Exists

            $path = if ($existsVal -is [string]) {
                [string]$existsVal
            } else {
                [string]$existsVal.Path
            }

            $value = Resolve-IdleConditionPathValue -Path $path
            return ($null -ne $value)
        }

        if ($Node.Contains('In')) {
            $op = $Node.In

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $values = $op.Values

            if ($null -eq $values) {
                return $false
            }

            # Treat scalar and array uniformly.
            $candidates = if ($values -is [System.Collections.IEnumerable] -and -not ($values -is [string])) {
                @($values)
            } else {
                @($values)
            }

            foreach ($candidate in $candidates) {
                if ([string]$actual -eq [string]$candidate) {
                    return $true
                }
            }

            return $false
        }

        if ($Node.Contains('Contains')) {
            $op = $Node.Contains

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $expected = $op.Value

            # Contains requires the resolved path to be a list.
            if ($null -eq $actual) {
                return $false
            }

            # Reject dictionaries/hashtables explicitly (they implement IEnumerable but are not lists).
            if ($actual -is [System.Collections.IDictionary]) {
                throw [System.ArgumentException]::new(
                    ("Contains operator requires Path to resolve to a list/array, not a hashtable/dictionary."),
                    'Condition'
                )
            }

            if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) {
                throw [System.ArgumentException]::new(
                    ("Contains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name),
                    'Condition'
                )
            }

            # Check if any element in the list matches the expected value (case-insensitive).
            foreach ($item in @($actual)) {
                if ([string]$item -eq [string]$expected) {
                    return $true
                }
            }

            return $false
        }

        if ($Node.Contains('NotContains')) {
            $op = $Node.NotContains

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $expected = $op.Value

            # NotContains requires the resolved path to be a list.
            if ($null -eq $actual) {
                return $true
            }

            # Reject dictionaries/hashtables explicitly (they implement IEnumerable but are not lists).
            if ($actual -is [System.Collections.IDictionary]) {
                throw [System.ArgumentException]::new(
                    ("NotContains operator requires Path to resolve to a list/array, not a hashtable/dictionary."),
                    'Condition'
                )
            }

            if (-not ($actual -is [System.Collections.IEnumerable]) -or ($actual -is [string])) {
                throw [System.ArgumentException]::new(
                    ("NotContains operator requires Path to resolve to a list, but got '{0}'." -f $actual.GetType().Name),
                    'Condition'
                )
            }

            # Check if no element in the list matches the expected value (case-insensitive).
            foreach ($item in @($actual)) {
                if ([string]$item -eq [string]$expected) {
                    return $false
                }
            }

            return $true
        }

        if ($Node.Contains('Like')) {
            $op = $Node.Like

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $pattern = $op.Pattern

            if ($null -eq $actual) {
                return $false
            }

            # Reject dictionaries/hashtables explicitly to avoid ambiguous iteration over keys/entries.
            if ($actual -is [System.Collections.IDictionary]) {
                throw [System.ArgumentException]::new(
                    ("Like operator cannot evaluate a hashtable/dictionary. Use a list/array or scalar value."),
                    'Condition'
                )
            }

            # If the value is a list, return true if ANY element matches the pattern.
            if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) {
                foreach ($item in @($actual)) {
                    if ([string]$item -like [string]$pattern) {
                        return $true
                    }
                }
                return $false
            }

            # Scalar: direct pattern match (case-insensitive by default).
            return ([string]$actual -like [string]$pattern)
        }

        if ($Node.Contains('NotLike')) {
            $op = $Node.NotLike

            $actual = Resolve-IdleConditionPathValue -Path ([string]$op.Path)
            $pattern = $op.Pattern

            if ($null -eq $actual) {
                return $true
            }

            # Reject dictionaries/hashtables explicitly to avoid ambiguous iteration over keys/entries.
            if ($actual -is [System.Collections.IDictionary]) {
                throw [System.ArgumentException]::new(
                    ("NotLike operator cannot evaluate a hashtable/dictionary. Use a list/array or scalar value."),
                    'Condition'
                )
            }

            # If the value is a list, return true if NO element matches the pattern.
            if (($actual -is [System.Collections.IEnumerable]) -and -not ($actual -is [string])) {
                foreach ($item in @($actual)) {
                    if ([string]$item -like [string]$pattern) {
                        return $false
                    }
                }
                return $true
            }

            # Scalar: direct pattern non-match (case-insensitive by default).
            return ([string]$actual -notlike [string]$pattern)
        }

        # Should never happen due to schema validation.
        return $false
    }

    return (Test-IdleConditionNode -Node $Condition)
}