MarkdownReporter.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.report;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.sandbox.mining.report.MiningReport.MatchEntry;

/**
 * Generates a Markdown report from mining results.
 */
public class MarkdownReporter {

	/**
	 * Generates a Markdown report string from the given mining report.
	 *
	 * @param report the mining report
	 * @return the Markdown content
	 */
	public String generate(MiningReport report) {
		return generate(report, null);
	}

	/**
	 * Generates a delta-aware Markdown report string. When a previous report
	 * is provided, the summary table includes "New" and "Known" columns, and
	 * the details section highlights only matches that were not present in the
	 * previous report.
	 *
	 * @param report         the current mining report
	 * @param previousReport the previous report for delta computation, or {@code null}
	 * @return the Markdown content
	 */
	public String generate(MiningReport report, MiningReport previousReport) {
		Set<String> knownMatchKeys = previousReport != null ? buildMatchKeys(previousReport) : Set.of();

		StringBuilder sb = new StringBuilder();
		sb.append("# Refactoring Mining Report — ").append(LocalDate.now()).append("\n\n");

		// Summary table
		boolean hasDelta = !knownMatchKeys.isEmpty();
		if (hasDelta) {
			sb.append("## Summary\n");
			sb.append("| Eclipse Project | Files | Matches | New | Known | Rules |\n");
			sb.append("|----------------|-------|---------|-----|-------|-------|\n");
		} else {
			sb.append("## Summary\n");
			sb.append("| Eclipse Project | Files | Matches | Rules |\n");
			sb.append("|----------------|-------|---------|-------|\n");
		}

		Map<String, Integer> fileCounts = report.getFileCounts();
		Map<String, List<MatchEntry>> byRepo = report.getMatchesByRepo();
		Map<String, String> errors = report.getErrors();

		for (Map.Entry<String, Integer> entry : fileCounts.entrySet()) {
			String repoName = entry.getKey();
			int files = entry.getValue();
			List<MatchEntry> repoMatches = byRepo.getOrDefault(repoName, List.of());
			long rules = report.getDistinctRuleCount(repoName);
			String marker = errors.containsKey(repoName) ? " ⚠️" : "";
			if (hasDelta) {
				long newCount = repoMatches.stream().filter(m -> !knownMatchKeys.contains(matchKey(m))).count();
				long knownCount = repoMatches.size() - newCount;
				sb.append("| ").append(repoName).append(marker).append(" | ").append(files).append(" | ")
						.append(repoMatches.size()).append(" | ").append(newCount).append(" | ")
						.append(knownCount).append(" | ").append(rules).append(" |\n");
			} else {
				sb.append("| ").append(repoName).append(marker).append(" | ").append(files).append(" | ").append(repoMatches.size())
						.append(" | ").append(rules).append(" |\n");
			}
		}

		// Details — when a previous report exists, highlight new matches
		sb.append("\n## Details\n");
		if (hasDelta) {
			sb.append("_Only **new** matches since the last run are shown below._\n\n");
		}
		for (Map.Entry<String, List<MatchEntry>> repoEntry : byRepo.entrySet()) {
			List<MatchEntry> repoMatches = repoEntry.getValue();
			List<MatchEntry> displayMatches = hasDelta
					? repoMatches.stream().filter(m -> !knownMatchKeys.contains(matchKey(m))).toList()
					: repoMatches;
			if (displayMatches.isEmpty()) {
				continue;
			}
			sb.append("### ").append(repoEntry.getKey()).append("\n");

			// Group by hint file and rule
			Map<String, Map<String, List<MatchEntry>>> byHintAndRule = new java.util.LinkedHashMap<>();
			for (MatchEntry match : displayMatches) {
				byHintAndRule.computeIfAbsent(match.hintFile(), k -> new java.util.LinkedHashMap<>())
						.computeIfAbsent(match.ruleName(), k -> new java.util.ArrayList<>()).add(match);
			}

			for (Map.Entry<String, Map<String, List<MatchEntry>>> hintEntry : byHintAndRule.entrySet()) {
				for (Map.Entry<String, List<MatchEntry>> ruleEntry : hintEntry.getValue().entrySet()) {
					sb.append("#### Rule: `").append(hintEntry.getKey()).append("` → `").append(ruleEntry.getKey())
							.append("`\n");
					for (MatchEntry match : ruleEntry.getValue()) {
						sb.append("- `").append(match.filePath()).append(":").append(match.line()).append("` — `")
								.append(truncate(match.matchedCode(), 80)).append("`");
						if (match.suggestedReplacement() != null) {
							sb.append(" → `").append(truncate(match.suggestedReplacement(), 80)).append("`");
						}
						sb.append("\n");
					}
					sb.append("\n");
				}
			}
		}

		if (!report.hasMatches()) {
			sb.append("No matches found.\n");
		}

		// Errors section
		if (report.hasErrors()) {
			sb.append("\n## Errors\n");
			sb.append("The following repositories encountered errors during scanning:\n\n");
			for (Map.Entry<String, String> error : report.getErrors().entrySet()) {
				sb.append("- **").append(error.getKey()).append("**: `").append(truncate(error.getValue(), 200))
						.append("`\n");
			}
		}

		return sb.toString();
	}

	/**
	 * Writes the Markdown report to a file.
	 *
	 * @param report    the mining report
	 * @param outputDir the output directory
	 * @throws IOException if file writing fails
	 */
	public void write(MiningReport report, Path outputDir) throws IOException {
		Files.createDirectories(outputDir);
		String content = generate(report);
		Files.writeString(outputDir.resolve("report.md"), content, StandardCharsets.UTF_8);
	}

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

	/**
	 * Builds a set of match keys from a report for delta comparison.
	 */
	private static Set<String> buildMatchKeys(MiningReport report) {
		Set<String> keys = new HashSet<>();
		for (MatchEntry m : report.getMatches()) {
			keys.add(matchKey(m));
		}
		return keys;
	}

	/**
	 * Creates a unique key for a match entry based on repo, file, line, and rule.
	 */
	private static String matchKey(MatchEntry m) {
		return m.repoName() + "\0" + m.hintFile() + "\0" + m.ruleName() + "\0" + m.filePath() + ":" + m.line();
	}
}