ControlMapping.java
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Egothor
// Copyright 2026 Accenture
package org.egothor.methodatlas.coverage;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* User-authored mapping from taxonomy tags to compliance-control requirement
* IDs, loaded from JSON.
*
* <p>
* The mapping is the user's responsibility; the tool records what the file
* says and does not pass judgement on compliance claims. Validation is
* structural only — see {@link #load(Path)} for the exact rules.
* </p>
*
* <p>
* Package-private because the only consumers — {@link
* ControlCoverageCollector} and {@link ControlCoverageWriter} — live in the
* same package. The {@code MethodAtlasApp} entry point talks to the package
* exclusively through {@link CoverageFacade}.
* </p>
*
* @param framework compliance framework label (e.g. {@code "ASVS"});
* non-blank
* @param frameworkVersion framework version (e.g. {@code "4.0"}); non-blank
* @param source absolute path of the mapping file as a string;
* used for provenance in the output report
* @param tagToControls immutable mapping from taxonomy tag to a list of
* control requirements; non-empty
*/
/* default */ record ControlMapping(
String framework,
String frameworkVersion,
String source,
Map<String, List<ControlEntry>> tagToControls) {
/** Schema version this implementation accepts. */
private static final String SCHEMA_VERSION = "1";
/** Prefix used in every validation error message. */
private static final String ERROR_PREFIX = "Mapping file '";
/** Closing token following the file path in every validation error message. */
private static final String ERROR_SUFFIX = "' ";
/**
* Loads and validates a control mapping from a JSON file on disk.
*
* <p>
* The loader is tolerant of unknown top-level fields (forward
* compatibility) but strict about the documented schema. Violations
* surface as {@link IllegalArgumentException} with a message that
* names the file and the failed constraint, so the CLI can render a
* clear stderr message before exiting with code {@code 2}.
* </p>
*
* <h2>Validation rules</h2>
* <ol>
* <li>{@code schemaVersion} must equal {@code "1"}.</li>
* <li>{@code framework} and {@code frameworkVersion} must be present
* and non-blank.</li>
* <li>{@code tagToControls} must be a non-empty JSON object whose
* values are arrays of control entries.</li>
* <li>Every entry must have a non-blank {@code id}; {@code chapter}
* and {@code chapterTitle} are optional.</li>
* </ol>
*
* <p>
* The {@link #source()} field of the returned mapping captures the
* absolute path string so the resulting report can document exactly
* which file produced the claim being made.
* </p>
*
* @param file path to the mapping JSON; must not be {@code null}
* @return validated, deeply-unmodifiable mapping
* @throws IOException if the file cannot be read or parsed
* @throws IllegalArgumentException if any validation rule fails
*/
/* default */ static ControlMapping load(Path file) throws IOException {
ObjectMapper mapper = JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build();
JsonNode root;
try {
root = mapper.readTree(file.toFile());
} catch (JacksonException e) {
throw new IOException("Cannot read or parse control mapping file '" + file + "'", e);
}
validateSchemaVersion(root, file);
String framework = requireString(root, "framework", file);
String frameworkVersion = requireString(root, "frameworkVersion", file);
Map<String, List<ControlEntry>> tagToControls = parseTagMap(root, file);
return new ControlMapping(framework, frameworkVersion,
file.toAbsolutePath().toString(),
Collections.unmodifiableMap(tagToControls));
}
/**
* Asserts that {@code root.schemaVersion} equals {@value #SCHEMA_VERSION}.
*
* @param root parsed JSON root
* @param file source file (used for error context)
*/
private static void validateSchemaVersion(JsonNode root, Path file) {
JsonNode node = root.path("schemaVersion");
if (!node.isString() || !SCHEMA_VERSION.equals(node.asString())) {
throw new IllegalArgumentException(ERROR_PREFIX + file + ERROR_SUFFIX
+ "has unsupported schemaVersion; expected \"" + SCHEMA_VERSION + "\"");
}
}
/**
* Extracts a required non-blank string field from {@code root}.
*
* @param root parsed JSON root
* @param field field name to fetch
* @param file source file (used for error context)
* @return the field's text value
*/
private static String requireString(JsonNode root, String field, Path file) {
JsonNode node = root.path(field);
if (!node.isString() || node.asString().isBlank()) {
throw new IllegalArgumentException(ERROR_PREFIX + file + ERROR_SUFFIX + "is missing required "
+ "non-blank field '" + field + "'");
}
return node.asString();
}
/**
* Parses the {@code tagToControls} object into a {@link LinkedHashMap}
* preserving JSON declaration order, validating every entry along the way.
*
* @param root parsed JSON root
* @param file source file (used for error context)
* @return populated map; never empty
*/
private static Map<String, List<ControlEntry>> parseTagMap(JsonNode root, Path file) {
JsonNode tagNode = root.path("tagToControls");
if (!tagNode.isObject() || tagNode.isEmpty()) {
throw new IllegalArgumentException("Mapping file '" + file
+ "' must contain a non-empty 'tagToControls' object");
}
Map<String, List<ControlEntry>> result = new LinkedHashMap<>();
tagNode.properties().forEach(entry ->
result.put(entry.getKey(), parseControlList(entry.getKey(), entry.getValue(), file)));
return result;
}
/**
* Parses one tag's control-array value, validating every element.
*
* @param tag tag name (for error context)
* @param array JSON array node
* @param file source file (for error context)
* @return immutable list of control entries
*/
private static List<ControlEntry> parseControlList(String tag, JsonNode array, Path file) {
if (!array.isArray()) {
throw new IllegalArgumentException(ERROR_PREFIX + file + ERROR_SUFFIX + "tag '" + tag
+ "' must map to a JSON array of control entries");
}
List<ControlEntry> entries = new ArrayList<>(array.size());
for (JsonNode element : array) {
entries.add(parseControlEntry(tag, element, file));
}
return Collections.unmodifiableList(entries);
}
/**
* Parses a single {@code {id, chapter, chapterTitle}} object.
*
* @param tag tag name (for error context)
* @param node JSON object node
* @param file source file (for error context)
* @return populated entry
*/
private static ControlEntry parseControlEntry(String tag, JsonNode node, Path file) {
if (!node.isObject()) {
throw new IllegalArgumentException(ERROR_PREFIX + file + ERROR_SUFFIX + "tag '" + tag
+ "' entries must be JSON objects");
}
JsonNode idNode = node.path("id");
if (!idNode.isString() || idNode.asString().isBlank()) {
throw new IllegalArgumentException(ERROR_PREFIX + file + ERROR_SUFFIX + "tag '" + tag
+ "' has an entry with missing or blank 'id'");
}
String chapter = optionalString(node, "chapter");
String chapterTitle = optionalString(node, "chapterTitle");
return new ControlEntry(idNode.asString(), chapter, chapterTitle);
}
/**
* Reads an optional textual field, returning {@code null} when absent or
* explicitly JSON-null. Blank strings are preserved so authors can supply
* empty placeholders intentionally.
*
* @param node JSON object node
* @param field field name
* @return field value or {@code null}
*/
private static String optionalString(JsonNode node, String field) {
JsonNode child = node.get(field);
if (child == null || child.isNull() || !child.isString()) {
return null;
}
return child.asString();
}
}