diff --git a/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java b/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java new file mode 100644 index 00000000..8d2f0ac6 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/workspace/FileSystemPackerWorkspaceService.java @@ -0,0 +1,151 @@ +package p.packer.workspace; + +import p.packer.api.PackerOperationClass; +import p.packer.api.PackerOperationStatus; +import p.packer.api.PackerProjectContext; +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.api.workspace.InitWorkspaceRequest; +import p.packer.api.workspace.InitWorkspaceResult; +import p.packer.api.workspace.ListAssetsRequest; +import p.packer.api.workspace.ListAssetsResult; +import p.packer.api.workspace.PackerWorkspaceService; +import p.packer.declarations.PackerAssetDeclarationParseResult; +import p.packer.declarations.PackerAssetDeclarationParser; +import p.packer.declarations.PackerAssetDetailsService; +import p.packer.foundation.PackerRegistryEntry; +import p.packer.foundation.PackerRegistryState; +import p.packer.foundation.PackerWorkspaceFoundation; +import p.packer.foundation.PackerWorkspacePaths; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +public final class FileSystemPackerWorkspaceService implements PackerWorkspaceService { + private final PackerWorkspaceFoundation workspaceFoundation; + private final PackerAssetDeclarationParser parser; + private final PackerAssetDetailsService detailsService; + + public FileSystemPackerWorkspaceService() { + this(new PackerWorkspaceFoundation(), new PackerAssetDeclarationParser()); + } + + public FileSystemPackerWorkspaceService( + PackerWorkspaceFoundation workspaceFoundation, + PackerAssetDeclarationParser parser) { + this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation"); + this.parser = Objects.requireNonNull(parser, "parser"); + this.detailsService = new PackerAssetDetailsService(workspaceFoundation, parser); + } + + @Override + public PackerOperationClass operationClass() { + return PackerOperationClass.READ_ONLY; + } + + @Override + public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) { + return workspaceFoundation.initWorkspace(request); + } + + @Override + public ListAssetsResult listAssets(ListAssetsRequest request) { + final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); + final Path assetsRoot = PackerWorkspacePaths.assetsRoot(project); + final PackerRegistryState registry = workspaceFoundation.loadRegistry(project); + final Map registryByRoot = new HashMap<>(); + for (PackerRegistryEntry entry : registry.assets()) { + registryByRoot.put(PackerWorkspacePaths.assetRoot(project, entry.root()), entry); + } + + final List assets = new ArrayList<>(); + final List diagnostics = new ArrayList<>(); + final Set discoveredRoots = new HashSet<>(); + + if (Files.isDirectory(assetsRoot)) { + try (Stream paths = Files.find(assetsRoot, Integer.MAX_VALUE, (path, attrs) -> + attrs.isRegularFile() && path.getFileName().toString().equals("asset.json"))) { + paths.forEach(assetManifestPath -> { + final Path assetRoot = assetManifestPath.getParent().toAbsolutePath().normalize(); + discoveredRoots.add(assetRoot); + final PackerRegistryEntry registryEntry = registryByRoot.get(assetRoot); + final PackerAssetDeclarationParseResult parsed = parser.parse(assetManifestPath); + diagnostics.addAll(parsed.diagnostics()); + assets.add(buildSummary(assetRoot, registryEntry, parsed)); + }); + } catch (IOException exception) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Unable to scan assets workspace: " + exception.getMessage(), + assetsRoot, + true)); + } + } + + for (PackerRegistryEntry entry : registry.assets()) { + final Path registeredRoot = PackerWorkspacePaths.assetRoot(project, entry.root()); + if (!discoveredRoots.contains(registeredRoot)) { + diagnostics.add(new PackerDiagnostic( + PackerDiagnosticSeverity.ERROR, + PackerDiagnosticCategory.STRUCTURAL, + "Registered asset root is missing asset.json: " + entry.root(), + registeredRoot.resolve("asset.json"), + true)); + } + } + + assets.sort(Comparator + .comparing((PackerAssetSummary asset) -> asset.identity().assetRoot().toString(), String.CASE_INSENSITIVE_ORDER) + .thenComparing(summary -> summary.identity().assetName(), String.CASE_INSENSITIVE_ORDER)); + final PackerOperationStatus status = diagnostics.stream().anyMatch(PackerDiagnostic::blocking) + ? PackerOperationStatus.PARTIAL + : PackerOperationStatus.SUCCESS; + return new ListAssetsResult( + status, + "Packer asset snapshot ready.", + assets, + diagnostics); + } + + @Override + public GetAssetDetailsResult getAssetDetails(GetAssetDetailsRequest request) { + return detailsService.getAssetDetails(request); + } + + private PackerAssetSummary buildSummary( + Path assetRoot, + PackerRegistryEntry registryEntry, + PackerAssetDeclarationParseResult parsed) { + final String assetName = parsed.declaration() != null + ? parsed.declaration().name() + : assetRoot.getFileName().toString(); + final String assetFamily = parsed.declaration() != null + ? parsed.declaration().type() + : "unknown"; + final boolean preload = parsed.declaration() != null && parsed.declaration().preloadEnabled(); + final boolean hasDiagnostics = !parsed.diagnostics().isEmpty(); + final PackerAssetState state = parsed.valid() + ? (registryEntry == null ? PackerAssetState.ORPHAN : PackerAssetState.MANAGED) + : PackerAssetState.INVALID; + return new PackerAssetSummary( + new PackerAssetIdentity( + registryEntry == null ? null : registryEntry.assetId(), + registryEntry == null ? null : registryEntry.assetUuid(), + assetName, + assetRoot), + state, + assetFamily, + preload, + hasDiagnostics); + } +} diff --git a/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java b/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java new file mode 100644 index 00000000..9ab6ea1b --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/workspace/FileSystemPackerWorkspaceServiceTest.java @@ -0,0 +1,76 @@ +package p.packer.workspace; + +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.ListAssetsRequest; +import p.packer.testing.PackerFixtureLocator; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +import static org.junit.jupiter.api.Assertions.*; + +final class FileSystemPackerWorkspaceServiceTest { + @TempDir + Path tempDir; + + @Test + void listsManagedAndOrphanAssetsFromWorkspaceScan() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-mixed", tempDir.resolve("mixed")); + final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.SUCCESS, result.status()); + assertEquals(2, result.assets().size()); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.MANAGED)); + assertTrue(result.assets().stream().anyMatch(asset -> asset.state() == PackerAssetState.ORPHAN)); + } + + @Test + void surfacesMissingRegisteredRootAsStructuralDiagnostic() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-missing-root", tempDir.resolve("missing-root")); + final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(PackerOperationStatus.PARTIAL, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> diagnostic.message().contains("missing asset.json"))); + } + + @Test + void includesInvalidDeclarationsInSnapshotWithDiagnostics() throws Exception { + final Path projectRoot = copyFixture("workspaces/read-invalid", tempDir.resolve("invalid")); + final FileSystemPackerWorkspaceService service = new FileSystemPackerWorkspaceService(); + + final var result = service.listAssets(new ListAssetsRequest(project(projectRoot))); + + assertEquals(1, result.assets().size()); + assertEquals(PackerAssetState.INVALID, result.assets().getFirst().state()); + assertTrue(result.assets().getFirst().hasDiagnostics()); + } + + 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; + } +} diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json b/prometeu-packer/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json new file mode 100644 index 00000000..fb786941 --- /dev/null +++ b/prometeu-packer/src/test/resources/fixtures/workspaces/read-invalid/assets/bad/asset.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "type": "image_bank", + "inputs": "wrong", + "output": { + "codec": "" + }, + "preload": { + "enabled": true + } +} diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json b/prometeu-packer/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json new file mode 100644 index 00000000..c6bb3691 --- /dev/null +++ b/prometeu-packer/src/test/resources/fixtures/workspaces/read-missing-root/assets/.prometeu/index.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "missing/atlas" + } + ] +} diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json new file mode 100644 index 00000000..cc68e1c5 --- /dev/null +++ b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/.prometeu/index.json @@ -0,0 +1,11 @@ +{ + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] +} diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json new file mode 100644 index 00000000..2f0f5aa3 --- /dev/null +++ b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/orphans/ui_sounds/asset.json @@ -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 + } +} diff --git a/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json new file mode 100644 index 00000000..66c95444 --- /dev/null +++ b/prometeu-packer/src/test/resources/fixtures/workspaces/read-mixed/assets/ui/atlas/asset.json @@ -0,0 +1,15 @@ +{ + "schema_version": 1, + "name": "ui_atlas", + "type": "image_bank", + "inputs": { + "sprites": ["sprites/confirm.png"] + }, + "output": { + "format": "TILES/indexed_v1", + "codec": "RAW" + }, + "preload": { + "enabled": true + } +} diff --git a/prometeu-studio/build.gradle.kts b/prometeu-studio/build.gradle.kts index 2705ae28..e57251e0 100644 --- a/prometeu-studio/build.gradle.kts +++ b/prometeu-studio/build.gradle.kts @@ -5,6 +5,7 @@ plugins { dependencies { implementation(project(":prometeu-infra")) + implementation(project(":prometeu-packer")) implementation(project(":prometeu-compiler:prometeu-compiler-core")) implementation(project(":prometeu-compiler:prometeu-build-pipeline")) implementation(project(":prometeu-compiler:prometeu-frontend-registry")) diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java index 73a53b6c..7ff7c1f2 100644 --- a/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/AssetWorkspace.java @@ -56,7 +56,7 @@ public final class AssetWorkspace implements Workspace { private String searchQuery = ""; public AssetWorkspace(ProjectReference projectReference) { - this(projectReference, new FileSystemAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService()); + this(projectReference, new PackerBackedAssetWorkspaceService(), new FileSystemAssetWorkspaceMutationService()); } public AssetWorkspace( diff --git a/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java new file mode 100644 index 00000000..a3782897 --- /dev/null +++ b/prometeu-studio/src/main/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceService.java @@ -0,0 +1,101 @@ +package p.studio.workspaces.assets; + +import p.packer.api.PackerProjectContext; +import p.packer.api.assets.PackerAssetDetails; +import p.packer.api.assets.PackerAssetState; +import p.packer.api.assets.PackerAssetSummary; +import p.packer.api.diagnostics.PackerDiagnostic; +import p.packer.api.diagnostics.PackerDiagnosticSeverity; +import p.packer.api.workspace.GetAssetDetailsRequest; +import p.packer.api.workspace.ListAssetsRequest; +import p.packer.workspace.FileSystemPackerWorkspaceService; +import p.studio.projects.ProjectReference; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; + +public final class PackerBackedAssetWorkspaceService implements AssetWorkspaceService { + private final FileSystemPackerWorkspaceService packerWorkspaceService; + + public PackerBackedAssetWorkspaceService() { + this(new FileSystemPackerWorkspaceService()); + } + + public PackerBackedAssetWorkspaceService(FileSystemPackerWorkspaceService packerWorkspaceService) { + this.packerWorkspaceService = Objects.requireNonNull(packerWorkspaceService, "packerWorkspaceService"); + } + + @Override + public AssetWorkspaceSnapshot loadWorkspace(ProjectReference projectReference) { + final var result = packerWorkspaceService.listAssets(new ListAssetsRequest(project(projectReference))); + final List assets = result.assets().stream() + .map(this::mapSummary) + .toList(); + return new AssetWorkspaceSnapshot(assets); + } + + @Override + public AssetWorkspaceAssetDetails loadAssetDetails(ProjectReference projectReference, AssetWorkspaceSelectionKey selectionKey) { + final String assetReference = switch (selectionKey) { + case AssetWorkspaceSelectionKey.ManagedAsset managedAsset -> Integer.toString(managedAsset.assetId()); + case AssetWorkspaceSelectionKey.OrphanAsset orphanAsset -> relativeAssetRoot(projectReference, orphanAsset.assetRoot()); + }; + final var result = packerWorkspaceService.getAssetDetails(new GetAssetDetailsRequest(project(projectReference), assetReference)); + return mapDetails(result.details()); + } + + private AssetWorkspaceAssetSummary mapSummary(PackerAssetSummary summary) { + final AssetWorkspaceAssetState state = toStudioState(summary); + final AssetWorkspaceSelectionKey selectionKey = state == AssetWorkspaceAssetState.MANAGED + ? new AssetWorkspaceSelectionKey.ManagedAsset(summary.identity().assetId()) + : new AssetWorkspaceSelectionKey.OrphanAsset(summary.identity().assetRoot()); + return new AssetWorkspaceAssetSummary( + selectionKey, + summary.identity().assetName(), + state, + summary.identity().assetId(), + summary.assetFamily(), + summary.identity().assetRoot(), + summary.preloadEnabled(), + summary.hasDiagnostics()); + } + + private AssetWorkspaceAssetDetails mapDetails(PackerAssetDetails details) { + return new AssetWorkspaceAssetDetails( + mapSummary(details.summary()), + details.outputFormat(), + details.outputCodec(), + details.inputsByRole(), + details.diagnostics().stream().map(this::mapDiagnostic).toList()); + } + + private AssetWorkspaceDiagnostic mapDiagnostic(PackerDiagnostic diagnostic) { + return new AssetWorkspaceDiagnostic( + switch (diagnostic.severity()) { + case ERROR -> AssetWorkspaceDiagnosticSeverity.BLOCKER; + case WARNING -> AssetWorkspaceDiagnosticSeverity.WARNING; + case INFO -> AssetWorkspaceDiagnosticSeverity.HINT; + }, + diagnostic.message()); + } + + private AssetWorkspaceAssetState toStudioState(PackerAssetSummary summary) { + if (summary.state() == PackerAssetState.MANAGED) { + return AssetWorkspaceAssetState.MANAGED; + } + return summary.identity().assetId() != null ? AssetWorkspaceAssetState.MANAGED : AssetWorkspaceAssetState.ORPHAN; + } + + private PackerProjectContext project(ProjectReference projectReference) { + final ProjectReference reference = Objects.requireNonNull(projectReference, "projectReference"); + return new PackerProjectContext(reference.name(), reference.rootPath()); + } + + private String relativeAssetRoot(ProjectReference projectReference, Path assetRoot) { + return projectReference.rootPath().resolve("assets").toAbsolutePath().normalize() + .relativize(assetRoot.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/'); + } +} diff --git a/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java new file mode 100644 index 00000000..b7304361 --- /dev/null +++ b/prometeu-studio/src/test/java/p/studio/workspaces/assets/PackerBackedAssetWorkspaceServiceTest.java @@ -0,0 +1,93 @@ +package p.studio.workspaces.assets; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.projects.ProjectReference; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerBackedAssetWorkspaceServiceTest { + @TempDir + Path tempDir; + + @Test + void loadsWorkspaceThroughPackerBackedSnapshot() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); + final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); + Files.createDirectories(managedRoot); + Files.createDirectories(orphanRoot); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(managedRoot.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(orphanRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "name": "ui_sounds", + "type": "sound_bank", + "inputs": { "sources": ["confirm.wav"] }, + "output": { "format": "SOUND/bank_v1", "codec": "RAW" }, + "preload": { "enabled": false } + } + """); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] + } + """); + + final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService(); + + final AssetWorkspaceSnapshot snapshot = service.loadWorkspace(project("Main", projectRoot)); + + assertEquals(2, snapshot.assets().size()); + assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.MANAGED)); + assertTrue(snapshot.assets().stream().anyMatch(asset -> asset.state() == AssetWorkspaceAssetState.ORPHAN)); + } + + @Test + void mapsInvalidDetailsToStudioDiagnostics() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final Path invalidRoot = projectRoot.resolve("assets/bad"); + Files.createDirectories(invalidRoot); + Files.writeString(invalidRoot.resolve("asset.json"), """ + { + "schema_version": 1, + "type": "image_bank" + } + """); + + final PackerBackedAssetWorkspaceService service = new PackerBackedAssetWorkspaceService(); + + final AssetWorkspaceAssetDetails details = service.loadAssetDetails( + project("Main", projectRoot), + new AssetWorkspaceSelectionKey.OrphanAsset(invalidRoot)); + + assertEquals("unknown", details.outputFormat()); + assertFalse(details.diagnostics().isEmpty()); + assertEquals(AssetWorkspaceDiagnosticSeverity.BLOCKER, details.diagnostics().getFirst().severity()); + } + + private ProjectReference project(String name, Path root) { + return new ProjectReference(name, "1.0.0", "pbs", 1, root); + } +}