Display name' => 100, 'Role definition > Description' => 200, 'Role definition > Role source' => 300, 'Role definition > Permission blocks' => 400, 'Role definition > Scope tag IDs' => 500, ]; private const PERMISSION_FIELD_ORDER = [ 'Allowed actions' => 100, 'Denied actions' => 200, 'Conditions' => 300, ]; public function __construct( private readonly DiffPresenter $presenter, ) {} /** * @param array $payload */ public function build(array $payload): DiffPresentation { $baseline = $this->normalizeSide( is_array($payload['baseline'] ?? null) ? $payload['baseline'] : [], ); $current = $this->normalizeSide( is_array($payload['current'] ?? null) ? $payload['current'] : [], ); $changedKeys = $this->changedKeys($payload); $presentation = $this->presenter->present( baseline: $baseline, current: $current, changedKeys: $changedKeys, labels: $this->labelsFor($baseline, $current, $changedKeys), ); if ($presentation->rows === []) { return $presentation; } $rows = array_map( fn (DiffRow $row): DiffRow => $this->normalizeRow($row), $presentation->rows, ); usort($rows, fn (DiffRow $left, DiffRow $right): int => $this->sortKey($left->key) <=> $this->sortKey($right->key)); return new DiffPresentation( summary: DiffSummary::fromRows($rows, $presentation->summary->message), rows: $rows, ); } /** * @param array $payload * @return list */ private function changedKeys(array $payload): array { $changedKeys = is_array($payload['changed_keys'] ?? null) ? $payload['changed_keys'] : []; return array_values(array_filter( $changedKeys, fn (mixed $key): bool => is_string($key) && trim($key) !== '', )); } /** * @param array $side * @return array */ private function normalizeSide(array $side): array { $normalized = []; $rows = is_array($side['normalized'] ?? null) ? $side['normalized'] : []; foreach ($rows as $key => $value) { if (! is_string($key) || trim($key) === '') { continue; } $normalized[trim($key)] = $value; } if (! array_key_exists('Role definition > Role source', $normalized)) { $roleSource = $this->roleSourceLabel($side['is_built_in'] ?? null); if ($roleSource !== null) { $normalized['Role definition > Role source'] = $roleSource; } } if ( ! array_key_exists('Role definition > Permission blocks', $normalized) && is_numeric($side['role_permission_count'] ?? null) ) { $normalized['Role definition > Permission blocks'] = (int) $side['role_permission_count']; } return $normalized; } private function roleSourceLabel(mixed $isBuiltIn): ?string { return match ($isBuiltIn) { true => 'Built-in', false => 'Custom', default => null, }; } /** * @param array $baseline * @param array $current * @param list $changedKeys * @return array */ private function labelsFor(array $baseline, array $current, array $changedKeys): array { $labels = []; foreach ([array_keys($baseline), array_keys($current), $changedKeys] as $sourceKeys) { foreach ($sourceKeys as $key) { if (! is_string($key) || trim($key) === '') { continue; } $labels[$key] = $key; } } return $labels; } private function normalizeRow(DiffRow $row): DiffRow { $shouldRenderAsList = $row->isListLike && $this->isDesignatedListField($row->key); if ($row->isListLike === $shouldRenderAsList) { return $row; } return new DiffRow( key: $row->key, label: $row->label, status: $row->status, oldValue: $row->oldValue, newValue: $row->newValue, isListLike: $shouldRenderAsList, addedItems: $shouldRenderAsList ? $row->addedItems : [], removedItems: $shouldRenderAsList ? $row->removedItems : [], unchangedItems: $shouldRenderAsList ? $row->unchangedItems : [], meta: $row->meta, ); } private function isDesignatedListField(string $key): bool { if ($key === 'Role definition > Scope tag IDs') { return true; } return preg_match('/^Permission block \d+ > (Allowed actions|Denied actions|Conditions)$/', $key) === 1; } /** * @return array{int, int, int, string} */ private function sortKey(string $key): array { if (array_key_exists($key, self::ROLE_FIELD_ORDER)) { return [0, self::ROLE_FIELD_ORDER[$key], 0, mb_strtolower($key)]; } if (preg_match('/^Role definition > (?P