diff --git a/src/main/java/org/jboss/jws/diag/Main.java b/src/main/java/org/jboss/jws/diag/Main.java
index b11bd88..811e8b1 100644
--- a/src/main/java/org/jboss/jws/diag/Main.java
+++ b/src/main/java/org/jboss/jws/diag/Main.java
@@ -2,6 +2,7 @@
import org.jboss.jws.diag.bundle.BundleCommand;
import org.jboss.jws.diag.config.ConfigCommand;
+import org.jboss.jws.diag.diff.DiffCommand;
import org.jboss.jws.diag.logs.LogsCommand;
import org.jboss.jws.diag.summary.SummaryCommand;
import org.jboss.jws.diag.validate.ValidateCommand;
@@ -17,7 +18,8 @@
ConfigCommand.class,
ValidateCommand.class,
BundleCommand.class,
- LogsCommand.class
+ LogsCommand.class,
+ DiffCommand.class
}
)
public class Main implements Runnable {
diff --git a/src/main/java/org/jboss/jws/diag/diff/ConfigDiffer.java b/src/main/java/org/jboss/jws/diag/diff/ConfigDiffer.java
new file mode 100644
index 0000000..06820d0
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/ConfigDiffer.java
@@ -0,0 +1,239 @@
+package org.jboss.jws.diag.diff;
+
+import org.jboss.jws.diag.config.model.CertificateConfig;
+import org.jboss.jws.diag.config.model.ConfigValue;
+import org.jboss.jws.diag.config.model.ConnectorConfig;
+import org.jboss.jws.diag.config.model.ExecutorConfig;
+import org.jboss.jws.diag.config.model.ServerConfig;
+import org.jboss.jws.diag.config.model.ServiceConfig;
+import org.jboss.jws.diag.config.model.SslHostConfig;
+import org.jboss.jws.diag.diff.model.ChangeType;
+import org.jboss.jws.diag.diff.model.DiffEntry;
+import org.jboss.jws.diag.diff.model.DiffReport;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Computes a structural diff between two parsed {@link ServerConfig} trees.
+ *
+ *
Connectors are keyed by port; executors by name. Structural differences
+ * (ADDED/REMOVED items) are reported before field-level CHANGED entries.
+ */
+public final class ConfigDiffer {
+
+ public DiffReport diff(Path leftBase, Path rightBase,
+ ServerConfig left, ServerConfig right) {
+ List entries = new ArrayList<>();
+
+ compareServer(left, right, entries);
+
+ Map leftServices = indexServicesByName(left.getServices());
+ Map rightServices = indexServicesByName(right.getServices());
+
+ for (String name : union(leftServices.keySet(), rightServices.keySet())) {
+ ServiceConfig lSvc = leftServices.get(name);
+ ServiceConfig rSvc = rightServices.get(name);
+ String svcPath = "services[" + name + "]";
+ if (lSvc == null) {
+ entries.add(new DiffEntry(svcPath, ChangeType.ADDED, null, name));
+ } else if (rSvc == null) {
+ entries.add(new DiffEntry(svcPath, ChangeType.REMOVED, name, null));
+ } else {
+ compareConnectors(svcPath, lSvc, rSvc, entries);
+ compareExecutors(svcPath, lSvc, rSvc, entries);
+ }
+ }
+
+ return new DiffReport(leftBase, rightBase, entries);
+ }
+
+ private void compareServer(ServerConfig left, ServerConfig right, List out) {
+ if (left.getShutdownPort() != right.getShutdownPort()) {
+ out.add(new DiffEntry("server.shutdownPort", ChangeType.CHANGED,
+ String.valueOf(left.getShutdownPort()),
+ String.valueOf(right.getShutdownPort())));
+ }
+ diffNullable("server.shutdownCommand",
+ left.getShutdownCommand(), right.getShutdownCommand(), out);
+ }
+
+ private void compareConnectors(String svcPath,
+ ServiceConfig left, ServiceConfig right,
+ List out) {
+ Map lMap = indexConnectorsByPort(left.getConnectors());
+ Map rMap = indexConnectorsByPort(right.getConnectors());
+
+ for (int port : union(lMap.keySet(), rMap.keySet())) {
+ String cPath = svcPath + ".connectors[" + port + "]";
+ ConnectorConfig lc = lMap.get(port);
+ ConnectorConfig rc = rMap.get(port);
+ if (lc == null) {
+ out.add(new DiffEntry(cPath, ChangeType.ADDED, null, "port " + port));
+ } else if (rc == null) {
+ out.add(new DiffEntry(cPath, ChangeType.REMOVED, "port " + port, null));
+ } else {
+ diffCv(cPath + ".protocol", lc.getProtocol(), rc.getProtocol(), out);
+ diffCv(cPath + ".sslEnabled", lc.getSslEnabled(), rc.getSslEnabled(), out);
+ diffCv(cPath + ".maxThreads", lc.getMaxThreads(), rc.getMaxThreads(), out);
+ diffCv(cPath + ".connectionTimeout", lc.getConnectionTimeout(), rc.getConnectionTimeout(), out);
+ diffCv(cPath + ".maxConnections", lc.getMaxConnections(), rc.getMaxConnections(), out);
+ diffCv(cPath + ".compression", lc.getCompression(), rc.getCompression(), out);
+ diffCv(cPath + ".secretRequired", lc.getSecretRequired(), rc.getSecretRequired(), out);
+ diffNullable(cPath + ".executorRef", lc.getExecutorRef(), rc.getExecutorRef(), out);
+ diffNullable(cPath + ".proxyName", lc.getProxyName(), rc.getProxyName(), out);
+ diffNullable(cPath + ".proxyPort",
+ lc.getProxyPort() == null ? null : String.valueOf(lc.getProxyPort()),
+ rc.getProxyPort() == null ? null : String.valueOf(rc.getProxyPort()), out);
+ compareSslHostConfigs(cPath,
+ lc.getSslHostConfigs() != null ? lc.getSslHostConfigs() : Collections.emptyList(),
+ rc.getSslHostConfigs() != null ? rc.getSslHostConfigs() : Collections.emptyList(),
+ out);
+ }
+ }
+ }
+
+ private void compareSslHostConfigs(String cPath,
+ List left, List right,
+ List out) {
+ Map lMap = indexSslByHost(left);
+ Map rMap = indexSslByHost(right);
+
+ for (String host : union(lMap.keySet(), rMap.keySet())) {
+ String sPath = cPath + ".ssl[" + host + "]";
+ SslHostConfig ls = lMap.get(host);
+ SslHostConfig rs = rMap.get(host);
+ if (ls == null) {
+ out.add(new DiffEntry(sPath, ChangeType.ADDED, null, host));
+ } else if (rs == null) {
+ out.add(new DiffEntry(sPath, ChangeType.REMOVED, host, null));
+ } else {
+ diffNullable(sPath + ".protocols", ls.getProtocols(), rs.getProtocols(), out);
+ diffNullable(sPath + ".sslEnabledProtocols",
+ ls.getSslEnabledProtocols(), rs.getSslEnabledProtocols(), out);
+ diffNullable(sPath + ".ciphers", ls.getCiphers(), rs.getCiphers(), out);
+ diffNullable(sPath + ".certificateVerification",
+ ls.getCertificateVerification(), rs.getCertificateVerification(), out);
+ compareCertificates(sPath, ls.getCertificates(), rs.getCertificates(), out);
+ }
+ }
+ }
+
+ private void compareCertificates(String sPath,
+ List left, List right,
+ List out) {
+ Map lMap = indexCertsByType(left);
+ Map rMap = indexCertsByType(right);
+
+ for (String type : union(lMap.keySet(), rMap.keySet())) {
+ String certPath = sPath + ".certificate[" + type + "]";
+ CertificateConfig lc = lMap.get(type);
+ CertificateConfig rc = rMap.get(type);
+ if (lc == null) {
+ out.add(new DiffEntry(certPath, ChangeType.ADDED, null, type));
+ } else if (rc == null) {
+ out.add(new DiffEntry(certPath, ChangeType.REMOVED, type, null));
+ } else {
+ diffNullable(certPath + ".keystoreFile", lc.getKeystoreFile(), rc.getKeystoreFile(), out);
+ diffCv(certPath + ".keystoreType", lc.getKeystoreType(), rc.getKeystoreType(), out);
+ diffNullable(certPath + ".type", lc.getType(), rc.getType(), out);
+ }
+ }
+ }
+
+ private void compareExecutors(String svcPath,
+ ServiceConfig left, ServiceConfig right,
+ List out) {
+ Map lMap = indexExecutorsByName(left.getExecutors());
+ Map rMap = indexExecutorsByName(right.getExecutors());
+
+ for (String name : union(lMap.keySet(), rMap.keySet())) {
+ String ePath = svcPath + ".executors[" + name + "]";
+ ExecutorConfig le = lMap.get(name);
+ ExecutorConfig re = rMap.get(name);
+ if (le == null) {
+ out.add(new DiffEntry(ePath, ChangeType.ADDED, null, name));
+ } else if (re == null) {
+ out.add(new DiffEntry(ePath, ChangeType.REMOVED, name, null));
+ } else {
+ diffCv(ePath + ".maxThreads", le.getMaxThreads(), re.getMaxThreads(), out);
+ diffCv(ePath + ".minSpareThreads", le.getMinSpareThreads(), re.getMinSpareThreads(), out);
+ diffCv(ePath + ".threadPriority", le.getThreadPriority(), re.getThreadPriority(), out);
+ diffCv(ePath + ".maxIdleTime", le.getMaxIdleTime(), re.getMaxIdleTime(), out);
+ diffNullable(ePath + ".namePrefix", le.getNamePrefix(), re.getNamePrefix(), out);
+ }
+ }
+ }
+
+ private void diffCv(String path, ConfigValue left, ConfigValue right,
+ List out) {
+ if (left == null && right == null) return;
+ String lStr = left == null ? null : renderCv(left);
+ String rStr = right == null ? null : renderCv(right);
+ if (!Objects.equals(lStr, rStr)) {
+ out.add(new DiffEntry(path, ChangeType.CHANGED, lStr, rStr));
+ }
+ }
+
+ private void diffNullable(String path, String left, String right, List out) {
+ if (!Objects.equals(left, right)) {
+ out.add(new DiffEntry(path, ChangeType.CHANGED, left, right));
+ }
+ }
+
+ static String renderCv(ConfigValue cv) {
+ return cv.getValue() + " (" + (cv.isExplicit() ? "explicit" : "default") + ")";
+ }
+
+ private static Map indexServicesByName(List items) {
+ Map map = new LinkedHashMap<>();
+ for (ServiceConfig s : items) map.put(s.getName(), s);
+ return map;
+ }
+
+ private static Map indexExecutorsByName(List items) {
+ Map map = new LinkedHashMap<>();
+ for (ExecutorConfig e : items) map.put(e.getName(), e);
+ return map;
+ }
+
+ private static Map indexConnectorsByPort(List items) {
+ Map map = new LinkedHashMap<>();
+ for (ConnectorConfig c : items) map.put(c.getPort(), c);
+ return map;
+ }
+
+ private static Map indexSslByHost(List items) {
+ Map map = new LinkedHashMap<>();
+ int idx = 0;
+ for (SslHostConfig s : items) {
+ String key = s.getHostName() != null ? s.getHostName() : String.valueOf(idx);
+ map.put(key, s);
+ idx++;
+ }
+ return map;
+ }
+
+ private static Map indexCertsByType(List items) {
+ Map map = new LinkedHashMap<>();
+ int idx = 0;
+ for (CertificateConfig c : items) {
+ String key = c.getType() != null ? c.getType() : String.valueOf(idx);
+ map.put(key, c);
+ idx++;
+ }
+ return map;
+ }
+
+ private static Iterable union(Iterable a, Iterable b) {
+ java.util.LinkedHashSet set = new java.util.LinkedHashSet<>();
+ a.forEach(set::add);
+ b.forEach(set::add);
+ return set;
+ }
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/DiffCommand.java b/src/main/java/org/jboss/jws/diag/diff/DiffCommand.java
new file mode 100644
index 0000000..5b35c5a
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/DiffCommand.java
@@ -0,0 +1,97 @@
+package org.jboss.jws.diag.diff;
+
+import org.jboss.jws.diag.common.ExitCodes;
+import org.jboss.jws.diag.common.OutputFormat;
+import org.jboss.jws.diag.common.OutputFormatMixin;
+import org.jboss.jws.diag.config.model.ServerConfig;
+import org.jboss.jws.diag.config.parser.PropertyResolver;
+import org.jboss.jws.diag.config.parser.ServerXmlParser;
+import org.jboss.jws.diag.diff.formatter.DiffHumanFormatter;
+import org.jboss.jws.diag.diff.formatter.DiffJsonFormatter;
+import org.jboss.jws.diag.diff.model.DiffReport;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@Command(name = "diff",
+ description = "Compare effective server.xml configuration between two CATALINA_BASE directories",
+ mixinStandardHelpOptions = true)
+public class DiffCommand implements Runnable {
+
+ @Option(names = "--left",
+ required = true,
+ description = "Path to the first CATALINA_BASE directory (or its server.xml)")
+ private Path left;
+
+ @Option(names = "--right",
+ required = true,
+ description = "Path to the second CATALINA_BASE directory (or its server.xml)")
+ private Path right;
+
+ @Mixin
+ private OutputFormatMixin outputFormat;
+
+ @Override
+ public void run() {
+ Path leftXml = resolveServerXml("--left", left);
+ Path rightXml = resolveServerXml("--right", right);
+ if (leftXml == null || rightXml == null) {
+ System.exit(ExitCodes.ERRORS);
+ return;
+ }
+
+ ServerConfig leftConfig = parse(leftXml, left);
+ ServerConfig rightConfig = parse(rightXml, right);
+ if (leftConfig == null || rightConfig == null) {
+ System.exit(ExitCodes.ERRORS);
+ return;
+ }
+
+ DiffReport report = new ConfigDiffer().diff(left, right, leftConfig, rightConfig);
+
+ String output;
+ if (outputFormat.getFormat() == OutputFormat.JSON) {
+ output = new DiffJsonFormatter().format(report);
+ } else {
+ output = new DiffHumanFormatter().format(report);
+ }
+
+ System.out.println(output);
+ System.exit(report.hasDifferences() ? ExitCodes.WARNINGS : ExitCodes.OK);
+ }
+
+ private Path resolveServerXml(String flag, Path path) {
+ if (Files.isRegularFile(path)) {
+ return path;
+ }
+ if (Files.isDirectory(path)) {
+ Path xml = path.resolve("conf/server.xml");
+ if (Files.exists(xml)) return xml;
+ System.err.println("ERROR: server.xml not found under " + flag + ": " + path);
+ return null;
+ }
+ System.err.println("ERROR: " + flag + " is not a directory or server.xml file: " + path);
+ return null;
+ }
+
+ private ServerConfig parse(Path serverXml, Path base) {
+ try {
+ Path resolverBase;
+ if (Files.isDirectory(base)) {
+ resolverBase = base;
+ } else {
+ Path parent = base.getParent();
+ resolverBase = (parent != null) ? parent.getParent() : null;
+ }
+ PropertyResolver resolver = PropertyResolver.create(resolverBase);
+ return new ServerXmlParser(resolver).parse(serverXml);
+ } catch (IOException e) {
+ System.err.println("ERROR: Failed to parse " + serverXml + ": " + e.getMessage());
+ return null;
+ }
+ }
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/formatter/DiffHumanFormatter.java b/src/main/java/org/jboss/jws/diag/diff/formatter/DiffHumanFormatter.java
new file mode 100644
index 0000000..5df0e7e
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/formatter/DiffHumanFormatter.java
@@ -0,0 +1,90 @@
+package org.jboss.jws.diag.diff.formatter;
+
+import org.jboss.jws.diag.diff.model.ChangeType;
+import org.jboss.jws.diag.diff.model.DiffEntry;
+import org.jboss.jws.diag.diff.model.DiffReport;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Renders a {@link DiffReport} as human-readable text grouped by path prefix.
+ *
+ * Example:
+ *
+ * Diff left : /opt/tomcat-a
+ * right: /opt/tomcat-b
+ *
+ * server
+ * ~ shutdownPort 8005 → 8006
+ *
+ * services[Catalina].connectors[8080]
+ * ~ maxThreads 200 (default) → 150 (explicit)
+ *
+ * services[Catalina].connectors[8443]
+ * + ADDED
+ *
+ * No differences found.
+ *
+ */
+public final class DiffHumanFormatter {
+
+ public String format(DiffReport report) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(String.format("Diff left : %s%n", report.getLeft()));
+ sb.append(String.format(" right: %s%n", report.getRight()));
+
+ if (!report.hasDifferences()) {
+ sb.append("\nNo differences found.\n");
+ return sb.toString();
+ }
+
+ sb.append(String.format("%n%d change(s) found:%n", report.getChangeCount()));
+
+ // Group entries by their section (everything up to the last '.')
+ Map> groups = new LinkedHashMap<>();
+ for (DiffEntry e : report.getEntries()) {
+ String section = sectionOf(e.getPath());
+ groups.computeIfAbsent(section, k -> new ArrayList<>()).add(e);
+ }
+
+ for (Map.Entry> g : groups.entrySet()) {
+ sb.append('\n').append(g.getKey()).append('\n');
+ for (DiffEntry e : g.getValue()) {
+ String field = fieldOf(e.getPath());
+ switch (e.getType()) {
+ case ADDED:
+ sb.append(String.format(" + %-28s (added in right)%n", field));
+ break;
+ case REMOVED:
+ sb.append(String.format(" - %-28s (only in left)%n", field));
+ break;
+ case CHANGED:
+ sb.append(String.format(" ~ %-28s %s → %s%n",
+ field,
+ nullSafe(e.getLeftValue()),
+ nullSafe(e.getRightValue())));
+ break;
+ }
+ }
+ }
+ return sb.toString();
+ }
+
+ private static String sectionOf(String path) {
+ int dot = path.lastIndexOf('.');
+ return dot < 0 ? path : path.substring(0, dot);
+ }
+
+ private static String fieldOf(String path) {
+ int dot = path.lastIndexOf('.');
+ // For ADDED/REMOVED whole-section entries (e.g. connectors[8443]) the path IS the section
+ return dot < 0 ? path : path.substring(dot + 1);
+ }
+
+ private static String nullSafe(String s) {
+ return s == null ? "(absent)" : s;
+ }
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/formatter/DiffJsonFormatter.java b/src/main/java/org/jboss/jws/diag/diff/formatter/DiffJsonFormatter.java
new file mode 100644
index 0000000..649100e
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/formatter/DiffJsonFormatter.java
@@ -0,0 +1,37 @@
+package org.jboss.jws.diag.diff.formatter;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.jboss.jws.diag.diff.model.DiffReport;
+
+/**
+ * Serializes a {@link DiffReport} as indented JSON.
+ *
+ * Schema:
+ *
+ * {
+ * "schemaVersion": "1.0",
+ * "left": "/opt/tomcat-a",
+ * "right": "/opt/tomcat-b",
+ * "changeCount": 2,
+ * "changes": [
+ * { "path": "server.shutdownPort", "type": "CHANGED", "left": "8005", "right": "8006" },
+ * ...
+ * ]
+ * }
+ *
+ */
+public final class DiffJsonFormatter {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ .enable(SerializationFeature.INDENT_OUTPUT);
+
+ public String format(DiffReport report) {
+ try {
+ return MAPPER.writeValueAsString(report);
+ } catch (JsonProcessingException e) {
+ throw new IllegalStateException("Failed to serialize DiffReport to JSON", e);
+ }
+ }
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/model/ChangeType.java b/src/main/java/org/jboss/jws/diag/diff/model/ChangeType.java
new file mode 100644
index 0000000..a0b8bfd
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/model/ChangeType.java
@@ -0,0 +1,10 @@
+package org.jboss.jws.diag.diff.model;
+
+public enum ChangeType {
+ /** Present only in right; absent in left. */
+ ADDED,
+ /** Present only in left; absent in right. */
+ REMOVED,
+ /** Present in both but values differ. */
+ CHANGED
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/model/DiffEntry.java b/src/main/java/org/jboss/jws/diag/diff/model/DiffEntry.java
new file mode 100644
index 0000000..5bbe587
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/model/DiffEntry.java
@@ -0,0 +1,44 @@
+package org.jboss.jws.diag.diff.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * A single difference between two server.xml configurations.
+ *
+ * {@code path} uses dot-notation, e.g.
+ * {@code services[Catalina].connectors[8080].maxThreads}.
+ * {@code leftValue} is null for ADDED entries; {@code rightValue} is null for REMOVED entries.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public final class DiffEntry {
+
+ @JsonProperty("path")
+ private final String path;
+
+ @JsonProperty("type")
+ private final ChangeType type;
+
+ @JsonProperty("left")
+ private final String leftValue;
+
+ @JsonProperty("right")
+ private final String rightValue;
+
+ public DiffEntry(String path, ChangeType type, String leftValue, String rightValue) {
+ this.path = path;
+ this.type = type;
+ this.leftValue = leftValue;
+ this.rightValue = rightValue;
+ }
+
+ public String getPath() { return path; }
+ public ChangeType getType() { return type; }
+ public String getLeftValue() { return leftValue; }
+ public String getRightValue() { return rightValue; }
+
+ @Override
+ public String toString() {
+ return type + " " + path + " [" + leftValue + " → " + rightValue + "]";
+ }
+}
diff --git a/src/main/java/org/jboss/jws/diag/diff/model/DiffReport.java b/src/main/java/org/jboss/jws/diag/diff/model/DiffReport.java
new file mode 100644
index 0000000..edaf5d3
--- /dev/null
+++ b/src/main/java/org/jboss/jws/diag/diff/model/DiffReport.java
@@ -0,0 +1,45 @@
+package org.jboss.jws.diag.diff.model;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonPropertyOrder;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Full diff result between two server.xml configurations.
+ */
+@JsonPropertyOrder({"schemaVersion", "left", "right", "changeCount", "changes"})
+public final class DiffReport {
+
+ @JsonProperty("schemaVersion")
+ private static final String SCHEMA_VERSION = "1.0";
+
+ private final Path leftBase;
+ private final Path rightBase;
+ private final List entries;
+
+ public DiffReport(Path leftBase, Path rightBase, List entries) {
+ this.leftBase = leftBase;
+ this.rightBase = rightBase;
+ this.entries = Collections.unmodifiableList(entries);
+ }
+
+ @JsonProperty("schemaVersion")
+ public String getSchemaVersion() { return SCHEMA_VERSION; }
+
+ @JsonProperty("left")
+ public String getLeft() { return leftBase.toString(); }
+
+ @JsonProperty("right")
+ public String getRight() { return rightBase.toString(); }
+
+ @JsonProperty("changeCount")
+ public int getChangeCount() { return entries.size(); }
+
+ @JsonProperty("changes")
+ public List getEntries() { return entries; }
+
+ public boolean hasDifferences() { return !entries.isEmpty(); }
+}
diff --git a/src/test/java/org/jboss/jws/diag/diff/ConfigDifferTest.java b/src/test/java/org/jboss/jws/diag/diff/ConfigDifferTest.java
new file mode 100644
index 0000000..8d28f9a
--- /dev/null
+++ b/src/test/java/org/jboss/jws/diag/diff/ConfigDifferTest.java
@@ -0,0 +1,340 @@
+package org.jboss.jws.diag.diff;
+
+import org.jboss.jws.diag.config.model.CertificateConfig;
+import org.jboss.jws.diag.config.model.ConfigValue;
+import org.jboss.jws.diag.config.model.ConnectorConfig;
+import org.jboss.jws.diag.config.model.ExecutorConfig;
+import org.jboss.jws.diag.config.model.ServerConfig;
+import org.jboss.jws.diag.config.model.ServiceConfig;
+import org.jboss.jws.diag.config.model.SslHostConfig;
+import org.jboss.jws.diag.diff.model.ChangeType;
+import org.jboss.jws.diag.diff.model.DiffEntry;
+import org.jboss.jws.diag.diff.model.DiffReport;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ConfigDifferTest {
+
+ private static final Path LEFT = Path.of("/left");
+ private static final Path RIGHT = Path.of("/right");
+ private final ConfigDiffer differ = new ConfigDiffer();
+
+ // ── helpers ─────────────────────────────────────────────────────────────
+
+ private static ServerConfig server(int shutdownPort, List connectors,
+ List executors) {
+ return ServerConfig.builder()
+ .shutdownPort(shutdownPort)
+ .shutdownCommand("SHUTDOWN")
+ .listeners(Collections.emptyList())
+ .services(List.of(ServiceConfig.builder()
+ .name("Catalina")
+ .connectors(connectors)
+ .executors(executors)
+ .build()))
+ .build();
+ }
+
+ private static ConnectorConfig connector(int port, int maxThreads, boolean explicit) {
+ return ConnectorConfig.builder()
+ .port(port)
+ .protocol(ConfigValue.defaulted("HTTP/1.1"))
+ .sslEnabled(ConfigValue.defaulted(false))
+ .maxThreads(explicit
+ ? ConfigValue.explicit(maxThreads)
+ : ConfigValue.defaulted(maxThreads))
+ .connectionTimeout(ConfigValue.defaulted(20000))
+ .maxConnections(ConfigValue.defaulted(8192))
+ .compression(ConfigValue.defaulted("off"))
+ .secretRequired(ConfigValue.defaulted(false))
+ .build();
+ }
+
+ private static ExecutorConfig executor(String name, int maxThreads) {
+ return ExecutorConfig.builder()
+ .name(name)
+ .maxThreads(ConfigValue.explicit(maxThreads))
+ .minSpareThreads(ConfigValue.defaulted(10))
+ .threadPriority(ConfigValue.defaulted(5))
+ .maxIdleTime(ConfigValue.defaulted(60000))
+ .build();
+ }
+
+ // ── tests ────────────────────────────────────────────────────────────────
+
+ @Test
+ void identicalConfigs_noEntries() {
+ ServerConfig left = server(8005, List.of(connector(8080, 200, false)), Collections.emptyList());
+ ServerConfig right = server(8005, List.of(connector(8080, 200, false)), Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ assertThat(report.hasDifferences()).isFalse();
+ assertThat(report.getEntries()).isEmpty();
+ }
+
+ @Test
+ void shutdownPortChange_reportedAsChanged() {
+ ServerConfig left = server(8005, Collections.emptyList(), Collections.emptyList());
+ ServerConfig right = server(8006, Collections.emptyList(), Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ assertThat(report.hasDifferences()).isTrue();
+ DiffEntry entry = report.getEntries().get(0);
+ assertThat(entry.getPath()).isEqualTo("server.shutdownPort");
+ assertThat(entry.getType()).isEqualTo(ChangeType.CHANGED);
+ assertThat(entry.getLeftValue()).isEqualTo("8005");
+ assertThat(entry.getRightValue()).isEqualTo("8006");
+ }
+
+ @Test
+ void connectorMaxThreadsChange_reportedWithProvenance() {
+ ServerConfig left = server(8005, List.of(connector(8080, 200, false)), Collections.emptyList());
+ ServerConfig right = server(8005, List.of(connector(8080, 150, true)), Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ assertThat(report.getEntries())
+ .extracting(DiffEntry::getPath)
+ .contains("services[Catalina].connectors[8080].maxThreads");
+
+ DiffEntry e = report.getEntries().stream()
+ .filter(x -> x.getPath().endsWith(".maxThreads"))
+ .findFirst().orElseThrow();
+ assertThat(e.getLeftValue()).isEqualTo("200 (default)");
+ assertThat(e.getRightValue()).isEqualTo("150 (explicit)");
+ }
+
+ @Test
+ void connectorOnlyInRight_reportedAsAdded() {
+ ServerConfig left = server(8005, List.of(connector(8080, 200, false)), Collections.emptyList());
+ ServerConfig right = server(8005,
+ List.of(connector(8080, 200, false), connector(8443, 200, false)),
+ Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry e = report.getEntries().stream()
+ .filter(x -> x.getPath().equals("services[Catalina].connectors[8443]"))
+ .findFirst().orElseThrow();
+ assertThat(e.getType()).isEqualTo(ChangeType.ADDED);
+ assertThat(e.getLeftValue()).isNull();
+ }
+
+ @Test
+ void connectorOnlyInLeft_reportedAsRemoved() {
+ ServerConfig left = server(8005,
+ List.of(connector(8080, 200, false), connector(8443, 200, false)),
+ Collections.emptyList());
+ ServerConfig right = server(8005, List.of(connector(8080, 200, false)), Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry e = report.getEntries().stream()
+ .filter(x -> x.getPath().equals("services[Catalina].connectors[8443]"))
+ .findFirst().orElseThrow();
+ assertThat(e.getType()).isEqualTo(ChangeType.REMOVED);
+ assertThat(e.getRightValue()).isNull();
+ }
+
+ @Test
+ void executorOnlyInRight_reportedAsAdded() {
+ ServerConfig left = server(8005, Collections.emptyList(), Collections.emptyList());
+ ServerConfig right = server(8005, Collections.emptyList(), List.of(executor("pool", 150)));
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry e = report.getEntries().stream()
+ .filter(x -> x.getPath().equals("services[Catalina].executors[pool]"))
+ .findFirst().orElseThrow();
+ assertThat(e.getType()).isEqualTo(ChangeType.ADDED);
+ }
+
+ @Test
+ void executorMaxThreadsChange_detected() {
+ ServerConfig left = server(8005, Collections.emptyList(), List.of(executor("pool", 100)));
+ ServerConfig right = server(8005, Collections.emptyList(), List.of(executor("pool", 200)));
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry e = report.getEntries().stream()
+ .filter(x -> x.getPath().endsWith("executors[pool].maxThreads"))
+ .findFirst().orElseThrow();
+ assertThat(e.getType()).isEqualTo(ChangeType.CHANGED);
+ assertThat(e.getLeftValue()).isEqualTo("100 (explicit)");
+ assertThat(e.getRightValue()).isEqualTo("200 (explicit)");
+ }
+
+ @Test
+ void multipleChanges_allReported() {
+ ServerConfig left = server(8005,
+ List.of(connector(8080, 200, false)),
+ List.of(executor("pool", 100)));
+ ServerConfig right = server(8006,
+ List.of(connector(8080, 150, true)),
+ List.of(executor("pool", 200)));
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ assertThat(report.getChangeCount()).isGreaterThanOrEqualTo(3);
+ }
+
+ @Test
+ void leftAndRightPathsPreservedInReport() {
+ ServerConfig cfg = server(8005, Collections.emptyList(), Collections.emptyList());
+ DiffReport report = differ.diff(LEFT, RIGHT, cfg, cfg);
+
+ assertThat(report.getLeft()).isEqualTo(LEFT.toString());
+ assertThat(report.getRight()).isEqualTo(RIGHT.toString());
+ }
+
+ // ── SSL / certificate / shutdownCommand ──────────────────────────────────
+
+ @Test
+ void shutdownCommandChange_reportedAsChanged() {
+ ServerConfig left = ServerConfig.builder()
+ .shutdownPort(8005).shutdownCommand("SHUTDOWN")
+ .listeners(Collections.emptyList())
+ .services(List.of(ServiceConfig.builder()
+ .name("Catalina").connectors(Collections.emptyList())
+ .executors(Collections.emptyList()).build()))
+ .build();
+ ServerConfig right = ServerConfig.builder()
+ .shutdownPort(8005).shutdownCommand("STOP")
+ .listeners(Collections.emptyList())
+ .services(List.of(ServiceConfig.builder()
+ .name("Catalina").connectors(Collections.emptyList())
+ .executors(Collections.emptyList()).build()))
+ .build();
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry entry = report.getEntries().stream()
+ .filter(e -> e.getPath().equals("server.shutdownCommand"))
+ .findFirst().orElseThrow();
+ assertThat(entry.getType()).isEqualTo(ChangeType.CHANGED);
+ assertThat(entry.getLeftValue()).isEqualTo("SHUTDOWN");
+ assertThat(entry.getRightValue()).isEqualTo("STOP");
+ }
+
+ @Test
+ void sslHostConfigAddedInRight_reportedAsAdded() {
+ ServerConfig left = server(8005,
+ List.of(connector(8443, 200, false)), Collections.emptyList());
+ ServerConfig right = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2,TLSv1.3",
+ Collections.emptyList())))),
+ Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry entry = report.getEntries().stream()
+ .filter(e -> e.getPath().contains(".ssl[") && e.getType() == ChangeType.ADDED)
+ .findFirst().orElseThrow();
+ assertThat(entry.getRightValue()).isEqualTo("_default_");
+ }
+
+ @Test
+ void sslHostConfigProtocolChange_reportedAsChanged() {
+ ServerConfig left = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2", Collections.emptyList())))),
+ Collections.emptyList());
+ ServerConfig right = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2,TLSv1.3",
+ Collections.emptyList())))),
+ Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry entry = report.getEntries().stream()
+ .filter(e -> e.getPath().endsWith(".protocols"))
+ .findFirst().orElseThrow();
+ assertThat(entry.getType()).isEqualTo(ChangeType.CHANGED);
+ assertThat(entry.getLeftValue()).isEqualTo("TLSv1.2");
+ assertThat(entry.getRightValue()).isEqualTo("TLSv1.2,TLSv1.3");
+ }
+
+ @Test
+ void certificateAddedInRight_reportedAsAdded() {
+ CertificateConfig cert = CertificateConfig.builder()
+ .keystoreFile("conf/server.jks")
+ .keystoreType(ConfigValue.explicit("JKS"))
+ .type("RSA")
+ .build();
+ ServerConfig left = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2",
+ Collections.emptyList())))),
+ Collections.emptyList());
+ ServerConfig right = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2",
+ List.of(cert))))),
+ Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry entry = report.getEntries().stream()
+ .filter(e -> e.getPath().contains(".certificate[") && e.getType() == ChangeType.ADDED)
+ .findFirst().orElseThrow();
+ assertThat(entry.getRightValue()).isEqualTo("RSA");
+ }
+
+ @Test
+ void certificateKeystoreFileChange_reportedAsChanged() {
+ CertificateConfig leftCert = CertificateConfig.builder()
+ .keystoreFile("conf/old.jks")
+ .keystoreType(ConfigValue.explicit("JKS"))
+ .type("RSA")
+ .build();
+ CertificateConfig rightCert = CertificateConfig.builder()
+ .keystoreFile("conf/new.jks")
+ .keystoreType(ConfigValue.explicit("JKS"))
+ .type("RSA")
+ .build();
+ ServerConfig left = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2", List.of(leftCert))))),
+ Collections.emptyList());
+ ServerConfig right = server(8005,
+ List.of(sslConnector(8443, List.of(ssl("_default_", "TLSv1.2", List.of(rightCert))))),
+ Collections.emptyList());
+
+ DiffReport report = differ.diff(LEFT, RIGHT, left, right);
+
+ DiffEntry entry = report.getEntries().stream()
+ .filter(e -> e.getPath().endsWith(".keystoreFile"))
+ .findFirst().orElseThrow();
+ assertThat(entry.getType()).isEqualTo(ChangeType.CHANGED);
+ assertThat(entry.getLeftValue()).isEqualTo("conf/old.jks");
+ assertThat(entry.getRightValue()).isEqualTo("conf/new.jks");
+ }
+
+ // ── helpers ──────────────────────────────────────────────────────────────
+
+ private static ConnectorConfig sslConnector(int port, List sslHostConfigs) {
+ return ConnectorConfig.builder()
+ .port(port)
+ .protocol(ConfigValue.explicit("org.apache.coyote.http11.Http11NioProtocol"))
+ .sslEnabled(ConfigValue.explicit(true))
+ .maxThreads(ConfigValue.defaulted(200))
+ .connectionTimeout(ConfigValue.defaulted(20000))
+ .maxConnections(ConfigValue.defaulted(8192))
+ .compression(ConfigValue.defaulted("off"))
+ .secretRequired(ConfigValue.defaulted(false))
+ .sslHostConfigs(sslHostConfigs)
+ .build();
+ }
+
+ private static SslHostConfig ssl(String hostName, String protocols,
+ List certs) {
+ return SslHostConfig.builder()
+ .hostName(hostName)
+ .protocols(protocols)
+ .certificates(certs)
+ .build();
+ }
+}
diff --git a/src/test/java/org/jboss/jws/diag/diff/DiffHumanFormatterTest.java b/src/test/java/org/jboss/jws/diag/diff/DiffHumanFormatterTest.java
new file mode 100644
index 0000000..6e6cba8
--- /dev/null
+++ b/src/test/java/org/jboss/jws/diag/diff/DiffHumanFormatterTest.java
@@ -0,0 +1,93 @@
+package org.jboss.jws.diag.diff;
+
+import org.jboss.jws.diag.diff.formatter.DiffHumanFormatter;
+import org.jboss.jws.diag.diff.model.ChangeType;
+import org.jboss.jws.diag.diff.model.DiffEntry;
+import org.jboss.jws.diag.diff.model.DiffReport;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DiffHumanFormatterTest {
+
+ private static final Path LEFT = Path.of("/opt/tomcat-a");
+ private static final Path RIGHT = Path.of("/opt/tomcat-b");
+ private final DiffHumanFormatter formatter = new DiffHumanFormatter();
+
+ private DiffReport report(List entries) {
+ return new DiffReport(LEFT, RIGHT, entries);
+ }
+
+ @Test
+ void noDifferences_showsNoDifferencesMessage() {
+ String out = formatter.format(report(Collections.emptyList()));
+
+ assertThat(out).contains("No differences found.");
+ assertThat(out).contains("/opt/tomcat-a");
+ assertThat(out).contains("/opt/tomcat-b");
+ }
+
+ @Test
+ void changedEntry_showsArrowBetweenValues() {
+ DiffEntry e = new DiffEntry("server.shutdownPort", ChangeType.CHANGED, "8005", "8006");
+ String out = formatter.format(report(List.of(e)));
+
+ assertThat(out).contains("8005");
+ assertThat(out).contains("8006");
+ assertThat(out).contains("→");
+ assertThat(out).contains("shutdownPort");
+ }
+
+ @Test
+ void addedEntry_showsAddedLabel() {
+ DiffEntry e = new DiffEntry("services[Catalina].connectors[8443]",
+ ChangeType.ADDED, null, "port 8443");
+ String out = formatter.format(report(List.of(e)));
+
+ assertThat(out).contains("+");
+ assertThat(out).contains("added in right");
+ }
+
+ @Test
+ void removedEntry_showsRemovedLabel() {
+ DiffEntry e = new DiffEntry("services[Catalina].connectors[8443]",
+ ChangeType.REMOVED, "port 8443", null);
+ String out = formatter.format(report(List.of(e)));
+
+ assertThat(out).contains("-");
+ assertThat(out).contains("only in left");
+ }
+
+ @Test
+ void changeCountShownInHeader() {
+ List entries = List.of(
+ new DiffEntry("server.shutdownPort", ChangeType.CHANGED, "8005", "8006"),
+ new DiffEntry("services[Catalina].connectors[8080].maxThreads",
+ ChangeType.CHANGED, "200 (default)", "150 (explicit)")
+ );
+ String out = formatter.format(report(entries));
+
+ assertThat(out).contains("2 change(s) found");
+ }
+
+ @Test
+ void groupingBySection_relatedEntriesUnderSameSection() {
+ List entries = List.of(
+ new DiffEntry("services[Catalina].connectors[8080].maxThreads",
+ ChangeType.CHANGED, "200 (default)", "150 (explicit)"),
+ new DiffEntry("services[Catalina].connectors[8080].connectionTimeout",
+ ChangeType.CHANGED, "20000 (default)", "60000 (explicit)")
+ );
+ String out = formatter.format(report(entries));
+
+ // Both entries should appear under the same section header
+ long sectionHeaderCount = out.lines()
+ .filter(l -> l.contains("connectors[8080]") && !l.stripLeading().startsWith("~"))
+ .count();
+ assertThat(sectionHeaderCount).isEqualTo(1);
+ }
+}
diff --git a/src/test/java/org/jboss/jws/diag/diff/DiffJsonFormatterTest.java b/src/test/java/org/jboss/jws/diag/diff/DiffJsonFormatterTest.java
new file mode 100644
index 0000000..622a187
--- /dev/null
+++ b/src/test/java/org/jboss/jws/diag/diff/DiffJsonFormatterTest.java
@@ -0,0 +1,89 @@
+package org.jboss.jws.diag.diff;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.jboss.jws.diag.diff.formatter.DiffJsonFormatter;
+import org.jboss.jws.diag.diff.model.ChangeType;
+import org.jboss.jws.diag.diff.model.DiffEntry;
+import org.jboss.jws.diag.diff.model.DiffReport;
+import org.junit.jupiter.api.Test;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DiffJsonFormatterTest {
+
+ private static final Path LEFT = Path.of("/opt/tomcat-a");
+ private static final Path RIGHT = Path.of("/opt/tomcat-b");
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private final DiffJsonFormatter formatter = new DiffJsonFormatter();
+
+ private JsonNode parse(DiffReport report) throws Exception {
+ return MAPPER.readTree(formatter.format(report));
+ }
+
+ @Test
+ void schemaVersionPresentAtRoot() throws Exception {
+ DiffReport report = new DiffReport(LEFT, RIGHT, Collections.emptyList());
+ JsonNode root = parse(report);
+
+ assertThat(root.get("schemaVersion").asText()).isEqualTo("1.0");
+ }
+
+ @Test
+ void leftAndRightPathsInRoot() throws Exception {
+ DiffReport report = new DiffReport(LEFT, RIGHT, Collections.emptyList());
+ JsonNode root = parse(report);
+
+ assertThat(root.get("left").asText()).isEqualTo("/opt/tomcat-a");
+ assertThat(root.get("right").asText()).isEqualTo("/opt/tomcat-b");
+ }
+
+ @Test
+ void changeCountMatchesEntriesSize() throws Exception {
+ List entries = List.of(
+ new DiffEntry("server.shutdownPort", ChangeType.CHANGED, "8005", "8006")
+ );
+ DiffReport report = new DiffReport(LEFT, RIGHT, entries);
+ JsonNode root = parse(report);
+
+ assertThat(root.get("changeCount").asInt()).isEqualTo(1);
+ assertThat(root.get("changes").size()).isEqualTo(1);
+ }
+
+ @Test
+ void changedEntry_hasAllFields() throws Exception {
+ DiffEntry entry = new DiffEntry("server.shutdownPort", ChangeType.CHANGED, "8005", "8006");
+ JsonNode root = parse(new DiffReport(LEFT, RIGHT, List.of(entry)));
+
+ JsonNode change = root.get("changes").get(0);
+ assertThat(change.get("path").asText()).isEqualTo("server.shutdownPort");
+ assertThat(change.get("type").asText()).isEqualTo("CHANGED");
+ assertThat(change.get("left").asText()).isEqualTo("8005");
+ assertThat(change.get("right").asText()).isEqualTo("8006");
+ }
+
+ @Test
+ void addedEntry_rightPresentLeftAbsent() throws Exception {
+ DiffEntry entry = new DiffEntry("services[Catalina].connectors[8443]",
+ ChangeType.ADDED, null, "port 8443");
+ JsonNode root = parse(new DiffReport(LEFT, RIGHT, List.of(entry)));
+
+ JsonNode change = root.get("changes").get(0);
+ assertThat(change.get("type").asText()).isEqualTo("ADDED");
+ assertThat(change.has("left")).isFalse();
+ assertThat(change.get("right").asText()).isEqualTo("port 8443");
+ }
+
+ @Test
+ void emptyDiff_changesIsEmptyArray() throws Exception {
+ JsonNode root = parse(new DiffReport(LEFT, RIGHT, Collections.emptyList()));
+
+ assertThat(root.get("changes").isArray()).isTrue();
+ assertThat(root.get("changes").size()).isEqualTo(0);
+ assertThat(root.get("changeCount").asInt()).isEqualTo(0);
+ }
+}