ScanRun.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
/**
* Immutable record identifying one CLI invocation of MethodAtlas.
*
* <p>
* Created once at the top of {@link MethodAtlasApp#run(String[], java.io.PrintWriter)}
* and propagated through {@link ScanRunContext} so that every log record
* emitted during the run can carry the correlation id. This makes it
* possible to trace a single audit-trail artefact, a single SARIF file, or a
* single CI log slice back to the exact run that produced it.
* </p>
*
* <h2>Fields</h2>
*
* <ul>
* <li>{@code runId} — a short hex-encoded random id (16 hex characters);
* intentionally not a full UUID because the id appears in log lines
* and CSV preamble where 36-character UUIDs are noisy.</li>
* <li>{@code startedAt} — wall-clock timestamp at run construction.</li>
* <li>{@code toolVersion} — implementation version reported by the
* {@code methodatlas} JAR manifest; falls back to {@code "dev"} for
* developer builds.</li>
* <li>{@code configFingerprint} — SHA-256 of a stable textual rendering of
* the parsed {@link CliConfig}. Lets two runs of the same configuration
* be correlated across time and machine boundaries.</li>
* </ul>
*
* @param runId short hex correlation id, non-empty
* @param startedAt wall-clock time the run was started
* @param toolVersion implementation version or {@code "dev"}
* @param configFingerprint SHA-256 hex digest of the canonical configuration
* text; never {@code null}, never empty
* @since 1.0.0
*/
public record ScanRun(String runId, Instant startedAt, String toolVersion, String configFingerprint) {
/**
* Compact constructor enforcing the documented invariants on the
* component values.
*/
public ScanRun {
Objects.requireNonNull(runId, "runId");
Objects.requireNonNull(startedAt, "startedAt");
Objects.requireNonNull(toolVersion, "toolVersion");
Objects.requireNonNull(configFingerprint, "configFingerprint");
if (runId.isBlank()) {
throw new IllegalArgumentException("runId must not be blank");
}
if (configFingerprint.isBlank()) {
throw new IllegalArgumentException("configFingerprint must not be blank");
}
}
/**
* Creates a new {@code ScanRun} with a freshly generated correlation id
* and the current wall-clock timestamp.
*
* <p>
* The {@code configFingerprint} is the SHA-256 of {@code configText}, a
* stable textual rendering of the parsed CLI configuration. Two runs
* with byte-identical configuration produce the same fingerprint,
* letting operators correlate scans without sharing the full config.
* </p>
*
* @param toolVersion implementation version, or {@code "dev"} for
* builds without a manifest version
* @param configText canonical textual rendering of the parsed
* {@link CliConfig}; must not be {@code null}
* @return a fresh scan-run identifier
*/
public static ScanRun create(String toolVersion, String configText) {
String runId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
return new ScanRun(runId, Instant.now(),
toolVersion == null || toolVersion.isBlank() ? "dev" : toolVersion,
sha256(configText));
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(
value == null ? new byte[0] : value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}