ScoredMarkdownReporter.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
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.nullability;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/**
 * Generates a severity-grouped Markdown report from scored match entries.
 *
 * <p>The report groups matches by severity level (WARNING, CLEANUP, QUICKASSIST,
 * INFO, IGNORE) and sorts them by descending {@code trivialChange} within each
 * group.</p>
 *
 * @since 1.2.6
 */
public class ScoredMarkdownReporter {

	private static final Map<MatchSeverity, String> SEVERITY_HEADERS = new EnumMap<>(MatchSeverity.class);
	static {
		SEVERITY_HEADERS.put(MatchSeverity.WARNING, "\u26a0\ufe0f Warnings"); //$NON-NLS-1$
		SEVERITY_HEADERS.put(MatchSeverity.CLEANUP, "\ud83d\udd27 Cleanup"); //$NON-NLS-1$
		SEVERITY_HEADERS.put(MatchSeverity.QUICKASSIST, "\ud83d\udca1 QuickAssist"); //$NON-NLS-1$
		SEVERITY_HEADERS.put(MatchSeverity.INFO, "\u2139\ufe0f Info"); //$NON-NLS-1$
		SEVERITY_HEADERS.put(MatchSeverity.IGNORE, "\ud83d\udeab Ignored"); //$NON-NLS-1$
	}

	private static final Map<MatchSeverity, String> SEVERITY_DESCRIPTIONS = new EnumMap<>(MatchSeverity.class);
	static {
		SEVERITY_DESCRIPTIONS.put(MatchSeverity.WARNING,
				"These locations have a high risk of NullPointerException:"); //$NON-NLS-1$
		SEVERITY_DESCRIPTIONS.put(MatchSeverity.CLEANUP,
				"Recommended changes, null safety unclear:"); //$NON-NLS-1$
		SEVERITY_DESCRIPTIONS.put(MatchSeverity.QUICKASSIST,
				"Optional, developer should decide:"); //$NON-NLS-1$
		SEVERITY_DESCRIPTIONS.put(MatchSeverity.INFO,
				"Probably safe locations, for information only:"); //$NON-NLS-1$
		SEVERITY_DESCRIPTIONS.put(MatchSeverity.IGNORE,
				"Provably non-null, no change required:"); //$NON-NLS-1$
	}

	/**
	 * Generates a Markdown report from scored entries.
	 *
	 * @param entries the scored match entries
	 * @return the Markdown content
	 */
	public String generate(List<ScoredMatchEntry> entries) {
		Objects.requireNonNull(entries, "entries"); //$NON-NLS-1$
		StringBuilder sb = new StringBuilder();
		sb.append("# Refactoring Mining Report \u2014 ").append(LocalDate.now()).append("\n\n"); //$NON-NLS-1$ //$NON-NLS-2$

		// Group by severity
		Map<MatchSeverity, List<ScoredMatchEntry>> bySeverity = groupBySeverity(entries);

		// Summary
		sb.append("## Summary\n\n"); //$NON-NLS-1$
		sb.append("| Severity | Count |\n"); //$NON-NLS-1$
		sb.append("|----------|-------|\n"); //$NON-NLS-1$
		for (MatchSeverity sev : MatchSeverity.values()) {
			List<ScoredMatchEntry> group = bySeverity.getOrDefault(sev, List.of());
			if (!group.isEmpty()) {
				sb.append("| ").append(SEVERITY_HEADERS.get(sev)) //$NON-NLS-1$
						.append(" | ").append(group.size()).append(" |\n"); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
		sb.append("\n"); //$NON-NLS-1$

		// Reverse order: WARNING first (highest severity), IGNORE last
		MatchSeverity[] displayOrder = {
				MatchSeverity.WARNING, MatchSeverity.CLEANUP,
				MatchSeverity.QUICKASSIST, MatchSeverity.INFO, MatchSeverity.IGNORE };

		for (MatchSeverity sev : displayOrder) {
			List<ScoredMatchEntry> group = bySeverity.getOrDefault(sev, List.of());
			if (group.isEmpty()) {
				continue;
			}

			sb.append("## ").append(SEVERITY_HEADERS.get(sev)) //$NON-NLS-1$
					.append(" \u2014 ").append(group.size()).append(" Matches\n\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append(SEVERITY_DESCRIPTIONS.get(sev)).append("\n\n"); //$NON-NLS-1$

			if (sev == MatchSeverity.IGNORE) {
				// For IGNORE, show a compact summary
				appendIgnoredSummary(sb, group);
			} else {
				// Sort by trivialChange descending
				List<ScoredMatchEntry> sorted = new ArrayList<>(group);
				sorted.sort(Comparator.comparingInt(
						(ScoredMatchEntry e) -> e.score().trivialChange()).reversed());

				for (ScoredMatchEntry entry : sorted) {
					sb.append("- `").append(entry.file()).append(":").append(entry.line()) //$NON-NLS-1$ //$NON-NLS-2$
							.append("` \u2014 `").append(truncate(entry.matchedCode(), 80)) //$NON-NLS-1$
							.append("` \u2014 ").append(entry.score().reason()); //$NON-NLS-1$
					if (entry.suggestedReplacement() != null) {
						sb.append(" \u2192 `").append(truncate(entry.suggestedReplacement(), 60)).append("`"); //$NON-NLS-1$ //$NON-NLS-2$
					}
					sb.append("\n"); //$NON-NLS-1$
				}
			}
			sb.append("\n"); //$NON-NLS-1$
		}

		return sb.toString();
	}

	private void appendIgnoredSummary(StringBuilder sb, List<ScoredMatchEntry> group) {
		// Group by reason category for compact display
		Map<String, Integer> reasonCounts = new LinkedHashMap<>();
		for (ScoredMatchEntry entry : group) {
			String key = summarizeReason(entry.score().reason());
			reasonCounts.merge(key, 1, Integer::sum);
		}
		for (Map.Entry<String, Integer> entry : reasonCounts.entrySet()) {
			sb.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("x\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
		}
	}

	private String summarizeReason(String reason) {
		if (reason.contains("toString_never_null")) { //$NON-NLS-1$
			return "StringBuilder/StringBuffer.toString()"; //$NON-NLS-1$
		}
		if (reason.contains("enum")) { //$NON-NLS-1$
			return "Enum.toString()"; //$NON-NLS-1$
		}
		if (reason.contains("this")) { //$NON-NLS-1$
			return "this.toString()"; //$NON-NLS-1$
		}
		if (reason.contains("primitive")) { //$NON-NLS-1$
			return "Primitive type"; //$NON-NLS-1$
		}
		if (reason.contains("parsed_node_valid") || reason.contains("structural_child")) { //$NON-NLS-1$ //$NON-NLS-2$
			return "AST-Node.toString()"; //$NON-NLS-1$
		}
		if (reason.contains("factory_never_null")) { //$NON-NLS-1$
			return "Factory method (java.time, etc.)"; //$NON-NLS-1$
		}
		if (reason.contains("new")) { //$NON-NLS-1$
			return "'new' expression"; //$NON-NLS-1$
		}
		if (reason.contains("null guard")) { //$NON-NLS-1$
			return "Inside null guard"; //$NON-NLS-1$
		}
		return reason;
	}

	private Map<MatchSeverity, List<ScoredMatchEntry>> groupBySeverity(List<ScoredMatchEntry> entries) {
		Map<MatchSeverity, List<ScoredMatchEntry>> result = new EnumMap<>(MatchSeverity.class);
		for (ScoredMatchEntry entry : entries) {
			result.computeIfAbsent(entry.score().severity(), k -> new ArrayList<>()).add(entry);
		}
		return result;
	}

	private static String truncate(String s, int maxLen) {
		if (s == null) {
			return ""; //$NON-NLS-1$
		}
		String cleaned = s.replace("\n", " ").replace("\r", ""); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
		if (cleaned.length() <= maxLen) {
			return cleaned;
		}
		return cleaned.substring(0, maxLen - 3) + "..."; //$NON-NLS-1$
	}
}