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 static final int MAX_PIPELINE_PALETTES = 64; 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 outputPipelineMetadata = optionalOutputPipelineMetadata(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, outputPipelineMetadata, 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: glyph_bank, scene_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 Map optionalOutputPipelineMetadata( final JsonNode node, final List diagnostics, final Path manifestPath) { final JsonNode pipelineNode = node.path("pipeline"); if (pipelineNode.isMissingNode() || pipelineNode.isNull()) { return Map.of(); } if (!pipelineNode.isObject()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline' must be a JSON object.", manifestPath, true)); return Map.of(); } final Map pipelineMetadata = new LinkedHashMap<>(); pipelineNode.fields().forEachRemaining(entry -> { final String key = Objects.requireNonNullElse(entry.getKey(), "").trim(); if (key.isBlank()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline' has an invalid empty key.", manifestPath, true)); return; } final JsonNode valueNode = entry.getValue(); if (valueNode == null) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline' values must be valid JSON values.", manifestPath, true)); return; } if ("palettes".equals(key)) { pipelineMetadata.put(key, validatePaletteDeclarations(valueNode, diagnostics, manifestPath)); return; } pipelineMetadata.put(key, valueNode.deepCopy()); }); return Map.copyOf(pipelineMetadata); } private JsonNode validatePaletteDeclarations( final JsonNode node, final List diagnostics, final Path manifestPath) { if (!node.isArray()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline.palettes' must be an array of palette declarations.", manifestPath, true)); return node.deepCopy(); } final Set seenIndices = new HashSet<>(); int paletteCount = 0; for (JsonNode entry : node) { paletteCount += 1; if (!entry.isObject()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Each palette declaration must be an object with non-negative integer 'index' and object 'palette'.", manifestPath, true)); continue; } final JsonNode indexNode = entry.path("index"); final JsonNode paletteNode = entry.path("palette"); if (!indexNode.isInt() || indexNode.intValue() < 0) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Each palette declaration must define a non-negative integer 'index'.", manifestPath, true)); } else if (!seenIndices.add(indexNode.intValue())) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline.palettes' contains duplicate palette index " + indexNode.intValue() + ".", manifestPath, true)); } if (!paletteNode.isObject()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Each palette declaration must define an object 'palette' payload.", manifestPath, true)); continue; } final JsonNode originalNode = paletteNode.path("originalArgb8888"); final JsonNode convertedNode = paletteNode.path("convertedRgb565"); if (!originalNode.isArray() || !convertedNode.isArray()) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Each palette payload must contain array fields 'originalArgb8888' and 'convertedRgb565'.", manifestPath, true)); continue; } } if (paletteCount > MAX_PIPELINE_PALETTES) { diagnostics.add(new PackerDiagnostic( PackerDiagnosticSeverity.ERROR, PackerDiagnosticCategory.STRUCTURAL, "Field 'output.pipeline.palettes' cannot declare more than " + MAX_PIPELINE_PALETTES + " palettes.", manifestPath, true)); } return node.deepCopy(); } 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 artifactIdNode = entry.path("artifact_id"); final JsonNode fileNode = entry.path("file"); final JsonNode indexNode = entry.path("index"); final JsonNode fingerprintNode = entry.path("fingerprint"); 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; } final String artifactId = artifactIdNode.isTextual() && !artifactIdNode.asText().isBlank() ? artifactIdNode.asText().trim() : null; final String fingerprint = fingerprintNode.isTextual() && !fingerprintNode.asText().isBlank() ? fingerprintNode.asText().trim() : null; result.add(new PackerAssetArtifactSelection(artifactId, relativePath, indexNode.asInt(), fingerprint)); } 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); } }