#Requires -Version 7.2 <# .SYNOPSIS Validates, tests, and interactively edits a Persona Engine configuration. .DESCRIPTION Two jobs in one tool, deliberately: the thing that checks a configuration and the thing that edits it must agree about what "valid" means, and the surest way to guarantee that is to make them the same code path. -ValidateOnly report findings and exit. Never enters the editor. -NonInteractive pipeline mode. Never prompts, never hangs (SC-010). (neither) interactive editor, re-validating before every save. -NonInteractive is not a convenience flag. A build agent runs with stdin closed; a tool that prompts there does not fail, it hangs until the job times out, and the pipeline reports an infrastructure problem instead of a bad configuration. Every prompting call in this script sits behind a check for it. Rule testing against synthetic users (-TestDataPath) runs the real rule engine with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer it gives offline is the answer it gives in production. .PARAMETER ConfigPath Configuration to validate or edit. .PARAMETER ValidateOnly Validate and report; do not enter the editor. .PARAMETER NonInteractive Pipeline mode: no prompts, exit code only. .PARAMETER SchemaPath Override the shipped schema. .PARAMETER OutputPath Save-As target. Leaves the input file untouched. .PARAMETER TreatWarningsAsErrors Escalate Warning findings to blocking (VR-005). .PARAMETER TestDataPath Synthetic sample users for offline rule testing (FR-025). .PARAMETER PreviousConfigPath Currently deployed configuration, enabling the VR-003 drift checks. .PARAMETER EnforcementEnabled Validate as though this configuration will drive an enforcing run, which raises the severity of several safety findings. .EXAMPLE ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive The pipeline invocation. Returns 0 when the configuration is clean. .EXAMPLE ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData Validate, then evaluate the rules against synthetic users with no tenant. .NOTES Exit codes 0 valid; no blocking findings 1 one or more Error findings 2 Warning findings present with -TreatWarningsAsErrors 3 configuration file not found or unreadable 4 schema file not found or itself invalid #> [CmdletBinding(SupportsShouldProcess = $true)] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ConfigPath, [switch] $ValidateOnly, [switch] $NonInteractive, [string] $SchemaPath, [string] $OutputPath, [switch] $TreatWarningsAsErrors, [string] $TestDataPath, [string] $PreviousConfigPath, [switch] $EnforcementEnabled ) $ErrorActionPreference = 'Stop' # The .psm1 directly, not the manifest. The manifest declares # Microsoft.Graph.Authentication as a required module, and this tool never touches # Graph - SC-008 requires full validation and synthetic rule testing to complete with # no network access. Importing the manifest would make a build agent that only # validates configuration install a Graph SDK it will never call. Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop $EXIT_OK = 0 $EXIT_FINDINGS = 1 $EXIT_WARNINGS_AS_ERRORS = 2 $EXIT_CONFIG_UNREADABLE = 3 $EXIT_SCHEMA_UNUSABLE = 4 function Invoke-ConfigurationValidation { <# .SYNOPSIS Runs all four layers and maps the result onto an exit code. .DESCRIPTION One function so the interactive save path and the pipeline path cannot disagree. The exit-code mapping lives here rather than at the call sites for the same reason: an exit code is the pipeline's only view of what happened. #> param([string] $Path) $params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled } if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath } if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath } $result = Test-PersonaConfiguration @params $code = $EXIT_OK # Order matters. An unreadable file and an unusable schema are environment # faults, not authoring faults, and a pipeline needs to tell them apart from a # genuinely invalid configuration - otherwise a missing schema file is reported # to the author as "your rules are wrong". if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) { $code = $EXIT_CONFIG_UNREADABLE } elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) { $code = $EXIT_SCHEMA_UNUSABLE } elseif (-not $result.IsValid) { $code = $EXIT_FINDINGS } elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) { # VR-005: warnings block only here. The finding severity is unchanged - the # configuration is not retroactively more broken because a switch was passed; # what changed is the caller's tolerance for it. $code = $EXIT_WARNINGS_AS_ERRORS } [pscustomobject]@{ Result = $result; ExitCode = $code } } function Invoke-SyntheticRuleTest { <# .SYNOPSIS Evaluates the configuration's rules against synthetic users (FR-025). .DESCRIPTION No tenant, no credentials, no network. Reads the fixture files, builds normalized records, and runs the real engine over them. Fixtures declare their membership facets explicitly, including failed ones. A fixture with an unretrieved facet is the only way to see EvaluationError behaviour before it happens against a live directory. #> param( [Parameter(Mandatory)] [object] $Configuration, [Parameter(Mandatory)] [string] $DataPath ) $usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) ` ? (Join-Path $DataPath 'Users/users.json') ` : $DataPath if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) { Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow return } $fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users $membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json' $membershipByFixture = @{} if (Test-Path -LiteralPath $membershipFile -PathType Leaf) { foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) { $params = @{ DirectGroupObjectIds = @($entry.directGroupObjectIds) TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds) DirectoryRoleIds = @($entry.directoryRoleIds) } foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) { if ($entry.PSObject.Properties[$facet] -and $entry.$facet) { $params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true } } foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) { if ($entry.PSObject.Properties[$facet] -and $entry.$facet) { $params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet } } $membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params } } Write-Host '' Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray foreach ($fixture in $fixtures) { $properties = @{} if ($fixture.properties) { foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value } } $record = New-PersonaUserRecord ` -AccountObjectId ([string]$fixture.accountObjectId) ` -UserPrincipalName ([string]$fixture.userPrincipalName) ` -DisplayName ([string]$fixture.displayName) ` -UserType ([string]$fixture.userType) ` -AccountEnabled ([bool]$fixture.accountEnabled) ` -Properties $properties ` -StoredPersona ([string]$fixture.storedPersona) ` -Membership $membershipByFixture[[string]$fixture.fixtureId] $result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules ` -MaxDepth $Configuration.MaxConditionDepth ` -DefaultMembershipMode $Configuration.DefaultMembershipMode $colour = switch ($result.Outcome) { 'Matched' { 'Green' } 'Unclassified' { 'Gray' } 'EvaluationError' { 'Yellow' } } $detail = $result.Outcome -eq 'Matched' ` ? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" ` : [string]$result.EvaluationErrorReason Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f $fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour } Write-Host '' } function Save-PersonaConfiguration { <# .SYNOPSIS Saves an edited configuration, backing up first (FR-026). .DESCRIPTION The save order is the contract, and it is deliberate: 1. Re-validate in full. 2. Refuse on any Error finding. 3. Back up the existing file, or write to -OutputPath instead. 4. Only then replace. Backing up before replacing rather than after is what makes step 4 safe to interrupt. A crash between backup and write leaves the original and a copy; a crash the other way round leaves neither. #> param( [Parameter(Mandatory)] [object] $Document, [Parameter(Mandatory)] [string] $Path, [string] $SaveAs ) $json = $Document | ConvertTo-Json -Depth 32 $temp = [System.IO.Path]::GetTempFileName() try { Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM $check = Invoke-ConfigurationValidation -Path $temp if (-not $check.Result.IsValid) { Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red Write-PersonaValidationFinding -Findings $check.Result.Findings return $false } } finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } $destination = $SaveAs ? $SaveAs : $Path if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) { $backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) { Write-Host 'Save cancelled.' -ForegroundColor Yellow return $false } Copy-Item -LiteralPath $destination -Destination $backup -Force Write-Host "Backup written: $backup" -ForegroundColor DarkGray } elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) { Write-Host 'Save cancelled.' -ForegroundColor Yellow return $false } Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM Write-Host "Saved: $destination" -ForegroundColor Green $true } function Test-PersonaCandidateEdit { <# .SYNOPSIS Applies a structural edit to a cloned document and re-validates it (FR-030). .DESCRIPTION Deep-clones Document via a JSON round trip (nested objects are reference types, so mutating Document directly would leave a half-applied edit in place if validation then rejected it), runs Apply against the clone, and validates the clone in full - the identical check the `[V]` command and `Save-PersonaConfiguration` use, so add/edit/delete can never be held to a looser standard than a hand edit. The document entering the interactive editor is already Error-free, so a clone that fails here failed because of Apply, not because of pre-existing state. .PARAMETER Document The current in-memory configuration document. .PARAMETER Apply A scriptblock taking the clone as its first parameter, followed by whatever ApplyArgs supplies, and mutating the clone in place. Values are passed as explicit arguments rather than closed over from the caller's scope: `&` invocation runs a scriptblock in a new child scope of wherever it is invoked (here, inside this function) rather than of the scope where the scriptblock literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as unbound. Explicit parameters sidestep that entirely. .PARAMETER ApplyArgs Positional arguments passed to Apply after the clone. .OUTPUTS pscustomobject with Applied (bool), Document (the clone if Applied, otherwise the original Document, unchanged), and Result (the validation result). #> param( [Parameter(Mandatory)] [object] $Document, [Parameter(Mandatory)] [scriptblock] $Apply, [object[]] $ApplyArgs = @() ) $clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32 & $Apply $clone @ApplyArgs $temp = [System.IO.Path]::GetTempFileName() try { Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM $check = Invoke-ConfigurationValidation -Path $temp } finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } [pscustomobject]@{ Applied = $check.Result.IsValid Document = $check.Result.IsValid ? $clone : $Document Result = $check.Result } } function ConvertTo-PersonaConditionPath { <# .SYNOPSIS Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects. .DESCRIPTION Blank input, or the literal word "root", addresses the rule's match group itself. #> param([string] $Text) $trimmed = ([string]$Text).Trim() if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() } $indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) { $value = 0 if (-not [int]::TryParse($part, [ref] $value)) { throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'." } $value } , @($indices) } function Read-PersonaConditionLeafFields { <# .SYNOPSIS Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects. #> Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray $typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant() $type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } } if (-not $type) { throw "Unrecognized condition type '$typeChoice'." } Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray $operator = (Read-Host ' Operator').Trim() $fields = @{ type = $type; operator = $operator } switch ($type) { 'property' { $fields['property'] = (Read-Host ' Property name').Trim() } 'membership' { $ids = (Read-Host ' Group Object IDs (comma-separated)') $fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) $mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim() if ($mode) { $fields['membershipMode'] = $mode } } 'role' { $ids = (Read-Host ' Role IDs (comma-separated)') $fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) } } if ($operator -in @('in', 'notIn')) { $values = (Read-Host ' Values (comma-separated)') $fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' }) } elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) { $fields['value'] = Read-Host ' Comparison value' } $fields } function Read-PersonaConditionNode { <# .SYNOPSIS Interactively builds one condition or nested group (FR-027, FR-028). .DESCRIPTION Recurses for a group's children. Nesting depth is not limited here - see Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why depth is enforced by the real validator instead of a second, local count. #> param([int] $Depth = 1) $indent = ' ' * $Depth Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray $kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant() if ($kind -eq 'G') { $operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant() if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." } $children = [System.Collections.Generic.List[object]]::new() do { $children.Add((Read-PersonaConditionNode -Depth ($Depth + 1))) $more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim() } while ($more.ToLowerInvariant() -eq 'y') return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() } } [pscustomobject](Read-PersonaConditionLeafFields) } function Read-PersonaRuleFields { <# .SYNOPSIS Prompts for a new rule's top-level fields (RE-001), excluding its match tree. #> $id = (Read-Host 'Rule id').Trim() $name = (Read-Host 'Rule name').Trim() $description = (Read-Host 'Description').Trim() $priority = 0 if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) { throw 'Priority must be an integer.' } $persona = (Read-Host 'Persona').Trim() $enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n' [pscustomobject]@{ id = $id; name = $name; description = $description enabled = $enabled; priority = $priority; persona = $persona } } function Invoke-InteractiveEditor { <# .SYNOPSIS The interactive loop (FR-023). .DESCRIPTION Toggles rules, adjusts priorities, adds/edits/deletes whole rules including their condition trees (FR-027 - FR-030), re-validates, tests against fixtures, and saves. Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit, which applies it to a clone and re-runs full validation before it is allowed to reach $Document. The live document entering this loop is already Error-free - the main script exits before this function is ever called otherwise - so a clone that fails validation failed because of the edit just applied, and is discarded rather than committed. This is what makes structural editing safe to add at all: the editor can never produce a worse document than a careful hand edit would, because it runs the same four validation layers a hand edit is checked against at save time. #> param( [Parameter(Mandatory)] [object] $Document, [Parameter(Mandatory)] [string] $Path ) $dirty = $false while ($true) { Write-Host '' Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : '')) Write-Host (" version {0}" -f $Document.configVersion) Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count) Write-Host '' Write-Host ' [L] list rules [T] toggle a rule [P] change a priority' Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule' Write-Host ' [V] re-validate [R] run rule test [S] save' Write-Host ' [Q] quit' Write-Host '' $choice = (Read-Host 'Choice').Trim().ToUpperInvariant() switch ($choice) { 'L' { Write-Host '' foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) { Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f $rule.priority, $rule.id, $rule.persona, ($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray') } } 'T' { $id = (Read-Host 'Rule ID to toggle').Trim() $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } $rule.enabled = -not $rule.enabled $dirty = $true Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green } 'P' { $id = (Read-Host 'Rule ID').Trim() $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } $value = 0 if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) { Write-Host 'Priority must be an integer.' -ForegroundColor Yellow break } $rule.priority = $value $dirty = $true Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green } 'A' { # FR-027. The candidate rule is built entirely from prompts before # Test-PersonaCandidateEdit ever sees it, so a collision or a # validation failure is reported once, against the whole rule, rather # than mid-build against a rule that only half exists yet. try { $fields = Read-PersonaRuleFields Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan $rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant() if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." } $rootChildren = [System.Collections.Generic.List[object]]::new() do { $rootChildren.Add((Read-PersonaConditionNode -Depth 1)) $more = (Read-Host 'Add another top-level condition? (y/N)').Trim() } while ($more.ToLowerInvariant() -eq 'y') $newRule = [pscustomobject]@{ id = $fields.id name = $fields.name description = $fields.description enabled = $fields.enabled priority = $fields.priority persona = $fields.persona match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() } } $attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply { param($doc, $rule) $doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule } if ($attempt.Applied) { $Document = $attempt.Document $dirty = $true Write-Host "Rule $($newRule.id) added." -ForegroundColor Green } else { Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red Write-PersonaValidationFinding -Findings $attempt.Result.Findings } } catch { Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow } } 'E' { # FR-028. One structural operation per pass through this case - the # operator repeats [E] to make further changes, which keeps each # Test-PersonaCandidateEdit call scoped to one edit and one finding # set, rather than a batch where a rejection leaves it unclear which # of several changes caused it. try { $id = (Read-Host 'Rule id to edit').Trim() $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan Write-Host ' [F] change a top-level field [C] add a condition/group' Write-Host ' [E] edit a leaf condition [X] remove a condition/group' Write-Host ' [B] back to the main menu, no change' $subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant() $attempt = switch ($subChoice) { 'F' { $field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant() $value = Read-Host ' New value' Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply { param($doc, $ruleId, $fieldName, $newValue) $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 switch ($fieldName) { 'priority' { $target.priority = [int]$newValue } 'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') } 'name' { $target.name = $newValue } 'description' { $target.description = $newValue } 'persona' { $target.persona = $newValue } default { throw "Unrecognized field '$fieldName'." } } } } 'C' { $parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")') $newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1) # ApplyArgs is built with .Add() rather than @(...), because an # int[] placed inside a `@()` array literal is unrolled into the # outer array (the same gotcha Get-CapturedAuditRecord's comma # idiom guards against elsewhere) - @($id, $parentPath, $newNode) # would silently flatten a two-segment path into two extra # positional arguments instead of passing it as one array. $args = [System.Collections.Generic.List[object]]::new() $args.Add($id); $args.Add($parentPath); $args.Add($newNode) Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { param($doc, $ruleId, $parentPathArg, $node) $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null } } 'E' { $path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")') if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' } $fields = Read-PersonaConditionLeafFields $args = [System.Collections.Generic.List[object]]::new() $args.Add($id); $args.Add($path); $args.Add($fields) Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { param($doc, $ruleId, $pathArg, $fieldsArg) $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 $leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null } } 'X' { $path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")') $args = [System.Collections.Generic.List[object]]::new() $args.Add($id); $args.Add($path) Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply { param($doc, $ruleId, $pathArg) $target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1 Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null } } 'B' { $null } default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null } } if ($null -ne $attempt) { if ($attempt.Applied) { $Document = $attempt.Document $dirty = $true Write-Host "Rule $id updated." -ForegroundColor Green } else { Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red Write-PersonaValidationFinding -Findings $attempt.Result.Findings } } } catch { Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow } } 'D' { # FR-029. Confirmation names id, name, and priority before anything is # touched, matching the acceptance criterion literally rather than a # generic "are you sure?". try { $id = (Read-Host 'Rule id to delete').Trim() $rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1 if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break } Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow $confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant() if ($confirm -ne 'y') { Write-Host 'Delete cancelled.' -ForegroundColor Yellow break } $attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply { param($doc, $ruleId) $doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId } if ($attempt.Applied) { $Document = $attempt.Document $dirty = $true Write-Host "Rule $id deleted." -ForegroundColor Green } else { Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red Write-PersonaValidationFinding -Findings $attempt.Result.Findings } } catch { Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow } } 'V' { $temp = [System.IO.Path]::GetTempFileName() try { Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM $check = Invoke-ConfigurationValidation -Path $temp Write-PersonaValidationFinding -Findings $check.Result.Findings } finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } 'R' { $dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData') $temp = [System.IO.Path]::GetTempFileName() try { Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath } finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } } 'S' { if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false } } 'Q' { if ($dirty) { $confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim() if ($confirm -ne 'y') { break } } return } default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow } } } } # ============================================================ main $validation = Invoke-ConfigurationValidation -Path $ConfigPath Write-PersonaValidationFinding -Findings $validation.Result.Findings if ($validation.ExitCode -ne $EXIT_OK) { Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red exit $validation.ExitCode } if ($TestDataPath) { Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath } # -ValidateOnly and -NonInteractive both short-circuit before any prompting call. # The check is here, once, at the single point where the editor could be entered. if ($ValidateOnly -or $NonInteractive) { Write-Host 'Configuration is valid.' -ForegroundColor Green exit $EXIT_OK } Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath exit $EXIT_OK