CandidateReviewCli.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.mining.core.candidate;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;

import com.google.gson.Gson;

/**
 * Applies an explicit review or promotion decision to one verified candidate.
 * Repeating an already-applied decision is retry-safe. An approval retry keeps
 * the existing decision transition but refreshes the persisted deterministic
 * verification with the current verifier.
 *
 * <p>Approval always re-runs deterministic verification with the current
 * verifier before recording or retaining the human decision. Promotion is
 * independently re-verified by {@link CandidatePromotionCli}.</p>
 */
public final class CandidateReviewCli {

	private static final Gson GSON = new Gson();

	private CandidateReviewCli() {
	}

	public static void main(String[] args) {
		try {
			int result = run(args);
			if (result != 0) {
				System.exit(result);
			}
		} catch (Exception e) {
			System.err.println("Candidate review failed: " + e.getMessage()); //$NON-NLS-1$
			System.exit(1);
		}
	}

	static int run(String[] args) throws IOException {
		Path candidateFile = null;
		String action = null;
		String actor = null;
		String reason = null;

		for (int i = 0; i < args.length; i++) {
			switch (args[i]) {
			case "--candidate": //$NON-NLS-1$
				candidateFile = Path.of(requireValue(args, ++i, "--candidate")); //$NON-NLS-1$
				break;
			case "--action": //$NON-NLS-1$
				action = requireValue(args, ++i, "--action"); //$NON-NLS-1$
				break;
			case "--actor": //$NON-NLS-1$
				actor = requireValue(args, ++i, "--actor"); //$NON-NLS-1$
				break;
			case "--reason": //$NON-NLS-1$
				reason = requireValue(args, ++i, "--reason"); //$NON-NLS-1$
				break;
			default:
				throw new IllegalArgumentException("Unknown option: " + args[i]); //$NON-NLS-1$
			}
		}

		if (candidateFile == null || !Files.isRegularFile(candidateFile)) {
			throw new IllegalArgumentException("--candidate must reference an existing file"); //$NON-NLS-1$
		}
		if (action == null || action.isBlank()) {
			throw new IllegalArgumentException("--action is required"); //$NON-NLS-1$
		}
		if (actor == null || actor.isBlank()) {
			throw new IllegalArgumentException("--actor is required"); //$NON-NLS-1$
		}

		Path absoluteCandidateFile = candidateFile.toAbsolutePath().normalize();
		Path parent = absoluteCandidateFile.getParent();
		if (parent == null) {
			throw new IOException("Candidate file has no parent directory: " + absoluteCandidateFile); //$NON-NLS-1$
		}
		CandidateStore store = new CandidateStore(parent);
		MiningCandidate candidate = GSON.fromJson(
				Files.readString(absoluteCandidateFile, StandardCharsets.UTF_8), MiningCandidate.class);
		if (candidate == null) {
			throw new IllegalArgumentException("Candidate JSON is empty"); //$NON-NLS-1$
		}

		try {
			applyAction(candidate, action.toLowerCase(Locale.ROOT), actor, reason);
		} catch (IllegalStateException e) {
			// Persist a freshly computed failed verification diagnostic before
			// returning the failed review decision to the caller.
			store.save(candidate);
			throw e;
		}

		store.save(candidate);
		Path canonicalFile = parent.resolve(candidate.toFileName());
		if (!absoluteCandidateFile.equals(canonicalFile) && Files.exists(absoluteCandidateFile)) {
			Files.delete(absoluteCandidateFile);
		}
		System.out.println(candidate.getCandidateId() + " -> " + candidate.getStatus()); //$NON-NLS-1$
		return 0;
	}

	private static void applyAction(MiningCandidate candidate, String action,
			String actor, String reason) {
		switch (action) {
		case "approve": //$NON-NLS-1$
			approve(candidate, actor, normalizeReason(reason, "Approved after human review")); //$NON-NLS-1$
			break;
		case "reject": //$NON-NLS-1$
			applyIdempotentTransition(candidate, CandidateStatus.READY_FOR_REVIEW,
					CandidateStatus.REJECTED, actor,
					requireReason(reason, "A rejection reason is required"), //$NON-NLS-1$
					"Only READY_FOR_REVIEW candidates can be rejected"); //$NON-NLS-1$
			break;
		case "promote": //$NON-NLS-1$
			applyIdempotentTransition(candidate, CandidateStatus.APPROVED,
					CandidateStatus.PROMOTED, actor,
					normalizeReason(reason, "Promotion pull request merged"), //$NON-NLS-1$
					"Only APPROVED candidates can be marked promoted"); //$NON-NLS-1$
			break;
		case "supersede": //$NON-NLS-1$
			if (candidate.getStatus() != CandidateStatus.SUPERSEDED) {
				candidate.transitionTo(CandidateStatus.SUPERSEDED, actor,
						requireReason(reason, "A supersession reason is required")); //$NON-NLS-1$
			}
			break;
		default:
			throw new IllegalArgumentException("Unsupported review action: " + action); //$NON-NLS-1$
		}
	}

	private static void approve(MiningCandidate candidate, String actor, String reason) {
		CandidateStatus status = candidate.getStatus();
		if (status != CandidateStatus.READY_FOR_REVIEW
				&& status != CandidateStatus.APPROVED) {
			throw new IllegalStateException(
					"Only READY_FOR_REVIEW or already APPROVED candidates can be approved"); //$NON-NLS-1$
		}
		CandidateVerification currentVerification = new CandidateVerifier().verify(candidate);
		candidate.setVerification(currentVerification);
		if (!currentVerification.successful()) {
			throw new IllegalStateException("Candidate failed current deterministic verification at " //$NON-NLS-1$
					+ currentVerification.stage() + ": " + currentVerification.message()); //$NON-NLS-1$
		}
		if (status == CandidateStatus.READY_FOR_REVIEW) {
			candidate.transitionTo(CandidateStatus.APPROVED, actor, reason);
		}
	}

	private static void applyIdempotentTransition(MiningCandidate candidate,
			CandidateStatus expectedSource, CandidateStatus target, String actor,
			String reason, String invalidStateMessage) {
		if (candidate.getStatus() == target) {
			return;
		}
		if (candidate.getStatus() != expectedSource) {
			throw new IllegalStateException(invalidStateMessage);
		}
		candidate.transitionTo(target, actor, reason);
	}

	private static String normalizeReason(String reason, String fallback) {
		return reason == null || reason.isBlank() ? fallback : reason.trim();
	}

	private static String requireReason(String reason, String message) {
		if (reason == null || reason.isBlank()) {
			throw new IllegalArgumentException(message);
		}
		return reason.trim();
	}

	private static String requireValue(String[] args, int index, String option) {
		if (index >= args.length) {
			throw new IllegalArgumentException(option + " requires a value"); //$NON-NLS-1$
		}
		return args[index];
	}
}