ScoredJsonReporter.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.util.List;
import java.util.Objects;

/**
 * Generates a JSON report from scored match entries.
 *
 * <p>The output includes the full score metadata for each entry,
 * following the schema described in the mining report specification.</p>
 *
 * @since 1.2.6
 */
public class ScoredJsonReporter {

	/**
	 * Generates a JSON report from scored entries.
	 *
	 * @param entries the scored match entries
	 * @return the JSON content
	 */
	public String generate(List<ScoredMatchEntry> entries) {
		Objects.requireNonNull(entries, "entries"); //$NON-NLS-1$
		StringBuilder sb = new StringBuilder();
		sb.append("[\n"); //$NON-NLS-1$

		for (int i = 0; i < entries.size(); i++) {
			ScoredMatchEntry entry = entries.get(i);
			MatchScore score = entry.score();

			sb.append("  {\n"); //$NON-NLS-1$
			sb.append("    \"repository\": ").append(jsonString(entry.repository())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("    \"rule\": ").append(jsonString(entry.rule())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("    \"file\": ").append(jsonString(entry.file())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("    \"line\": ").append(entry.line()).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("    \"matchedCode\": ").append(jsonString(entry.matchedCode())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			if (entry.suggestedReplacement() != null) {
				sb.append("    \"suggestedReplacement\": ").append(jsonString(entry.suggestedReplacement())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			}
			sb.append("    \"score\": {\n"); //$NON-NLS-1$
			sb.append("      \"trivialChange\": ").append(score.trivialChange()).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("      \"nullStatus\": ").append(jsonString(score.nullStatus().name())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("      \"severity\": ").append(jsonString(score.severity().name())).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
			sb.append("      \"reason\": ").append(jsonString(score.reason())); //$NON-NLS-1$

			if (!score.evidence().isEmpty()) {
				sb.append(",\n      \"evidence\": [\n"); //$NON-NLS-1$
				for (int j = 0; j < score.evidence().size(); j++) {
					sb.append("        ").append(jsonString(score.evidence().get(j))); //$NON-NLS-1$
					if (j < score.evidence().size() - 1) {
						sb.append(',');
					}
					sb.append('\n');
				}
				sb.append("      ]"); //$NON-NLS-1$
			}

			sb.append("\n    }\n"); //$NON-NLS-1$
			sb.append("  }"); //$NON-NLS-1$
			if (i < entries.size() - 1) {
				sb.append(',');
			}
			sb.append('\n');
		}

		sb.append("]\n"); //$NON-NLS-1$
		return sb.toString();
	}

	private static String jsonString(String value) {
		if (value == null) {
			return "null"; //$NON-NLS-1$
		}
		StringBuilder sb = new StringBuilder("\""); //$NON-NLS-1$
		for (char c : value.toCharArray()) {
			switch (c) {
			case '"':
				sb.append("\\\""); //$NON-NLS-1$
				break;
			case '\\':
				sb.append("\\\\"); //$NON-NLS-1$
				break;
			case '\n':
				sb.append("\\n"); //$NON-NLS-1$
				break;
			case '\r':
				sb.append("\\r"); //$NON-NLS-1$
				break;
			case '\t':
				sb.append("\\t"); //$NON-NLS-1$
				break;
			default:
				if (c < 0x20) {
					sb.append(String.format("\\u%04x", (int) c)); //$NON-NLS-1$
				} else {
					sb.append(c);
				}
				break;
			}
		}
		sb.append('"');
		return sb.toString();
	}
}