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 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 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 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 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 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 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 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 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 optionalOutputMetadata( final JsonNode node, final List 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 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 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 optionalArtifacts( final JsonNode node, final List 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 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); } }