2026-03-24 13:42:52 +00:00

318 lines
13 KiB
Java

package p.packer.services;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.messages.assets.AssetFamilyCatalog;
import p.packer.messages.assets.OutputCodecCatalog;
import p.packer.messages.assets.OutputFormatCatalog;
import p.packer.messages.diagnostics.PackerDiagnosticCategory;
import p.packer.messages.diagnostics.PackerDiagnosticSeverity;
import p.packer.models.PackerAssetArtifactSelection;
import p.packer.models.PackerAssetDeclaration;
import p.packer.models.PackerAssetDeclarationParseResult;
import p.packer.models.PackerDiagnostic;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
public final class PackerAssetDeclarationParser {
private static final Set<Integer> SUPPORTED_SCHEMA_VERSIONS = Set.of(1);
private final ObjectMapper mapper;
public PackerAssetDeclarationParser(ObjectMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
}
public PackerAssetDeclarationParseResult parse(final Path assetManifestPath) {
final var manifestPath = Objects.requireNonNull(assetManifestPath, "assetManifestPath").toAbsolutePath().normalize();
final List<PackerDiagnostic> diagnostics = new ArrayList<>();
final JsonNode root;
try {
root = mapper.readTree(manifestPath.toFile());
} catch (IOException exception) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Unable to parse asset.json: " + exception.getMessage(),
manifestPath,
true));
return new PackerAssetDeclarationParseResult(null, diagnostics);
}
final var schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath);
final var assetUuid = requiredText(root, "asset_uuid", diagnostics, manifestPath);
final var name = requiredText(root, "name", diagnostics, manifestPath);
final var assetFamily = requiredAssetFamily(root, diagnostics, manifestPath);
rejectLegacyInputs(root.path("inputs"), diagnostics, manifestPath);
final var artifacts = optionalArtifacts(root.path("artifacts"), diagnostics, manifestPath);
final var outputFormat = requiredOutputFormat(root.path("output"), diagnostics, manifestPath);
final var outputCodec = requiredOutputCodec(root.path("output"), diagnostics, manifestPath);
final var outputMetadata = optionalOutputMetadata(root.path("output"), diagnostics, manifestPath);
final var preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath);
if (schemaVersion != null && !SUPPORTED_SCHEMA_VERSIONS.contains(schemaVersion)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.VERSIONING,
"Unsupported asset.json schema_version: " + schemaVersion,
manifestPath,
true));
}
if (diagnostics.stream().anyMatch(PackerDiagnostic::blocking)) {
return new PackerAssetDeclarationParseResult(null, diagnostics);
}
return new PackerAssetDeclarationParseResult(
new PackerAssetDeclaration(
schemaVersion,
assetUuid,
name,
assetFamily,
artifacts,
outputFormat,
outputCodec,
outputMetadata,
preloadEnabled),
diagnostics);
}
private Integer requiredInt(
final JsonNode node,
final String fieldName,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final JsonNode field = node.path(fieldName);
if (!field.isInt()) {
diagnostics.add(missingOrInvalid(fieldName, "integer", manifestPath));
return null;
}
return field.intValue();
}
private String requiredText(
final JsonNode node,
final String fieldName,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final JsonNode field = node.path(fieldName);
if (!field.isTextual() || field.asText().isBlank()) {
diagnostics.add(missingOrInvalid(fieldName, "non-blank string", manifestPath));
return null;
}
return field.asText().trim();
}
private AssetFamilyCatalog requiredAssetFamily(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final String manifestType = requiredText(node, "type", diagnostics, manifestPath);
if (manifestType == null) {
return null;
}
final AssetFamilyCatalog assetFamily = AssetFamilyCatalog.fromManifestType(manifestType);
if (assetFamily == AssetFamilyCatalog.UNKNOWN) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'type' must be one of: tile_bank, palette_bank, sound_bank.",
manifestPath,
true));
return null;
}
return assetFamily;
}
private Boolean requiredBoolean(
final JsonNode node,
final String fieldName,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final JsonNode field = node.path(fieldName);
if (!field.isBoolean()) {
diagnostics.add(missingOrInvalid(fieldName, "boolean", manifestPath));
return null;
}
return field.booleanValue();
}
private OutputFormatCatalog requiredOutputFormat(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final String fmtValue = requiredText(node, "format", diagnostics, manifestPath);
if (fmtValue == null) {
return null;
}
final OutputFormatCatalog outputFormat = OutputFormatCatalog.fromManifestValue(fmtValue);
if (outputFormat == OutputFormatCatalog.UNKNOWN) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'output' must have a valid value.",
manifestPath,
true));
return null;
}
return outputFormat;
}
private OutputCodecCatalog requiredOutputCodec(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final String codecValue = requiredText(node, "codec", diagnostics, manifestPath);
if (codecValue == null) {
return null;
}
final OutputCodecCatalog outputCodec = OutputCodecCatalog.fromManifestValue(codecValue);
if (outputCodec == OutputCodecCatalog.UNKNOWN) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'codec' must have a valid value.",
manifestPath,
true));
return null;
}
return outputCodec;
}
private Map<String, String> optionalOutputMetadata(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
final JsonNode metadataNode = node.path("metadata");
if (metadataNode.isMissingNode() || metadataNode.isNull()) {
return Map.of();
}
if (!metadataNode.isObject()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'output.metadata' must be a JSON object.",
manifestPath,
true));
return Map.of();
}
final Map<String, String> metadata = new LinkedHashMap<>();
metadataNode.fields().forEachRemaining(entry -> {
final String key = Objects.requireNonNullElse(entry.getKey(), "").trim();
if (key.isBlank()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'output.metadata' has an invalid empty key.",
manifestPath,
true));
return;
}
final JsonNode valueNode = entry.getValue();
if (valueNode == null || valueNode.isContainerNode()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'output.metadata' values must be scalar (text/number/boolean).",
manifestPath,
true));
return;
}
metadata.put(key, valueNode.asText());
});
return Map.copyOf(metadata);
}
private void rejectLegacyInputs(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
if (node.isMissingNode() || node.isNull()) {
return;
}
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'inputs' is no longer supported; use 'artifacts' instead.",
manifestPath,
true));
}
private List<PackerAssetArtifactSelection> optionalArtifacts(
final JsonNode node,
final List<PackerDiagnostic> diagnostics,
final Path manifestPath) {
if (node.isMissingNode() || node.isNull()) {
return List.of();
}
if (!node.isArray()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field 'artifacts' must be an array.",
manifestPath,
true));
return List.of();
}
final List<PackerAssetArtifactSelection> result = new ArrayList<>();
for (JsonNode entry : node) {
if (!entry.isObject()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Each artifact entry must be an object with 'file' and 'index'.",
manifestPath,
true));
continue;
}
final JsonNode fileNode = entry.path("file");
final JsonNode indexNode = entry.path("index");
if (!fileNode.isTextual() || fileNode.asText().isBlank() || !indexNode.isInt() || indexNode.asInt() < 0) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Each artifact entry must define a non-blank 'file' and a non-negative integer 'index'.",
manifestPath,
true));
continue;
}
final String relativePath = fileNode.asText().trim();
if (!isTrustedRelativePath(relativePath)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Artifact file path must stay inside the asset root.",
manifestPath,
true));
continue;
}
result.add(new PackerAssetArtifactSelection(relativePath, indexNode.asInt()));
}
result.sort(Comparator.comparingInt(PackerAssetArtifactSelection::index));
return List.copyOf(result);
}
private boolean isTrustedRelativePath(final String value) {
final Path path = Path.of(value).normalize();
return !path.isAbsolute() && !path.startsWith("..");
}
private PackerDiagnostic missingOrInvalid(
final String fieldName,
final String expected,
final Path manifestPath) {
return new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field '" + fieldName + "' must be a " + expected + ".",
manifestPath,
true);
}
}