ExecutionTreeComparator.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer and others.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.api;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import org.sandbox.jdt.triggerpattern.api.ExecutionTreeSnapshot.Node;
import org.sandbox.jdt.triggerpattern.api.ExecutionTreeSnapshot.NodeKind;
/** Compares framework-neutral execution trees under an explicit safety policy. */
public final class ExecutionTreeComparator {
/**
* Explicit comparison dimensions.
*
* <p>Multiplicity is always exact: canonical entries retain every occurrence.
* A migration may relax wrapper identity, nesting or order only by selecting a
* corresponding policy. There is deliberately no set-only mode that could hide
* duplicate or lost tests.</p>
*/
public record Policy(boolean compareNesting, boolean compareContainerIdentity,
boolean compareOrder, boolean compareResults, boolean compareAttributes,
boolean compareDisplayNames, boolean requireSuccessful) {
/** Strict comparison of structure, semantic identities, order, results and attributes. */
public static Policy strict() {
return new Policy(true, true, true, true, true, false, true);
}
/** Compares tree shape and leaves while allowing framework container names to change. */
public static Policy sameShape() {
return new Policy(true, false, true, true, true, false, true);
}
/** Compares ordered leaf identities and multiplicity while ignoring all wrappers. */
public static Policy leavesInOrder() {
return new Policy(false, false, true, true, true, false, true);
}
/** Compares the unordered leaf multiset while preserving exact multiplicity. */
public static Policy leafMultiset() {
return new Policy(false, false, false, true, true, false, true);
}
/** Returns a copy with result comparison changed. */
public Policy withResults(boolean enabled) {
return new Policy(compareNesting, compareContainerIdentity, compareOrder, enabled,
compareAttributes, compareDisplayNames, requireSuccessful);
}
/** Returns a copy with adapter attributes changed. */
public Policy withAttributes(boolean enabled) {
return new Policy(compareNesting, compareContainerIdentity, compareOrder, compareResults,
enabled, compareDisplayNames, requireSuccessful);
}
/** Returns a copy with display-name comparison changed. */
public Policy withDisplayNames(boolean enabled) {
return new Policy(compareNesting, compareContainerIdentity, compareOrder, compareResults,
compareAttributes, enabled, requireSuccessful);
}
/** Returns a copy with the successful-execution requirement changed. */
public Policy requiringSuccessful(boolean enabled) {
return new Policy(compareNesting, compareContainerIdentity, compareOrder, compareResults,
compareAttributes, compareDisplayNames, enabled);
}
}
/** Complete deterministic comparison result and diagnostic canonical forms. */
public record Comparison(boolean equivalent, List<String> beforeEntries,
List<String> afterEntries, String difference) {
public Comparison {
beforeEntries= List.copyOf(beforeEntries);
afterEntries= List.copyOf(afterEntries);
difference= difference == null ? "" : difference; //$NON-NLS-1$
}
}
private ExecutionTreeComparator() {
}
/** Compares two snapshots under the supplied explicit policy. */
public static Comparison compare(ExecutionTreeSnapshot before, ExecutionTreeSnapshot after,
Policy policy) {
Objects.requireNonNull(before);
Objects.requireNonNull(after);
Objects.requireNonNull(policy);
List<String> beforeEntries= canonicalEntries(before, policy);
List<String> afterEntries= canonicalEntries(after, policy);
if (policy.requireSuccessful() && (!before.successful() || !after.successful())) {
return new Comparison(false, beforeEntries, afterEntries,
"Execution must be successful before and after migration: before=" //$NON-NLS-1$
+ before.successful() + ", after=" + after.successful()); //$NON-NLS-1$
}
if (beforeEntries.equals(afterEntries)) {
return new Comparison(true, beforeEntries, afterEntries, ""); //$NON-NLS-1$
}
return new Comparison(false, beforeEntries, afterEntries,
firstDifference(beforeEntries, afterEntries));
}
private static List<String> canonicalEntries(ExecutionTreeSnapshot snapshot, Policy policy) {
List<String> entries= new ArrayList<>();
if (policy.compareNesting()) {
for (Node root : snapshot.roots()) {
entries.add(canonicalNode(root, policy));
}
} else {
for (Node root : snapshot.roots()) {
appendLeaves(root, policy, entries);
}
}
if (!policy.compareOrder()) {
entries.sort(Comparator.naturalOrder());
}
return List.copyOf(entries);
}
private static String canonicalNode(Node node, Policy policy) {
List<String> children= node.children().stream()
.map(child -> canonicalNode(child, policy))
.collect(java.util.stream.Collectors.toCollection(ArrayList::new));
if (!policy.compareOrder()) {
children.sort(Comparator.naturalOrder());
}
StringBuilder result= new StringBuilder();
result.append(node.kind()).append('{');
if (node.kind() != NodeKind.CONTAINER || policy.compareContainerIdentity()) {
appendValue(result, node.identity());
}
if (policy.compareDisplayNames()) {
appendValue(result, node.displayName());
}
if (policy.compareResults()) {
appendValue(result, node.result());
}
if (policy.compareAttributes()) {
appendAttributes(result, node.attributes());
}
result.append('}').append('[');
for (String child : children) {
appendValue(result, child);
}
return result.append(']').toString();
}
private static void appendLeaves(Node node, Policy policy, List<String> entries) {
if (node.kind() == NodeKind.TEST || node.children().isEmpty()) {
entries.add(canonicalLeaf(node, policy));
return;
}
for (Node child : node.children()) {
appendLeaves(child, policy, entries);
}
}
private static String canonicalLeaf(Node node, Policy policy) {
StringBuilder result= new StringBuilder(node.kind().name()).append('{');
appendValue(result, node.identity());
if (policy.compareDisplayNames()) {
appendValue(result, node.displayName());
}
if (policy.compareResults()) {
appendValue(result, node.result());
}
if (policy.compareAttributes()) {
appendAttributes(result, node.attributes());
}
return result.append('}').toString();
}
private static void appendAttributes(StringBuilder target, Map<String, String> attributes) {
Map<String, String> sorted= new TreeMap<>(attributes);
for (Map.Entry<String, String> entry : sorted.entrySet()) {
appendValue(target, entry.getKey());
appendValue(target, entry.getValue());
}
}
private static void appendValue(StringBuilder target, String value) {
String text= value == null ? "" : value; //$NON-NLS-1$
target.append(text.length()).append(':').append(text).append(';');
}
private static String firstDifference(List<String> before, List<String> after) {
int common= Math.min(before.size(), after.size());
for (int index= 0; index < common; index++) {
if (!before.get(index).equals(after.get(index))) {
return "Execution tree differs at canonical entry " + index //$NON-NLS-1$
+ ": before=" + before.get(index) + ", after=" + after.get(index); //$NON-NLS-1$ //$NON-NLS-2$
}
}
return "Execution tree occurrence count differs: before=" + before.size() //$NON-NLS-1$
+ ", after=" + after.size(); //$NON-NLS-1$
}
}