diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java new file mode 100644 index 00000000..a2d8482a --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlan.java @@ -0,0 +1,21 @@ +package p.packer.building; + +import java.util.List; +import java.util.Objects; + +public record PackerBuildPlan( + String cacheKey, + List assets, + String assetTableJson, + String preloadJson) { + + public PackerBuildPlan { + cacheKey = Objects.requireNonNull(cacheKey, "cacheKey").trim(); + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + assetTableJson = Objects.requireNonNull(assetTableJson, "assetTableJson").trim(); + preloadJson = Objects.requireNonNull(preloadJson, "preloadJson").trim(); + if (cacheKey.isBlank() || assetTableJson.isBlank() || preloadJson.isBlank()) { + throw new IllegalArgumentException("build plan fields must not be blank"); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java new file mode 100644 index 00000000..c1b72b81 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanResult.java @@ -0,0 +1,23 @@ +package p.packer.building; + +import p.packer.api.PackerOperationStatus; +import p.packer.api.diagnostics.PackerDiagnostic; + +import java.util.List; +import java.util.Objects; + +public record PackerBuildPlanResult( + PackerOperationStatus status, + String summary, + PackerBuildPlan plan, + List diagnostics) { + + public PackerBuildPlanResult { + Objects.requireNonNull(status, "status"); + summary = Objects.requireNonNull(summary, "summary").trim(); + diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); + if (summary.isBlank()) { + throw new IllegalArgumentException("summary must not be blank"); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java new file mode 100644 index 00000000..a58fb00b --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerBuildPlanner.java @@ -0,0 +1,220 @@ +package p.packer.building; + +import p.packer.api.PackerOperationStatus; +import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerAssetState; +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.ListAssetsRequest; +import p.packer.declarations.PackerAssetDetailsService; +import p.packer.workspace.FileSystemPackerWorkspaceService; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public final class PackerBuildPlanner { + private final FileSystemPackerWorkspaceService workspaceService; + private final PackerAssetDetailsService detailsService; + + public PackerBuildPlanner() { + this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService()); + } + + public PackerBuildPlanner( + FileSystemPackerWorkspaceService workspaceService, + PackerAssetDetailsService detailsService) { + this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); + this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); + } + + public PackerBuildPlanResult plan(PackerProjectContext project) { + final var snapshot = workspaceService.listAssets(new ListAssetsRequest(Objects.requireNonNull(project, "project"))); + final List diagnostics = new ArrayList<>(snapshot.diagnostics()); + final List plannedAssets = new ArrayList<>(); + + snapshot.assets().stream() + .filter(asset -> asset.state() == PackerAssetState.MANAGED) + .sorted(Comparator.comparingInt(asset -> asset.identity().assetId())) + .forEach(asset -> { + final var detailsResult = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, Integer.toString(asset.identity().assetId()))); + diagnostics.addAll(detailsResult.diagnostics()); + if (detailsResult.details().summary().state() != PackerAssetState.MANAGED) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.BUILD, + "Managed asset is not build-eligible: " + asset.identity().assetName(), + asset.identity().assetRoot(), + true)); + return; + } + + final List plannedInputs = new ArrayList<>(); + detailsResult.details().inputsByRole().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> entry.getValue().forEach(input -> { + if (!Files.isRegularFile(input)) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.BUILD, + "Build input is missing: " + relativePath(project, input), + input, + true)); + return; + } + plannedInputs.add(new PackerPlannedInput( + entry.getKey(), + asset.identity().assetRoot().relativize(input).toString().replace('\\', '/'), + fileHash(input), + input)); + })); + + if (diagnostics.stream().anyMatch(diagnostic -> + diagnostic.blocking() && asset.identity().assetRoot().equals(parentRoot(diagnostic.evidencePath(), asset.identity().assetRoot())))) { + return; + } + + plannedAssets.add(new PackerPlannedAsset( + asset.identity().assetId(), + asset.identity().assetUuid(), + asset.identity().assetName(), + asset.assetFamily(), + relativePath(project, asset.identity().assetRoot()), + detailsResult.details().outputFormat(), + detailsResult.details().outputCodec(), + asset.preloadEnabled(), + plannedInputs, + asset.identity().assetRoot())); + }); + + final boolean hasBlocking = diagnostics.stream().anyMatch(PackerDiagnostic::blocking); + if (hasBlocking) { + return new PackerBuildPlanResult( + PackerOperationStatus.FAILED, + "Build plan blocked by diagnostics.", + null, + List.copyOf(diagnostics)); + } + + final String assetTableJson = buildAssetTableJson(plannedAssets); + final String preloadJson = buildPreloadJson(plannedAssets); + final String cacheKey = sha256(PackerCanonicalJson.write(Map.of( + "asset_table", parseCanonical(assetTableJson), + "preload", parseCanonical(preloadJson), + "inputs", plannedAssets.stream().map(this::cacheKeyView).toList()))); + return new PackerBuildPlanResult( + diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL, + "Build plan ready for " + plannedAssets.size() + " managed assets.", + new PackerBuildPlan(cacheKey, plannedAssets, assetTableJson, preloadJson), + List.copyOf(diagnostics)); + } + + private Map cacheKeyView(PackerPlannedAsset asset) { + return Map.of( + "asset_family", asset.assetFamily(), + "asset_id", asset.assetId(), + "asset_name", asset.assetName(), + "asset_uuid", asset.assetUuid(), + "inputs", asset.inputs().stream().map(input -> Map.of( + "content_hash", input.contentHash(), + "path", input.relativePath(), + "role", input.role())).toList(), + "output_codec", asset.outputCodec(), + "output_format", asset.outputFormat(), + "preload", asset.preload(), + "root", asset.relativeRoot()); + } + + private String buildAssetTableJson(List assets) { + final List> rows = assets.stream() + .map(asset -> { + final Map row = new LinkedHashMap<>(); + row.put("asset_family", asset.assetFamily()); + row.put("asset_id", asset.assetId()); + row.put("asset_name", asset.assetName()); + row.put("asset_uuid", asset.assetUuid()); + row.put("output_codec", asset.outputCodec()); + row.put("output_format", asset.outputFormat()); + row.put("relative_root", asset.relativeRoot()); + return row; + }) + .toList(); + return PackerCanonicalJson.write(rows); + } + + private String buildPreloadJson(List assets) { + final List> rows = assets.stream() + .filter(PackerPlannedAsset::preload) + .map(asset -> { + final Map row = new LinkedHashMap<>(); + row.put("asset_id", asset.assetId()); + row.put("asset_name", asset.assetName()); + row.put("asset_uuid", asset.assetUuid()); + return row; + }) + .toList(); + return PackerCanonicalJson.write(rows); + } + + private Object parseCanonical(String json) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().readValue(json, Object.class); + } catch (IOException exception) { + throw new IllegalArgumentException("Unable to parse canonical JSON", exception); + } + } + + private Path parentRoot(Path evidencePath, Path fallbackRoot) { + if (evidencePath == null) { + return fallbackRoot; + } + Path current = evidencePath.toAbsolutePath().normalize(); + while (current != null && !current.equals(fallbackRoot)) { + if (current.getParent() != null && current.getParent().equals(fallbackRoot)) { + return fallbackRoot; + } + current = current.getParent(); + } + return fallbackRoot; + } + + private String relativePath(PackerProjectContext project, Path path) { + final Path normalized = path.toAbsolutePath().normalize(); + final Path assetsRoot = project.rootPath().resolve("assets").toAbsolutePath().normalize(); + if (normalized.startsWith(assetsRoot)) { + return assetsRoot.relativize(normalized).toString().replace('\\', '/'); + } + return normalized.toString(); + } + + private String fileHash(Path path) { + try { + return sha256(Files.readAllBytes(path)); + } catch (IOException exception) { + throw new IllegalArgumentException("Unable to hash build input: " + path, exception); + } + } + + private String sha256(String value) { + return sha256(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private String sha256(byte[] value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is not available", exception); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java b/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java new file mode 100644 index 00000000..fb86ad4c --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerCanonicalJson.java @@ -0,0 +1,23 @@ +package p.packer.building; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +public final class PackerCanonicalJson { + private static final ObjectMapper MAPPER = new ObjectMapper() + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + private PackerCanonicalJson() { + } + + public static String write(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("Unable to serialize canonical JSON value", exception); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java new file mode 100644 index 00000000..9b823742 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedAsset.java @@ -0,0 +1,32 @@ +package p.packer.building; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public record PackerPlannedAsset( + int assetId, + String assetUuid, + String assetName, + String assetFamily, + String relativeRoot, + String outputFormat, + String outputCodec, + boolean preload, + List inputs, + Path assetRoot) { + + public PackerPlannedAsset { + assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); + assetName = Objects.requireNonNull(assetName, "assetName").trim(); + assetFamily = Objects.requireNonNull(assetFamily, "assetFamily").trim(); + relativeRoot = Objects.requireNonNull(relativeRoot, "relativeRoot").trim(); + outputFormat = Objects.requireNonNull(outputFormat, "outputFormat").trim(); + outputCodec = Objects.requireNonNull(outputCodec, "outputCodec").trim(); + inputs = List.copyOf(Objects.requireNonNull(inputs, "inputs")); + assetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + if (assetId <= 0 || assetUuid.isBlank() || assetName.isBlank() || relativeRoot.isBlank() || outputFormat.isBlank() || outputCodec.isBlank()) { + throw new IllegalArgumentException("planned asset fields must be valid"); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java new file mode 100644 index 00000000..70b6dd6c --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/building/PackerPlannedInput.java @@ -0,0 +1,21 @@ +package p.packer.building; + +import java.nio.file.Path; +import java.util.Objects; + +public record PackerPlannedInput( + String role, + String relativePath, + String contentHash, + Path absolutePath) { + + public PackerPlannedInput { + role = Objects.requireNonNull(role, "role").trim(); + relativePath = Objects.requireNonNull(relativePath, "relativePath").trim(); + contentHash = Objects.requireNonNull(contentHash, "contentHash").trim(); + absolutePath = Objects.requireNonNull(absolutePath, "absolutePath").toAbsolutePath().normalize(); + if (role.isBlank() || relativePath.isBlank() || contentHash.isBlank()) { + throw new IllegalArgumentException("planned input fields must not be blank"); + } + } +} diff --git a/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java b/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java new file mode 100644 index 00000000..9a762ba1 --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/building/PackerBuildPlannerTest.java @@ -0,0 +1,125 @@ +package p.packer.building; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.api.PackerOperationStatus; +import p.packer.api.PackerProjectContext; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerBuildPlannerTest { + @TempDir + Path tempDir; + + @Test + void equivalentProjectsProduceEquivalentBuildPlans() throws Exception { + final Path left = createProject(tempDir.resolve("left"), false); + final Path right = createProject(tempDir.resolve("right"), true); + final PackerBuildPlanner planner = new PackerBuildPlanner(); + + final PackerBuildPlan leftPlan = planner.plan(new PackerProjectContext("left", left)).plan(); + final PackerBuildPlan rightPlan = planner.plan(new PackerProjectContext("right", right)).plan(); + + assertNotNull(leftPlan); + assertNotNull(rightPlan); + assertEquals(leftPlan.cacheKey(), rightPlan.cacheKey()); + assertEquals(leftPlan.assetTableJson(), rightPlan.assetTableJson()); + assertEquals(leftPlan.preloadJson(), rightPlan.preloadJson()); + } + + @Test + void assetOrderingIsDeterministicByAssetId() throws Exception { + final Path projectRoot = createProject(tempDir.resolve("ordered"), true); + final PackerBuildPlanner planner = new PackerBuildPlanner(); + + final PackerBuildPlan plan = planner.plan(new PackerProjectContext("ordered", projectRoot)).plan(); + + assertEquals(2, plan.assets().size()); + assertEquals(1, plan.assets().get(0).assetId()); + assertEquals(2, plan.assets().get(1).assetId()); + assertTrue(plan.assetTableJson().contains("\"asset_id\":1")); + assertTrue(plan.assetTableJson().indexOf("\"asset_id\":1") < plan.assetTableJson().indexOf("\"asset_id\":2")); + } + + @Test + void missingManagedInputsBlockBuildPlanning() throws Exception { + final Path projectRoot = createProject(tempDir.resolve("broken"), false); + Files.delete(projectRoot.resolve("assets/ui/atlas/sprites/confirm.png")); + final PackerBuildPlanner planner = new PackerBuildPlanner(); + + final var result = planner.plan(new PackerProjectContext("broken", projectRoot)); + + assertEquals(PackerOperationStatus.FAILED, result.status()); + assertNull(result.plan()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("Build input is missing"))); + } + + private Path createProject(Path projectRoot, boolean reverseRegistryOrder) throws Exception { + final Path atlasRoot = projectRoot.resolve("assets/ui/atlas"); + final Path soundsRoot = projectRoot.resolve("assets/audio/ui_sounds"); + Files.createDirectories(atlasRoot.resolve("sprites")); + Files.createDirectories(soundsRoot.resolve("sources")); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(atlasRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "name": "ui_atlas", + "type": "image_bank", + "inputs": { "sprites": ["sprites/confirm.png"] }, + "output": { "format": "TILES/indexed_v1", "codec": "RAW" }, + "preload": { "enabled": true } + } + """); + Files.writeString(soundsRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "name": "ui_sounds", + "type": "sound_bank", + "inputs": { "sources": ["sources/confirm.wav"] }, + "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, + "preload": { "enabled": false } + } + """); + Files.writeString(atlasRoot.resolve("sprites/confirm.png"), "png"); + Files.writeString(soundsRoot.resolve("sources/confirm.wav"), "wav"); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), reverseRegistryOrder ? """ + { + "schema_version": 1, + "next_asset_id": 3, + "assets": [ + { + "asset_id": 2, + "asset_uuid": "uuid-2", + "root": "audio/ui_sounds" + }, + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] + } + """ : """ + { + "schema_version": 1, + "next_asset_id": 3, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + }, + { + "asset_id": 2, + "asset_uuid": "uuid-2", + "root": "audio/ui_sounds" + } + ] + } + """); + return projectRoot; + } +}