MiningCandidate.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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* Authoritative staged representation of one mined cleanup proposal.
*
* <p>The candidate ID identifies the discovery origin and stays stable while a
* proposal is corrected. Mutable rule and example content has separate
* fingerprints, and corrected content increments {@link #revision} when saved
* by {@link CandidateStore}.</p>
*/
public class MiningCandidate {
private int schemaVersion = 2;
private String candidateId;
private int revision = 1;
private int candidateOrdinal;
private String dslRule;
private String beforeExample;
private String afterExample;
private String negativeExample;
private String targetHintFile;
private String sourceCommit;
private String sourceRepo;
private String category;
private String summary;
private String sourceVersion = "21"; //$NON-NLS-1$
private CandidateStatus status;
private String discoveredAt;
private String rejectionReason;
private String ruleFingerprint;
private String behaviorFingerprint;
private CandidateVerification verification;
private List<CandidateTransition> transitions = new ArrayList<>();
/** No-arg constructor for Gson. */
public MiningCandidate() {
this.status = CandidateStatus.DISCOVERED;
}
/**
* Creates a candidate with the fields currently supplied by commit discovery.
*/
public MiningCandidate(String dslRule, String beforeExample, String afterExample,
String negativeExample, String targetHintFile, String sourceCommit,
String sourceRepo, String category, String summary, String discoveredAt) {
this.dslRule = dslRule;
this.beforeExample = beforeExample;
this.afterExample = afterExample;
this.negativeExample = negativeExample;
this.targetHintFile = targetHintFile;
this.sourceCommit = sourceCommit;
this.sourceRepo = sourceRepo;
this.category = category;
this.summary = summary;
this.discoveredAt = discoveredAt;
this.status = CandidateStatus.DISCOVERED;
}
public int getSchemaVersion() {
return schemaVersion;
}
public void setSchemaVersion(int schemaVersion) {
this.schemaVersion = schemaVersion;
}
/**
* Stable ID derived from repository, source commit, discovery category,
* target rule library, and candidate ordinal. Rule text and examples are
* deliberately excluded so corrections remain revisions of one candidate.
*/
public String getCandidateId() {
if (candidateId == null || candidateId.isBlank()) {
candidateId = sha256(nullToEmpty(sourceRepo) + '\n'
+ nullToEmpty(sourceCommit) + '\n'
+ nullToEmpty(category) + '\n'
+ nullToEmpty(targetHintFile) + '\n'
+ candidateOrdinal);
}
return candidateId;
}
public void setCandidateId(String candidateId) {
this.candidateId = candidateId;
}
public int getRevision() {
return revision;
}
public void setRevision(int revision) {
this.revision = Math.max(1, revision);
}
public int getCandidateOrdinal() {
return candidateOrdinal;
}
public void setCandidateOrdinal(int candidateOrdinal) {
if (candidateOrdinal < 0) {
throw new IllegalArgumentException("candidateOrdinal must be >= 0"); //$NON-NLS-1$
}
this.candidateOrdinal = candidateOrdinal;
this.candidateId = null;
}
public String getDslRule() {
return dslRule;
}
public void setDslRule(String dslRule) {
this.dslRule = dslRule;
this.ruleFingerprint = null;
}
public String getBeforeExample() {
return beforeExample;
}
public void setBeforeExample(String beforeExample) {
this.beforeExample = beforeExample;
this.behaviorFingerprint = null;
}
public String getAfterExample() {
return afterExample;
}
public void setAfterExample(String afterExample) {
this.afterExample = afterExample;
this.behaviorFingerprint = null;
}
public String getNegativeExample() {
return negativeExample;
}
public void setNegativeExample(String negativeExample) {
this.negativeExample = negativeExample;
this.behaviorFingerprint = null;
}
public String getTargetHintFile() {
return targetHintFile;
}
public void setTargetHintFile(String targetHintFile) {
this.targetHintFile = targetHintFile;
this.candidateId = null;
}
public String getSourceCommit() {
return sourceCommit;
}
public void setSourceCommit(String sourceCommit) {
this.sourceCommit = sourceCommit;
this.candidateId = null;
}
public String getSourceRepo() {
return sourceRepo;
}
public void setSourceRepo(String sourceRepo) {
this.sourceRepo = sourceRepo;
this.candidateId = null;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
this.candidateId = null;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
/**
* Returns whether the serialized candidate explicitly declares a source
* version instead of relying on the default returned by {@link #getSourceVersion()}.
*/
public boolean hasDeclaredSourceVersion() {
return sourceVersion != null && !sourceVersion.isBlank();
}
public String getSourceVersion() {
return hasDeclaredSourceVersion() ? sourceVersion : "21"; //$NON-NLS-1$
}
public void setSourceVersion(String sourceVersion) {
this.sourceVersion = sourceVersion;
}
public CandidateStatus getStatus() {
return status == null ? CandidateStatus.DISCOVERED : status;
}
/**
* Compatibility setter used by Gson and the existing discovery code.
* New operational code should use {@link #transitionTo(CandidateStatus, String, String)}.
*/
public void setStatus(CandidateStatus status) {
this.status = status;
}
public String getDiscoveredAt() {
return discoveredAt;
}
public void setDiscoveredAt(String discoveredAt) {
this.discoveredAt = discoveredAt;
}
public String getRejectionReason() {
return rejectionReason;
}
public void setRejectionReason(String rejectionReason) {
this.rejectionReason = rejectionReason;
}
public String getRuleFingerprint() {
if (ruleFingerprint == null || ruleFingerprint.isBlank()) {
ruleFingerprint = "sha256:" + sha256(normalizeDsl(dslRule)); //$NON-NLS-1$
}
return ruleFingerprint;
}
public void setRuleFingerprint(String ruleFingerprint) {
this.ruleFingerprint = ruleFingerprint;
}
public String getBehaviorFingerprint() {
if (behaviorFingerprint == null || behaviorFingerprint.isBlank()) {
String behavior = normalizeSourceForFingerprint(beforeExample) + '\n'
+ normalizeSourceForFingerprint(afterExample) + '\n'
+ normalizeSourceForFingerprint(negativeExample);
behaviorFingerprint = "sha256:" + sha256(behavior); //$NON-NLS-1$
}
return behaviorFingerprint;
}
public void setBehaviorFingerprint(String behaviorFingerprint) {
this.behaviorFingerprint = behaviorFingerprint;
}
public CandidateVerification getVerification() {
return verification;
}
public void setVerification(CandidateVerification verification) {
this.verification = verification;
}
public List<CandidateTransition> getTransitions() {
if (transitions == null) {
transitions = new ArrayList<>();
}
return List.copyOf(transitions);
}
public void setTransitions(List<CandidateTransition> transitions) {
this.transitions = transitions == null ? new ArrayList<>() : new ArrayList<>(transitions);
}
/**
* Applies and records an allowed lifecycle transition.
*/
public void transitionTo(CandidateStatus target, String actor, String reason) {
CandidateStatus current = getStatus();
if (!current.canTransitionTo(target)) {
throw new IllegalStateException("Invalid candidate transition: " + current + " -> " + target); //$NON-NLS-1$ //$NON-NLS-2$
}
if (transitions == null) {
transitions = new ArrayList<>();
}
transitions.add(new CandidateTransition(current, target,
nullToEmpty(actor), nullToEmpty(reason), Instant.now().toString()));
status = target;
if (target == CandidateStatus.REJECTED) {
rejectionReason = reason;
}
}
/** Returns the stable JSON file name for this candidate. */
public String toFileName() {
return getCandidateId() + "-candidate.json"; //$NON-NLS-1$
}
private static String normalizeDsl(String value) {
if (value == null) {
return ""; //$NON-NLS-1$
}
String[] lines = value.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1); //$NON-NLS-1$ //$NON-NLS-2$
StringBuilder normalized = new StringBuilder();
for (String line : lines) {
String trimmed = line.strip();
if (!trimmed.isEmpty()) {
if (!normalized.isEmpty()) {
normalized.append('\n');
}
normalized.append(trimmed);
}
}
return normalized.toString();
}
/**
* Normalizes transport-level differences only. Whitespace inside Java
* literals remains part of the behavior fingerprint.
*/
private static String normalizeSourceForFingerprint(String value) {
return value == null ? "" : value.replace("\r\n", "\n").replace('\r', '\n').strip(); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
}
private static String nullToEmpty(String value) {
return value == null ? "" : value; //$NON-NLS-1$
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(hash.length * 2);
for (byte b : hash) {
hex.append(Character.forDigit((b >>> 4) & 0x0f, 16));
hex.append(Character.forDigit(b & 0x0f, 16));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 algorithm not available", e); //$NON-NLS-1$
}
}
@Override
public String toString() {
return "MiningCandidate{" //$NON-NLS-1$
+ "candidateId='" + getCandidateId() + '\'' //$NON-NLS-1$
+ ", revision=" + revision //$NON-NLS-1$
+ ", sourceCommit='" + sourceCommit + '\'' //$NON-NLS-1$
+ ", status=" + getStatus() //$NON-NLS-1$
+ ", summary='" + summary + '\'' //$NON-NLS-1$
+ '}';
}
}