MultiFileCleanUpDiagnostics.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* 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.cleanup.multifile;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
/** Immutable aggregate diagnostics for one coordinated cleanup planning run. */
public record MultiFileCleanUpDiagnostics(String cleanupId, MultiFileScopeDiagnostic scope,
List<MultiFileCandidateDiagnostic> candidates) {
/** Validates and deterministically orders diagnostics. */
public MultiFileCleanUpDiagnostics {
cleanupId= Objects.requireNonNull(cleanupId);
scope= scope == null ? MultiFileScopeDiagnostic.empty() : scope;
List<MultiFileCandidateDiagnostic> normalized= candidates == null ? List.of() : new ArrayList<>(candidates);
normalized.sort(Comparator.comparing(MultiFileCandidateDiagnostic::ownerCompilationUnitHandle)
.thenComparing(MultiFileCandidateDiagnostic::candidateId)
.thenComparing(candidate -> candidate.outcome().name())
.thenComparing(MultiFileCandidateDiagnostic::reasonCode));
candidates= List.copyOf(normalized);
}
/** @return empty diagnostics for a cleanup with no coordinated candidate */
public static MultiFileCleanUpDiagnostics empty() {
return new MultiFileCleanUpDiagnostics("unknown", MultiFileScopeDiagnostic.empty(), List.of()); //$NON-NLS-1$
}
/** Returns the impact contract shared by every coordinated multi-file cleanup. */
public CleanUpImpact impact() {
return CleanUpImpact.PROJECT_CLOSED;
}
/** Returns the number of distinct selected and discovered source units. */
public int affectedCompilationUnitCount() {
Set<String> handles= new LinkedHashSet<>(scope.selectedCompilationUnitHandles());
handles.addAll(scope.addedCompilationUnitHandles());
for (MultiFileCandidateDiagnostic candidate : candidates) {
handles.add(candidate.ownerCompilationUnitHandle());
handles.addAll(candidate.relatedCompilationUnitHandles());
}
handles.remove(""); //$NON-NLS-1$
return handles.size();
}
/** Returns a copy containing the observed host scope expansion. */
public MultiFileCleanUpDiagnostics withScope(MultiFileScopeDiagnostic newScope) {
return new MultiFileCleanUpDiagnostics(cleanupId, newScope, candidates);
}
/** Adds one concise, nonfatal planning summary to the cleanup preview status. */
public void appendSummary(RefactoringStatus status) {
if (status == null || candidates.isEmpty() && scope.addedCompilationUnitHandles().isEmpty()
&& scope.complete()) {
return;
}
long transformed= candidates.stream()
.filter(candidate -> candidate.outcome() == MultiFileCandidateOutcome.TRANSFORMED).count();
Map<String, Long> rejectedByReason= new TreeMap<>();
for (MultiFileCandidateDiagnostic candidate : candidates) {
if (candidate.outcome() == MultiFileCandidateOutcome.REJECTED) {
rejectedByReason.merge(candidate.reasonCode(), Long.valueOf(1), Long::sum);
}
}
long rejected= rejectedByReason.values().stream().mapToLong(Long::longValue).sum();
StringBuilder summary= new StringBuilder(240)
.append('[').append(impact()).append("] Coordinated cleanup ").append(cleanupId).append(": ") //$NON-NLS-1$ //$NON-NLS-2$
.append(scope.selectedCompilationUnitHandles().size()).append(" selected, ") //$NON-NLS-1$
.append(scope.addedCompilationUnitHandles().size()).append(" added, ") //$NON-NLS-1$
.append(affectedCompilationUnitCount()).append(" affected; ") //$NON-NLS-1$
.append(transformed).append(" transformed, ").append(rejected).append(" rejected"); //$NON-NLS-1$ //$NON-NLS-2$
if (!scope.complete()) {
summary.append("; scope incomplete (").append(scope.reasonCode()).append(')'); //$NON-NLS-1$
}
if (!rejectedByReason.isEmpty()) {
summary.append(" ("); //$NON-NLS-1$
int index= 0;
for (Map.Entry<String, Long> entry : rejectedByReason.entrySet()) {
if (index++ > 0) {
summary.append(", "); //$NON-NLS-1$
}
summary.append(entry.getKey()).append('=').append(entry.getValue());
}
summary.append(')');
}
summary.append(". ").append(impact().compatibilityStatement()); //$NON-NLS-1$
status.addInfo(summary.toString());
}
/**
* Returns deterministic JSON suitable for CI artifacts and headless callers.
* Compilation-unit handles are replaced with stable opaque identifiers so the
* explicit export does not disclose workspace paths or Java model handles.
*
* @return privacy-preserving deterministic JSON
*/
public String toJson() {
StringBuilder json= new StringBuilder(768);
json.append('{');
property(json, "schemaVersion", "2").append(','); //$NON-NLS-1$ //$NON-NLS-2$
property(json, "cleanupId", cleanupId).append(','); //$NON-NLS-1$
property(json, "impact", impact().name()).append(','); //$NON-NLS-1$
json.append("\"affectedCompilationUnitCount\":").append(affectedCompilationUnitCount()).append(','); //$NON-NLS-1$
json.append("\"saveActionEligible\":").append(impact().saveActionEligible()).append(','); //$NON-NLS-1$
json.append("\"explicitPreviewRequired\":").append(impact().explicitPreviewRequired()).append(','); //$NON-NLS-1$
property(json, "compatibilityStatement", impact().compatibilityStatement()).append(','); //$NON-NLS-1$
json.append("\"scope\":{"); //$NON-NLS-1$
arrayProperty(json, "selectedCompilationUnits", externalUnitIds(scope.selectedCompilationUnitHandles())).append(','); //$NON-NLS-1$
arrayProperty(json, "addedCompilationUnits", externalUnitIds(scope.addedCompilationUnitHandles())).append(','); //$NON-NLS-1$
property(json, "reasonCode", scope.reasonCode()).append(','); //$NON-NLS-1$
property(json, "explanation", scope.explanation()).append(','); //$NON-NLS-1$
json.append("\"complete\":").append(scope.complete()).append("},"); //$NON-NLS-1$ //$NON-NLS-2$
json.append("\"candidates\":["); //$NON-NLS-1$
for (int index= 0; index < candidates.size(); index++) {
if (index > 0) {
json.append(',');
}
MultiFileCandidateDiagnostic candidate= candidates.get(index);
json.append('{');
property(json, "candidateId", candidate.candidateId()).append(','); //$NON-NLS-1$
property(json, "ownerCompilationUnit", externalUnitId(candidate.ownerCompilationUnitHandle())).append(','); //$NON-NLS-1$
property(json, "outcome", candidate.outcome().name()).append(','); //$NON-NLS-1$
property(json, "reasonCode", candidate.reasonCode()).append(','); //$NON-NLS-1$
property(json, "message", candidate.message()).append(','); //$NON-NLS-1$
arrayProperty(json, "relatedCompilationUnits", externalUnitIds(candidate.relatedCompilationUnitHandles())); //$NON-NLS-1$
json.append('}');
}
return json.append("]}").toString(); //$NON-NLS-1$
}
private static List<String> externalUnitIds(List<String> handles) {
return handles.stream().map(MultiFileCleanUpDiagnostics::externalUnitId).sorted().toList();
}
private static String externalUnitId(String handle) {
try {
byte[] digest= MessageDigest.getInstance("SHA-256").digest(handle.getBytes(StandardCharsets.UTF_8)); //$NON-NLS-1$
return "cu-" + HexFormat.of().formatHex(digest, 0, 6); //$NON-NLS-1$
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e); //$NON-NLS-1$
}
}
private static StringBuilder property(StringBuilder json, String name, String value) {
return json.append('"').append(escape(name)).append("\":\"") //$NON-NLS-1$
.append(escape(value)).append('"');
}
private static StringBuilder arrayProperty(StringBuilder json, String name, List<String> values) {
json.append('"').append(escape(name)).append("\":["); //$NON-NLS-1$
for (int index= 0; index < values.size(); index++) {
if (index > 0) {
json.append(',');
}
json.append('"').append(escape(values.get(index))).append('"');
}
return json.append(']');
}
private static String escape(String value) {
StringBuilder escaped= new StringBuilder(value.length() + 16);
for (int index= 0; index < value.length(); index++) {
char character= value.charAt(index);
switch (character) {
case '"' -> escaped.append("\\\""); //$NON-NLS-1$
case '\\' -> escaped.append("\\\\"); //$NON-NLS-1$
case '\b' -> escaped.append("\\b"); //$NON-NLS-1$
case '\f' -> escaped.append("\\f"); //$NON-NLS-1$
case '\n' -> escaped.append("\\n"); //$NON-NLS-1$
case '\r' -> escaped.append("\\r"); //$NON-NLS-1$
case '\t' -> escaped.append("\\t"); //$NON-NLS-1$
default -> {
if (character < 0x20) {
escaped.append(String.format("\\u%04x", Integer.valueOf(character))); //$NON-NLS-1$
} else {
escaped.append(character);
}
}
}
}
return escaped.toString();
}
}