DeltaReport.java
/*******************************************************************************
* Copyright (c) 2025 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
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.mining.core.comparison;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
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.stream.Collectors;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* Holds the results of comparing Gemini mining results against
* a reference evaluation. Provides gap analysis and suggestions
* for improving the mining pipeline.
*/
public class DeltaReport {
private final List<GapEntry> gaps = new ArrayList<>();
/**
* Adds a gap entry to the report.
*
* @param entry the gap entry
*/
public void addGap(GapEntry entry) {
if (entry != null) {
gaps.add(entry);
}
}
/**
* Returns all gap entries.
*
* @return unmodifiable list of all gaps
*/
public List<GapEntry> getGaps() {
return Collections.unmodifiableList(gaps);
}
/**
* Groups gaps by category.
*
* @return map of category to list of gaps
*/
public Map<GapCategory, List<GapEntry>> groupByCategory() {
return gaps.stream()
.collect(Collectors.groupingBy(
GapEntry::category,
LinkedHashMap::new,
Collectors.toList()));
}
/**
* Returns a summary of gaps per category.
*
* @return map of category to count
*/
public Map<GapCategory, Long> summarize() {
return gaps.stream()
.collect(Collectors.groupingBy(
GapEntry::category,
LinkedHashMap::new,
Collectors.counting()));
}
/**
* Returns the total number of gaps.
*
* @return gap count
*/
public int getTotalGaps() {
return gaps.size();
}
/**
* Formats the delta report as a human-readable string.
*
* @return formatted report
*/
public String format() {
StringBuilder sb = new StringBuilder();
sb.append("=== Delta Report ===\n"); //$NON-NLS-1$
sb.append("Total gaps: ").append(gaps.size()).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
Map<GapCategory, List<GapEntry>> grouped = groupByCategory();
for (Map.Entry<GapCategory, List<GapEntry>> entry : grouped.entrySet()) {
sb.append("## ").append(entry.getKey()).append(" (") //$NON-NLS-1$ //$NON-NLS-2$
.append(entry.getValue().size()).append(")\n"); //$NON-NLS-1$
for (GapEntry gap : entry.getValue()) {
sb.append(" - ").append(gap.commitHash(), 0, Math.min(7, gap.commitHash().length())); //$NON-NLS-1$
if (gap.suggestion() != null) {
sb.append(": ").append(gap.suggestion()); //$NON-NLS-1$
}
sb.append("\n"); //$NON-NLS-1$
}
sb.append("\n"); //$NON-NLS-1$
}
return sb.toString();
}
/**
* Formats the delta report as Markdown with actionable suggestions per category.
*
* @return Markdown-formatted report
*/
public String formatMarkdown() {
StringBuilder sb = new StringBuilder();
sb.append("# Delta Report\n\n"); //$NON-NLS-1$
sb.append("**Total gaps:** ").append(gaps.size()).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
if (gaps.isEmpty()) {
sb.append("No gaps found — mining results match the reference evaluation.\n"); //$NON-NLS-1$
return sb.toString();
}
sb.append("## Gap Distribution\n\n"); //$NON-NLS-1$
sb.append("| Category | Count | Suggested Action |\n"); //$NON-NLS-1$
sb.append("|----------|-------|------------------|\n"); //$NON-NLS-1$
Map<GapCategory, Long> summary = summarize();
for (Map.Entry<GapCategory, Long> entry : summary.entrySet()) {
sb.append("| ").append(entry.getKey()) //$NON-NLS-1$
.append(" | ").append(entry.getValue()) //$NON-NLS-1$
.append(" | ").append(entry.getKey().suggestedAction()) //$NON-NLS-1$
.append(" |\n"); //$NON-NLS-1$
}
sb.append("\n"); //$NON-NLS-1$
sb.append("## Gap Details\n\n"); //$NON-NLS-1$
Map<GapCategory, List<GapEntry>> grouped = groupByCategory();
for (Map.Entry<GapCategory, List<GapEntry>> entry : grouped.entrySet()) {
sb.append("### ").append(entry.getKey()) //$NON-NLS-1$
.append(" (").append(entry.getValue().size()).append(")\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
for (GapEntry gap : entry.getValue()) {
String shortHash = gap.commitHash() != null
? gap.commitHash().substring(0, Math.min(7, gap.commitHash().length()))
: "???????"; //$NON-NLS-1$
sb.append("- **").append(shortHash).append("**"); //$NON-NLS-1$ //$NON-NLS-2$
if (gap.suggestion() != null) {
sb.append(": ").append(gap.suggestion()); //$NON-NLS-1$
}
if (gap.geminiValue() != null && gap.referenceValue() != null) {
String gemini = sanitizeInlineValue(gap.geminiValue());
String reference = sanitizeInlineValue(gap.referenceValue());
sb.append(" (gemini=`").append(gemini) //$NON-NLS-1$
.append("` → reference=`").append(reference).append("`)"); //$NON-NLS-1$ //$NON-NLS-2$
}
sb.append("\n"); //$NON-NLS-1$
}
sb.append("\n"); //$NON-NLS-1$
}
return sb.toString();
}
/**
* Serializes the delta report as a JSON string.
*
* @return JSON representation of the report
*/
public String toJson() {
Gson gson = new GsonBuilder().setPrettyPrinting().create();
Map<String, Object> reportMap = new LinkedHashMap<>();
reportMap.put("totalGaps", gaps.size()); //$NON-NLS-1$
reportMap.put("summary", summarize()); //$NON-NLS-1$
reportMap.put("gaps", gaps); //$NON-NLS-1$
return gson.toJson(reportMap);
}
/**
* Writes the delta report to JSON and Markdown files in the given directory.
*
* @param outputDir directory where files will be written
* @throws IOException if file writing fails
*/
public void writeToFiles(Path outputDir) throws IOException {
Files.createDirectories(outputDir);
Files.writeString(outputDir.resolve("delta-report.json"), //$NON-NLS-1$
toJson(), StandardCharsets.UTF_8);
Files.writeString(outputDir.resolve("delta-report.md"), //$NON-NLS-1$
formatMarkdown(), StandardCharsets.UTF_8);
}
private static final int MAX_INLINE_LENGTH = 80;
/**
* Sanitizes a value for inline Markdown rendering: truncates to a single line,
* escapes backticks, and limits length.
*/
static String sanitizeInlineValue(String value) {
if (value == null) {
return ""; //$NON-NLS-1$
}
// Take only the first line
int nl = value.indexOf('\n');
String single = nl >= 0 ? value.substring(0, nl) : value;
// Escape backticks
single = single.replace("`", "'"); //$NON-NLS-1$ //$NON-NLS-2$
// Truncate
if (single.length() > MAX_INLINE_LENGTH) {
single = single.substring(0, MAX_INLINE_LENGTH) + "…"; //$NON-NLS-1$
}
return single;
}
}