StructMatcher.psm1

<#
.SYNOPSIS
    StructMatcher - Evaluate declarative rule sets against structured data.

.DESCRIPTION
    StructMatcher evaluates one or more rules against structured input data
    and returns the results of all matching rules.

    Supported data formats:
      - Hashtable
      - PSCustomObject
      - JSON text

    Supported rule formats:
      - Rule object
      - Rule collections
      - JSON object
      - JSON array

    Compatible with:
      - Windows PowerShell 5.1
      - PowerShell 7+

.NOTES
    Project : StructMatcher
    Version : 2.0.1
    Author : Peter Kaagman
    License : MIT
    GitHub : https://github.com/peter-kaagman/structmatcher

    Design goals:
      - Data-driven rule evaluation
      - No scriptblock execution
      - Predictable behavior
      - PowerShell 5.1 compatibility
#>

function Get-InfoFromStruct {
    <#
    Traverses a nested object structure (hashtables, PSCustomObjects,
    and arrays) to retrieve the value at the specified path.
    Returns a custom object with properties:
    - Found: Indicates whether the path exists in the structure.
    - Value: The value at the specified path, or $null if not found.
    #>

param (
        [Parameter(Mandatory)]
        [string[]] $path,

        [Parameter(Mandatory)]
        $data
    )

    $current = $data

    foreach ($segment in $path) {
        if ($null -eq $current) {
            return [pscustomobject]@{
                Found = $false
                Value = $null
            }
        }
        if ($current -is [System.Collections.IDictionary]) {
            if (-not $current.Contains($segment)) {
                return [pscustomobject]@{
                    Found = $false
                    Value = $null
                }
            }
            $current = $current[$segment]
            continue
        }
        $property = $current.PSObject.Properties[$segment]
        if ($null -eq $property) {
            return [pscustomobject]@{
                Found = $false
                Value = $null
            }
        }
        $current = $property.Value
    }
    return [pscustomobject]@{
        Found = $true
        Value = $current
    }
}

function ConvertTo-NormalizedArray {
    <#
    Contains, NoContains, In, NotIn operators require the
    found value to be an array. This function normalizes
    the input to an array, handling strings and nested arrays
    appropriately.
    #>


    param($Value)

    if ($null -eq $Value) {
        return ,@()
    }

    if ($Value -is [string]) {
        try {
            $parsed = $null

            if (
                $Value.Trim().StartsWith("[") -and
                $Value.Trim().EndsWith("]")
            ) {
                $parsed = $Value | ConvertFrom-Json -ErrorAction Stop

                # PS7 returns a scalar for single-item JSON arrays.
                # Force the result back into an array.
                $Value = @($parsed)
            }
            else {
                $Value = $Value -split ','
            }
        }
        catch {
            $Value = $Value -split ','
        }
    }
    elseif ($Value -isnot [array]) {
        $Value = @($Value)
    }

    while (
        $Value.Count -eq 1 -and
        $Value[0] -is [array]
    ) {
        $Value = $Value[0]
    }

    return ,$Value
}
function ConvertTo-NormalizedStructure {

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        $InputObject
    )

    # JSON text
    if ($InputObject -is [string]) {

        if ([string]::IsNullOrWhiteSpace($InputObject)){
            throw "Input cannot be an empty string."
        }

        try {
            return $InputObject | ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            throw "Failed to parse JSON input: $($_.Exception.Message)"
        }
    }

    # Hashtable
    if ($InputObject -is [hashtable]) {
        return $InputObject
    }

    # PSCustomObject
    if ($InputObject -is [pscustomobject]) {
        return $InputObject
    }

    # Collection
    if ($InputObject -is [System.Collections.IEnumerable]) {
        return $InputObject
    }

    throw "Unsupported input type [$($InputObject.GetType().FullName)]. Supported types are Hashtable, PSCustomObject, collections and JSON text."
}

function Test-ConditionSet {
param (
        [Parameter(Mandatory)] $rule,
        [Parameter(Mandatory)] $data
    )

    # Ensure rule.result is not null
    if ($null -eq $rule.result) {
        throw "Rule.result can't be null. This is reserved to indicate that no conditions were met."
    }
    # Default to true, so that if no conditions are specified, the rule is considered met.
    $allMet = $true
    foreach ($condition in $rule.conditions) {
        # ensure path exists
        if ( [string]::IsNullOrWhitespace($condition.path)) {
            throw "Condition is missing a 'path' property."
        }
        # ensure path is an array
        $path = @($condition.path -split '\.')
        $check = $condition.check
        # If no operator is specified, default to Equals
        $operator = if ($condition.operator) { 
            $condition.operator 
        } else { 
            "Equals" 
        }
        # missingOk default to true if not specified
        $missingOk = if ($null -ne $condition.missingOk) {
            $condition.missingOk
        } else {
            $true
        }
        $result = Get-InfoFromStruct -path $path -data $data
        $value = $result.Value
        $isFound = $result.Found
        $conditionMet = 
        switch ($operator) {
            # Positive operators => node should exist and match the condition
            # missingOk is ignored for positive operators
            "Equals"         { ($isFound) -and ( $value -eq $check) }
            "Contains"       { ($isFound) -and ( (ConvertTo-NormalizedArray $value) -contains $check)}
            "In"             { ($isFound) -and ( $value -in (ConvertTo-NormalizedArray $check)) }
            "Like"           { ($isFound) -and ( $value -like $check) }
            "Match"          { ($isFound) -and ( $value -match $check) }
            "GreaterThan"    { ($isFound) -and ( $value -gt $check) }
            "LessThan"       { ($isFound) -and ( $value -lt $check) }
            "GreaterOrEqual" { ($isFound) -and ( $value -ge $check) }
            "LessOrEqual"    { ($isFound) -and ( $value -le $check) }

            # Negative operators => result depends on missingOk flag
            "NotEquals" {
                if ($missingOk) { 
                    (-not $isFound) -or ($value -ne $check) }
                else { 
                    $isFound -and ($value -ne $check) }
            }
            "NotContains" {
                if ($missingOk) { 
                    (-not $isFound) -or ((ConvertTo-NormalizedArray $value) -notcontains $check) }
                else { 
                    $isFound -and ((ConvertTo-NormalizedArray $value) -notcontains $check) }
            }
            "NotIn" {
                if ($missingOk) { 
                    (-not $isFound) -or ($value -notin (ConvertTo-NormalizedArray $check)) }
                else { 
                    $isFound -and ($value -notin (ConvertTo-NormalizedArray $check)) }
            }
            "NotLike" {
                if ($missingOk) { 
                    (-not $isFound) -or ($value -notlike $check) }
                else { 
                    $isFound -and ($value -notlike $check) }
            }
            "NotMatch" {
                if ($missingOk) { 
                    (-not $isFound) -or ($value -notmatch $check) }
                else { 
                    $isFound -and ($value -notmatch $check) }
            }
            default { 
                throw "Unsupported operator [$operator]. Supported operators are: Equals, NotEquals, Contains, NotContains, In, NotIn, Like, NotLike, Match, NotMatch, GreaterThan, LessThan, GreaterOrEqual, LessOrEqual."
            }
        }
        # Doing an AND so bail out if any condition fails
        if (-not $conditionMet) {
            $allMet = $false
            break
        }
    }
    if ($allMet) {
        return $rule.result
    }
    return $null
}

function Test-RulePropertyName {
param(
        [Parameter(Mandatory)] $Rules
    )

    $validRuleProperties = @( 'conditions', 'result')

    $validConditionProperties = @( 'path', 'operator', 'check', 'missingOk')
    foreach ($rule in @($rules)) {

        $rulePropertyNames =
        if ($rule -is [System.Collections.IDictionary]) {
            @($rule.Keys)
        } else {
            @($rule.PSObject.Properties.Name)
        }

        $unknownRuleProperties =
        $rulePropertyNames |
        Where-Object { $_ -notin $validRuleProperties }

        if ($unknownRuleProperties) {
            throw "Unknown rule properties: $($unknownRuleProperties -join ', ')"
        }

        foreach ($condition in @($rule.conditions)) {

            $conditionPropertyNames =
            if ($condition -is [System.Collections.IDictionary]) {
                @($condition.Keys)
            } else {
                @($condition.PSObject.Properties.Name)
            }

            $unknownConditionProperties =
            $conditionPropertyNames |
            Where-Object { $_ -notin $validConditionProperties }

            if ($unknownConditionProperties) {
                throw "Unknown condition properties: $($unknownConditionProperties -join ', ')"
            }
        }
    }
}

function Invoke-StructMatcher {
param (
        [Parameter(Mandatory)] $rules,
        [Parameter(Mandatory)] $data
    )
    # Convert JSON input to a traverseable PowerShell structure.
    # Existing dictionaries, objects, and collections are retained.
    $rules = ConvertTo-NormalizedStructure $rules
    if ($null -eq $rules) {
        return @()
    }
    $data = ConvertTo-NormalizedStructure $data

    #Check if rule property names are valid
    Test-RulePropertyName -rules $rules

    # Keep matching results in insertion order while preventing duplicates.
    $result = [System.Collections.Generic.List[object]]::new()
    foreach ($rule in $rules) {
        $reply = Test-ConditionSet -rule $rule -data $data
        if ($null -ne $reply -and -not $result.Contains($reply)) {
            [void]$result.Add($reply)
        }
    }
    return $result.ToArray()
}

# Public API
Export-ModuleMember -Function Invoke-StructMatcher