diff --git a/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java b/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java new file mode 100644 index 00000000..302fa904 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/doctor/FileSystemPackerDoctorService.java @@ -0,0 +1,153 @@ +package p.packer.doctor; + +import p.packer.api.PackerOperationClass; +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.doctor.PackerDoctorMode; +import p.packer.api.doctor.PackerDoctorRequest; +import p.packer.api.doctor.PackerDoctorResult; +import p.packer.api.doctor.PackerDoctorService; +import p.packer.api.workspace.GetAssetDetailsRequest; +import p.packer.api.workspace.ListAssetsRequest; +import p.packer.declarations.PackerAssetDetailsService; +import p.packer.workspace.FileSystemPackerWorkspaceService; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +public final class FileSystemPackerDoctorService implements PackerDoctorService { + private final FileSystemPackerWorkspaceService workspaceService; + private final PackerAssetDetailsService detailsService; + + public FileSystemPackerDoctorService() { + this(new FileSystemPackerWorkspaceService(), new PackerAssetDetailsService()); + } + + public FileSystemPackerDoctorService( + FileSystemPackerWorkspaceService workspaceService, + PackerAssetDetailsService detailsService) { + this.workspaceService = Objects.requireNonNull(workspaceService, "workspaceService"); + this.detailsService = Objects.requireNonNull(detailsService, "detailsService"); + } + + @Override + public PackerOperationClass operationClass() { + return PackerOperationClass.READ_ONLY; + } + + @Override + public PackerDoctorResult doctor(PackerDoctorRequest request) { + final PackerDoctorRequest doctorRequest = Objects.requireNonNull(request, "request"); + final PackerProjectContext project = doctorRequest.project(); + final var snapshot = workspaceService.listAssets(new ListAssetsRequest(project)); + final List diagnostics = new ArrayList<>(); + final Set safeFixes = new LinkedHashSet<>(); + final Set seenDiagnostics = new LinkedHashSet<>(); + + for (PackerDiagnostic diagnostic : snapshot.diagnostics()) { + addDiagnostic(diagnostics, seenDiagnostics, diagnostic); + } + + for (var asset : snapshot.assets()) { + if (!includeAsset(doctorRequest.mode(), asset.state())) { + continue; + } + final String assetReference = asset.identity().assetId() == null + ? relativeAssetRoot(project, asset.identity().assetRoot()) + : Integer.toString(asset.identity().assetId()); + final var details = detailsService.getAssetDetails(new GetAssetDetailsRequest(project, assetReference)).details(); + for (PackerDiagnostic diagnostic : details.diagnostics()) { + addDiagnostic(diagnostics, seenDiagnostics, diagnostic); + } + + if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && asset.state() == PackerAssetState.ORPHAN) { + addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( + PackerDiagnosticSeverity.WARNING, + PackerDiagnosticCategory.HYGIENE, + "Orphan asset is valid but not registered in the managed build set: " + relativeAssetRoot(project, asset.identity().assetRoot()), + asset.identity().assetRoot(), + false)); + if (doctorRequest.includeSafeFixes() && details.summary().state() != PackerAssetState.INVALID) { + safeFixes.add("register_asset " + relativeAssetRoot(project, asset.identity().assetRoot())); + } + } + + if (doctorRequest.mode() == PackerDoctorMode.EXPANDED_WORKSPACE && isInsideQuarantine(project, asset.identity().assetRoot())) { + addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( + PackerDiagnosticSeverity.INFO, + PackerDiagnosticCategory.HYGIENE, + "Asset root is currently quarantined and excluded from the active workspace surface.", + asset.identity().assetRoot(), + false)); + } + + details.inputsByRole().forEach((role, inputs) -> inputs.forEach(input -> { + if (Files.isRegularFile(input)) { + return; + } + final boolean managed = asset.state() == PackerAssetState.MANAGED; + addDiagnostic(diagnostics, seenDiagnostics, new PackerDiagnostic( + managed ? PackerDiagnosticSeverity.ERROR : PackerDiagnosticSeverity.WARNING, + managed ? PackerDiagnosticCategory.STRUCTURAL : PackerDiagnosticCategory.HYGIENE, + "Declared input is missing for role '" + role + "': " + relativeEvidence(project, input), + input, + managed)); + })); + } + + final long blockingCount = diagnostics.stream().filter(PackerDiagnostic::blocking).count(); + final long warningCount = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == PackerDiagnosticSeverity.WARNING).count(); + final PackerOperationStatus status = blockingCount > 0L ? PackerOperationStatus.FAILED + : diagnostics.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL; + final String summary = blockingCount > 0L + ? "Doctor found " + blockingCount + " blocking diagnostics and " + warningCount + " warnings." + : diagnostics.isEmpty() + ? "Doctor found no diagnostics." + : "Doctor found " + diagnostics.size() + " diagnostics with no blockers."; + return new PackerDoctorResult(status, summary, diagnostics, List.copyOf(safeFixes)); + } + + private boolean includeAsset(PackerDoctorMode mode, PackerAssetState state) { + if (mode == PackerDoctorMode.EXPANDED_WORKSPACE) { + return true; + } + return state == PackerAssetState.MANAGED || state == PackerAssetState.INVALID; + } + + private void addDiagnostic(List diagnostics, Set seenDiagnostics, PackerDiagnostic diagnostic) { + final String key = diagnostic.category() + "|" + diagnostic.severity() + "|" + diagnostic.message() + "|" + diagnostic.evidencePath() + "|" + diagnostic.blocking(); + if (seenDiagnostics.add(key)) { + diagnostics.add(diagnostic); + } + } + + private boolean isInsideQuarantine(PackerProjectContext project, Path assetRoot) { + return assetRoot.toAbsolutePath().normalize() + .startsWith(project.rootPath().resolve("assets/.prometeu/quarantine").toAbsolutePath().normalize()); + } + + private String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { + return project.rootPath().resolve("assets").toAbsolutePath().normalize() + .relativize(assetRoot.toAbsolutePath().normalize()) + .toString() + .replace('\\', '/'); + } + + private String relativeEvidence(PackerProjectContext project, Path evidencePath) { + final Path normalizedEvidence = evidencePath.toAbsolutePath().normalize(); + final Path projectRoot = project.rootPath().toAbsolutePath().normalize(); + if (normalizedEvidence.startsWith(projectRoot)) { + return projectRoot.relativize(normalizedEvidence).toString().replace('\\', '/'); + } + return normalizedEvidence.toString(); + } +} diff --git a/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java b/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java new file mode 100644 index 00000000..cf64c943 --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/doctor/FileSystemPackerDoctorServiceTest.java @@ -0,0 +1,160 @@ +package p.packer.doctor; + +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.diagnostics.PackerDiagnosticCategory; +import p.packer.api.doctor.PackerDoctorMode; +import p.packer.api.doctor.PackerDoctorRequest; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class FileSystemPackerDoctorServiceTest { + @TempDir + Path tempDir; + + @Test + void managedWorldReportsBlockingDiagnosticsForMissingManagedInputs() throws Exception { + final Path projectRoot = createManagedProjectWithMissingInput(); + final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); + + final var result = service.doctor(new PackerDoctorRequest( + new PackerProjectContext("main", projectRoot), + PackerDoctorMode.MANAGED_WORLD, + false)); + + assertEquals(PackerOperationStatus.FAILED, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.category() == PackerDiagnosticCategory.STRUCTURAL + && diagnostic.blocking() + && diagnostic.message().contains("Declared input is missing"))); + assertTrue(result.safeFixes().isEmpty()); + } + + @Test + void expandedWorkspaceReportsOrphanAssetsAndRegisterSafeFixes() throws Exception { + final Path projectRoot = createExpandedWorkspace(); + final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); + + final var result = service.doctor(new PackerDoctorRequest( + new PackerProjectContext("main", projectRoot), + PackerDoctorMode.EXPANDED_WORKSPACE, + true)); + + assertEquals(PackerOperationStatus.PARTIAL, result.status()); + assertTrue(result.diagnostics().stream().anyMatch(diagnostic -> + diagnostic.category() == PackerDiagnosticCategory.HYGIENE + && diagnostic.message().contains("Orphan asset is valid but not registered"))); + assertTrue(result.safeFixes().contains("register_asset orphans/ui_sounds")); + } + + @Test + void managedWorldIgnoresOrphanOnlyHygieneFindings() throws Exception { + final Path projectRoot = tempDir.resolve("orphan-only"); + final Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); + Files.createDirectories(orphanRoot); + 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(orphanRoot.resolve("confirm.wav"), "beep"); + + final FileSystemPackerDoctorService service = new FileSystemPackerDoctorService(); + + final var result = service.doctor(new PackerDoctorRequest( + new PackerProjectContext("main", projectRoot), + PackerDoctorMode.MANAGED_WORLD, + true)); + + assertEquals(PackerOperationStatus.SUCCESS, result.status()); + assertTrue(result.diagnostics().isEmpty()); + assertTrue(result.safeFixes().isEmpty()); + } + + private Path createManagedProjectWithMissingInput() throws Exception { + final Path projectRoot = tempDir.resolve("managed-missing-input"); + final Path assetRoot = projectRoot.resolve("assets/ui/atlas"); + Files.createDirectories(assetRoot); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(assetRoot.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(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "schema_version": 1, + "next_asset_id": 2, + "assets": [ + { + "asset_id": 1, + "asset_uuid": "uuid-1", + "root": "ui/atlas" + } + ] + } + """); + return projectRoot; + } + + private Path createExpandedWorkspace() throws Exception { + final Path projectRoot = tempDir.resolve("expanded"); + final Path managedRoot = projectRoot.resolve("assets/ui/atlas"); + Files.createDirectories(managedRoot); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.createDirectories(managedRoot.resolve("sprites")); + 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(managedRoot.resolve("sprites/confirm.png"), "png"); + 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 Path orphanRoot = projectRoot.resolve("assets/orphans/ui_sounds"); + Files.createDirectories(orphanRoot); + 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(orphanRoot.resolve("confirm.wav"), "beep"); + return projectRoot; + } +}