implements packer PR-03 declaration parsing and details

This commit is contained in:
bQUARKz 2026-03-11 17:40:40 +00:00
parent 42e7331d62
commit 12f2311d08
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
10 changed files with 543 additions and 0 deletions

View File

@ -0,0 +1,29 @@
package p.packer.declarations;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public record PackerAssetDeclaration(
int schemaVersion,
String name,
String type,
Map<String, List<String>> inputsByRole,
String outputFormat,
String outputCodec,
boolean preloadEnabled) {
public PackerAssetDeclaration {
if (schemaVersion <= 0) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
name = Objects.requireNonNull(name, "name").trim();
type = Objects.requireNonNull(type, "type").trim();
inputsByRole = Map.copyOf(Objects.requireNonNull(inputsByRole, "inputsByRole"));
outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim();
outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim();
if (name.isBlank() || type.isBlank() || outputFormat.isBlank() || outputCodec.isBlank()) {
throw new IllegalArgumentException("declaration fields must not be blank");
}
}
}

View File

@ -0,0 +1,19 @@
package p.packer.declarations;
import p.packer.api.diagnostics.PackerDiagnostic;
import java.util.List;
import java.util.Objects;
public record PackerAssetDeclarationParseResult(
PackerAssetDeclaration declaration,
List<PackerDiagnostic> diagnostics) {
public PackerAssetDeclarationParseResult {
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
public boolean valid() {
return declaration != null && diagnostics.stream().noneMatch(PackerDiagnostic::blocking);
}
}

View File

@ -0,0 +1,140 @@
package p.packer.declarations;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class PackerAssetDeclarationParser {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final int SUPPORTED_SCHEMA_VERSION = 1;
public PackerAssetDeclarationParseResult parse(Path assetManifestPath) {
final Path 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 Integer schemaVersion = requiredInt(root, "schema_version", diagnostics, manifestPath);
final String name = requiredText(root, "name", diagnostics, manifestPath);
final String type = requiredText(root, "type", diagnostics, manifestPath);
final Map<String, List<String>> inputsByRole = requiredInputs(root.path("inputs"), diagnostics, manifestPath);
final String outputFormat = requiredText(root.path("output"), "format", diagnostics, manifestPath);
final String outputCodec = requiredText(root.path("output"), "codec", diagnostics, manifestPath);
final Boolean preloadEnabled = requiredBoolean(root.path("preload"), "enabled", diagnostics, manifestPath);
if (schemaVersion != null && schemaVersion != SUPPORTED_SCHEMA_VERSION) {
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,
name,
type,
inputsByRole,
outputFormat,
outputCodec,
preloadEnabled),
diagnostics);
}
private Integer requiredInt(JsonNode node, String fieldName, List<PackerDiagnostic> diagnostics, 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(JsonNode node, String fieldName, List<PackerDiagnostic> diagnostics, 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 Boolean requiredBoolean(JsonNode node, String fieldName, List<PackerDiagnostic> diagnostics, Path manifestPath) {
final JsonNode field = node.path(fieldName);
if (!field.isBoolean()) {
diagnostics.add(missingOrInvalid(fieldName, "boolean", manifestPath));
return null;
}
return field.booleanValue();
}
private Map<String, List<String>> requiredInputs(JsonNode inputsNode, List<PackerDiagnostic> diagnostics, Path manifestPath) {
if (!inputsNode.isObject()) {
diagnostics.add(missingOrInvalid("inputs", "object of input roles", manifestPath));
return Map.of();
}
final Map<String, List<String>> result = new LinkedHashMap<>();
inputsNode.fields().forEachRemaining(entry -> {
if (!entry.getValue().isArray()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Input role '" + entry.getKey() + "' must be an array of relative paths.",
manifestPath,
true));
return;
}
final List<String> values = new ArrayList<>();
entry.getValue().forEach(value -> {
if (!value.isTextual() || value.asText().isBlank()) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Input role '" + entry.getKey() + "' must contain only non-blank path strings.",
manifestPath,
true));
return;
}
values.add(value.asText().trim());
});
result.put(entry.getKey(), List.copyOf(values));
});
return Map.copyOf(result);
}
private PackerDiagnostic missingOrInvalid(String fieldName, String expected, Path manifestPath) {
return new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Field '" + fieldName + "' must be a " + expected + ".",
manifestPath,
true);
}
}

View File

@ -0,0 +1,172 @@
package p.packer.declarations;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetIdentity;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.assets.PackerAssetSummary;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.api.diagnostics.PackerDiagnosticSeverity;
import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.api.workspace.GetAssetDetailsResult;
import p.packer.foundation.PackerRegistryEntry;
import p.packer.foundation.PackerRegistryLookup;
import p.packer.foundation.PackerRegistryState;
import p.packer.foundation.PackerWorkspaceFoundation;
import p.packer.foundation.PackerWorkspacePaths;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public final class PackerAssetDetailsService {
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDeclarationParser parser;
public PackerAssetDetailsService() {
this(new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser());
}
public PackerAssetDetailsService(
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDeclarationParser parser) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.parser = Objects.requireNonNull(parser, "parser");
}
public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) {
final PackerProjectContext project = Objects.requireNonNull(request, "request").project();
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
final ResolvedAssetReference resolved = resolveReference(project, registry, request.assetReference());
final Path manifestPath = resolved.assetRoot().resolve("asset.json");
final List<PackerDiagnostic> diagnostics = new ArrayList<>(resolved.diagnostics());
if (!Files.isRegularFile(manifestPath)) {
diagnostics.add(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"asset.json was not found for the requested asset root.",
manifestPath,
true));
return failureResult(resolved, diagnostics);
}
final PackerAssetDeclarationParseResult parsed = parser.parse(manifestPath);
diagnostics.addAll(parsed.diagnostics());
if (!parsed.valid()) {
return failureResult(resolved, diagnostics);
}
final PackerAssetDeclaration declaration = parsed.declaration();
final PackerAssetSummary summary = new PackerAssetSummary(
new PackerAssetIdentity(
resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null),
resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null),
declaration.name(),
resolved.assetRoot()),
resolved.registryEntry().isPresent() ? PackerAssetState.MANAGED : PackerAssetState.ORPHAN,
declaration.type(),
declaration.preloadEnabled(),
!diagnostics.isEmpty());
final PackerAssetDetails details = new PackerAssetDetails(
summary,
declaration.outputFormat(),
declaration.outputCodec(),
resolveInputs(resolved.assetRoot(), declaration.inputsByRole()),
diagnostics);
return new GetAssetDetailsResult(
diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL,
"Asset details resolved from packer declaration state.",
details,
diagnostics);
}
private GetAssetDetailsResult failureResult(ResolvedAssetReference resolved, List<PackerDiagnostic> diagnostics) {
final PackerAssetSummary summary = new PackerAssetSummary(
new PackerAssetIdentity(
resolved.registryEntry().map(PackerRegistryEntry::assetId).orElse(null),
resolved.registryEntry().map(PackerRegistryEntry::assetUuid).orElse(null),
resolved.assetRoot().getFileName().toString(),
resolved.assetRoot()),
PackerAssetState.INVALID,
"unknown",
false,
true);
final PackerAssetDetails details = new PackerAssetDetails(summary, "unknown", "unknown", Map.of(), diagnostics);
return new GetAssetDetailsResult(
PackerOperationStatus.FAILED,
"Asset declaration is invalid or unreadable.",
details,
diagnostics);
}
private Map<String, List<Path>> resolveInputs(Path assetRoot, Map<String, List<String>> inputsByRole) {
final Map<String, List<Path>> resolved = new LinkedHashMap<>();
inputsByRole.forEach((role, inputs) -> resolved.put(
role,
inputs.stream().map(input -> assetRoot.resolve(input).toAbsolutePath().normalize()).toList()));
return Map.copyOf(resolved);
}
private ResolvedAssetReference resolveReference(PackerProjectContext project, PackerRegistryState registry, String reference) {
final PackerRegistryLookup lookup = workspaceFoundation.lookup();
final Optional<PackerRegistryEntry> byId = parseAssetId(reference).flatMap(assetId -> lookup.findByAssetId(registry, assetId));
if (byId.isPresent()) {
return new ResolvedAssetReference(
PackerWorkspacePaths.assetRoot(project, byId.get().root()),
byId,
List.of());
}
final Optional<PackerRegistryEntry> byUuid = lookup.findByAssetUuid(registry, reference);
if (byUuid.isPresent()) {
return new ResolvedAssetReference(
PackerWorkspacePaths.assetRoot(project, byUuid.get().root()),
byUuid,
List.of());
}
final Path candidateRoot = PackerWorkspacePaths.assetRoot(project, reference);
if (Files.isDirectory(candidateRoot) || Files.isRegularFile(candidateRoot.resolve("asset.json"))) {
return new ResolvedAssetReference(candidateRoot, lookup.findByRoot(project, registry, candidateRoot), List.of());
}
final Path missingRoot = candidateRoot;
return new ResolvedAssetReference(
missingRoot,
Optional.empty(),
List.of(new PackerDiagnostic(
PackerDiagnosticSeverity.ERROR,
PackerDiagnosticCategory.STRUCTURAL,
"Requested asset reference could not be resolved.",
missingRoot,
true)));
}
private Optional<Integer> parseAssetId(String reference) {
try {
return Optional.of(Integer.parseInt(reference.trim()));
} catch (NumberFormatException ignored) {
return Optional.empty();
}
}
private record ResolvedAssetReference(
Path assetRoot,
Optional<PackerRegistryEntry> registryEntry,
List<PackerDiagnostic> diagnostics) {
private ResolvedAssetReference {
assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize();
registryEntry = Objects.requireNonNull(registryEntry, "registryEntry");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}
}

View File

@ -0,0 +1,51 @@
package p.packer.declarations;
import org.junit.jupiter.api.Test;
import p.packer.api.diagnostics.PackerDiagnosticCategory;
import p.packer.testing.PackerFixtureLocator;
import static org.junit.jupiter.api.Assertions.*;
final class PackerAssetDeclarationParserTest {
private final PackerAssetDeclarationParser parser = new PackerAssetDeclarationParser();
@Test
void parsesValidDeclarationFixture() {
final var result = parser.parse(PackerFixtureLocator.fixtureRoot("workspaces/managed-basic/assets/ui/atlas/asset.json"));
assertTrue(result.valid());
assertNotNull(result.declaration());
assertEquals(1, result.declaration().schemaVersion());
assertEquals("ui_atlas", result.declaration().name());
assertEquals("image_bank", result.declaration().type());
assertEquals("TILES/indexed_v1", result.declaration().outputFormat());
assertEquals("RAW", result.declaration().outputCodec());
assertTrue(result.declaration().preloadEnabled());
}
@Test
void rejectsMalformedJsonWithStructuralDiagnostic() {
final var result = parser.parse(PackerFixtureLocator.fixtureRoot("workspaces/invalid-malformed/assets/bad/asset.json"));
assertFalse(result.valid());
assertNull(result.declaration());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.category() == PackerDiagnosticCategory.STRUCTURAL));
}
@Test
void rejectsMissingRequiredFields() {
final var result = parser.parse(PackerFixtureLocator.fixtureRoot("workspaces/invalid-missing-fields/assets/bad/asset.json"));
assertFalse(result.valid());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("name")));
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("format")));
}
@Test
void rejectsUnsupportedSchemaVersion() {
final var result = parser.parse(PackerFixtureLocator.fixtureRoot("workspaces/invalid-version/assets/bad/asset.json"));
assertFalse(result.valid());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.category() == PackerDiagnosticCategory.VERSIONING));
}
}

View File

@ -0,0 +1,90 @@
package p.packer.declarations;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.workspace.GetAssetDetailsRequest;
import p.packer.testing.PackerFixtureLocator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import static org.junit.jupiter.api.Assertions.*;
final class PackerAssetDetailsServiceTest {
@TempDir
Path tempDir;
@Test
void returnsManagedDetailsForRegisteredAssetReferenceById() throws Exception {
final Path projectRoot = copyFixture("workspaces/managed-basic", tempDir.resolve("managed"));
final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "1"));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(PackerAssetState.MANAGED, result.details().summary().state());
assertEquals("ui_atlas", result.details().summary().identity().assetName());
assertEquals("TILES/indexed_v1", result.details().outputFormat());
assertTrue(result.diagnostics().isEmpty());
}
@Test
void returnsOrphanDetailsForValidUnregisteredRootReference() throws Exception {
final Path projectRoot = copyFixture("workspaces/orphan-valid", tempDir.resolve("orphan"));
final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "orphans/ui_sounds"));
assertEquals(PackerOperationStatus.SUCCESS, result.status());
assertEquals(PackerAssetState.ORPHAN, result.details().summary().state());
assertEquals("ui_sounds", result.details().summary().identity().assetName());
}
@Test
void returnsInvalidDetailsForInvalidDeclaration() throws Exception {
final Path projectRoot = copyFixture("workspaces/invalid-missing-fields", tempDir.resolve("invalid"));
final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(projectRoot), "bad"));
assertEquals(PackerOperationStatus.FAILED, result.status());
assertEquals(PackerAssetState.INVALID, result.details().summary().state());
assertFalse(result.diagnostics().isEmpty());
}
@Test
void returnsFailureWhenReferenceCannotBeResolved() {
final PackerAssetDetailsService service = new PackerAssetDetailsService();
final var result = service.getAssetDetails(new GetAssetDetailsRequest(project(tempDir.resolve("empty")), "missing/root"));
assertEquals(PackerOperationStatus.FAILED, result.status());
assertEquals(PackerAssetState.INVALID, result.details().summary().state());
assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("could not be resolved")));
}
private PackerProjectContext project(Path root) {
return new PackerProjectContext("main", root);
}
private Path copyFixture(String relativePath, Path targetRoot) throws Exception {
final Path sourceRoot = PackerFixtureLocator.fixtureRoot(relativePath);
try (var stream = Files.walk(sourceRoot)) {
for (Path source : stream.sorted(Comparator.naturalOrder()).toList()) {
final Path target = targetRoot.resolve(sourceRoot.relativize(source).toString());
if (Files.isDirectory(source)) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(source, target);
}
}
}
return targetRoot;
}
}

View File

@ -0,0 +1,11 @@
{
"schema_version": 1,
"type": "image_bank",
"inputs": "wrong",
"output": {
"codec": ""
},
"preload": {
"enabled": true
}
}

View File

@ -0,0 +1,15 @@
{
"schema_version": 9,
"name": "future_asset",
"type": "image_bank",
"inputs": {
"sprites": ["future.png"]
},
"output": {
"format": "TILES/indexed_v1",
"codec": "RAW"
},
"preload": {
"enabled": true
}
}

View File

@ -0,0 +1,15 @@
{
"schema_version": 1,
"name": "ui_sounds",
"type": "sound_bank",
"inputs": {
"sources": ["confirm.wav"]
},
"output": {
"format": "SOUND/bank_v1",
"codec": "RAW"
},
"preload": {
"enabled": false
}
}