diff --git a/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java new file mode 100644 index 00000000..b18036ad --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/FileSystemPackerRegistryRepository.java @@ -0,0 +1,118 @@ +package p.packer.foundation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import p.packer.api.PackerProjectContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public final class FileSystemPackerRegistryRepository implements PackerRegistryRepository { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int REGISTRY_SCHEMA_VERSION = 1; + + @Override + public PackerRegistryState load(PackerProjectContext project) { + final Path registryPath = PackerWorkspacePaths.registryPath(project); + if (!Files.isRegularFile(registryPath)) { + return emptyState(); + } + + try { + final RegistryDocument document = MAPPER.readValue(registryPath.toFile(), RegistryDocument.class); + final int schemaVersion = document.schemaVersion <= 0 ? REGISTRY_SCHEMA_VERSION : document.schemaVersion; + final List entries = new ArrayList<>(); + if (document.assets != null) { + for (RegistryAssetDocument asset : document.assets) { + if (asset == null) { + continue; + } + entries.add(new PackerRegistryEntry(asset.assetId, asset.assetUuid, normalizeRoot(asset.root))); + } + } + validateEntries(entries); + final int nextAssetId = document.nextAssetId > 0 + ? document.nextAssetId + : entries.stream().mapToInt(PackerRegistryEntry::assetId).max().orElse(0) + 1; + return new PackerRegistryState( + schemaVersion, + nextAssetId, + entries.stream().sorted(Comparator.comparingInt(PackerRegistryEntry::assetId)).toList()); + } catch (IOException exception) { + throw new PackerRegistryException("Unable to load registry: " + registryPath, exception); + } + } + + @Override + public void save(PackerProjectContext project, PackerRegistryState state) { + final Path registryDirectory = PackerWorkspacePaths.registryDirectory(project); + final Path registryPath = PackerWorkspacePaths.registryPath(project); + validateEntries(state.assets()); + try { + Files.createDirectories(registryDirectory); + final RegistryDocument document = new RegistryDocument(); + document.schemaVersion = state.schemaVersion(); + document.nextAssetId = state.nextAssetId(); + document.assets = state.assets().stream() + .map(entry -> new RegistryAssetDocument(entry.assetId(), entry.assetUuid(), entry.root())) + .toList(); + MAPPER.writerWithDefaultPrettyPrinter().writeValue(registryPath.toFile(), document); + } catch (IOException exception) { + throw new PackerRegistryException("Unable to save registry: " + registryPath, exception); + } + } + + private PackerRegistryState emptyState() { + return new PackerRegistryState(REGISTRY_SCHEMA_VERSION, 1, List.of()); + } + + private void validateEntries(List entries) { + final Set assetIds = new HashSet<>(); + final Set assetUuids = new HashSet<>(); + final Set roots = new HashSet<>(); + for (PackerRegistryEntry entry : entries) { + if (!assetIds.add(entry.assetId())) { + throw new PackerRegistryException("Duplicate asset_id in registry: " + entry.assetId()); + } + if (!assetUuids.add(entry.assetUuid())) { + throw new PackerRegistryException("Duplicate asset_uuid in registry: " + entry.assetUuid()); + } + if (!roots.add(entry.root())) { + throw new PackerRegistryException("Duplicate asset root in registry: " + entry.root()); + } + } + } + + private String normalizeRoot(String root) { + if (root == null || root.isBlank()) { + throw new PackerRegistryException("Registry asset root must not be blank"); + } + return root.trim().replace('\\', '/'); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private static final class RegistryDocument { + @JsonProperty("schema_version") + public int schemaVersion; + + @JsonProperty("next_asset_id") + public int nextAssetId; + + @JsonProperty("assets") + public List assets = List.of(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record RegistryAssetDocument( + @JsonProperty("asset_id") int assetId, + @JsonProperty("asset_uuid") String assetUuid, + @JsonProperty("root") String root) { + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java new file mode 100644 index 00000000..0b792854 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerIdentityAllocator.java @@ -0,0 +1,36 @@ +package p.packer.foundation; + +import p.packer.api.PackerProjectContext; + +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public final class PackerIdentityAllocator { + public PackerRegistryEntry allocate( + PackerProjectContext project, + PackerRegistryState state, + Path assetRoot) { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(state, "state"); + final Path normalizedAssetRoot = Objects.requireNonNull(assetRoot, "assetRoot").toAbsolutePath().normalize(); + final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, normalizedAssetRoot); + final boolean alreadyRegistered = state.assets().stream().anyMatch(entry -> entry.root().equals(relativeRoot)); + if (alreadyRegistered) { + throw new PackerRegistryException("Asset root is already registered: " + relativeRoot); + } + return new PackerRegistryEntry(state.nextAssetId(), UUID.randomUUID().toString(), relativeRoot); + } + + public PackerRegistryState append(PackerRegistryState state, PackerRegistryEntry entry) { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(entry, "entry"); + final List updated = state.assets().stream() + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + updated.add(entry); + updated.sort(Comparator.comparingInt(PackerRegistryEntry::assetId)); + return new PackerRegistryState(state.schemaVersion(), entry.assetId() + 1, updated); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java new file mode 100644 index 00000000..ae56a570 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryEntry.java @@ -0,0 +1,20 @@ +package p.packer.foundation; + +import java.util.Objects; + +public record PackerRegistryEntry( + int assetId, + String assetUuid, + String root) { + + public PackerRegistryEntry { + assetUuid = Objects.requireNonNull(assetUuid, "assetUuid").trim(); + root = Objects.requireNonNull(root, "root").trim(); + if (assetId <= 0) { + throw new IllegalArgumentException("assetId must be positive"); + } + if (assetUuid.isBlank() || root.isBlank()) { + throw new IllegalArgumentException("assetUuid and root must not be blank"); + } + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java new file mode 100644 index 00000000..5a0396f3 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryException.java @@ -0,0 +1,11 @@ +package p.packer.foundation; + +public final class PackerRegistryException extends RuntimeException { + public PackerRegistryException(String message) { + super(message); + } + + public PackerRegistryException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java new file mode 100644 index 00000000..ca6d4a84 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryLookup.java @@ -0,0 +1,38 @@ +package p.packer.foundation; + +import p.packer.api.PackerProjectContext; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +public final class PackerRegistryLookup { + public Optional findByAssetId(PackerRegistryState state, int assetId) { + Objects.requireNonNull(state, "state"); + return state.assets().stream().filter(entry -> entry.assetId() == assetId).findFirst(); + } + + public Optional findByAssetUuid(PackerRegistryState state, String assetUuid) { + Objects.requireNonNull(state, "state"); + final String normalized = Objects.requireNonNull(assetUuid, "assetUuid").trim(); + return state.assets().stream().filter(entry -> entry.assetUuid().equals(normalized)).findFirst(); + } + + public Optional findByRoot(PackerProjectContext project, PackerRegistryState state, Path assetRoot) { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(state, "state"); + final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, Objects.requireNonNull(assetRoot, "assetRoot")); + return state.assets().stream().filter(entry -> entry.root().equals(relativeRoot)).findFirst(); + } + + public Path resolveExistingRoot(PackerProjectContext project, PackerRegistryEntry entry) { + Objects.requireNonNull(project, "project"); + Objects.requireNonNull(entry, "entry"); + final Path assetRoot = PackerWorkspacePaths.assetRoot(project, entry.root()); + if (!Files.isDirectory(assetRoot)) { + throw new PackerRegistryException("Registered asset root does not exist: " + entry.root()); + } + return assetRoot; + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java new file mode 100644 index 00000000..3b979c1c --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryRepository.java @@ -0,0 +1,9 @@ +package p.packer.foundation; + +import p.packer.api.PackerProjectContext; + +public interface PackerRegistryRepository { + PackerRegistryState load(PackerProjectContext project); + + void save(PackerProjectContext project, PackerRegistryState state); +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java new file mode 100644 index 00000000..08c743b6 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerRegistryState.java @@ -0,0 +1,30 @@ +package p.packer.foundation; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public record PackerRegistryState( + int schemaVersion, + int nextAssetId, + List assets) { + + public PackerRegistryState { + if (schemaVersion <= 0) { + throw new IllegalArgumentException("schemaVersion must be positive"); + } + if (nextAssetId <= 0) { + throw new IllegalArgumentException("nextAssetId must be positive"); + } + assets = List.copyOf(Objects.requireNonNull(assets, "assets")); + } + + public PackerRegistryState withAssets(List entries, int nextId) { + return new PackerRegistryState( + schemaVersion, + nextId, + entries.stream() + .sorted(Comparator.comparingInt(PackerRegistryEntry::assetId)) + .toList()); + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java new file mode 100644 index 00000000..cfa5d843 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspaceFoundation.java @@ -0,0 +1,66 @@ +package p.packer.foundation; + +import p.packer.api.PackerOperationStatus; +import p.packer.api.PackerProjectContext; +import p.packer.api.workspace.InitWorkspaceRequest; +import p.packer.api.workspace.InitWorkspaceResult; + +import java.nio.file.Files; +import java.util.List; +import java.util.Objects; + +public final class PackerWorkspaceFoundation { + private final PackerRegistryRepository registryRepository; + private final PackerIdentityAllocator identityAllocator; + private final PackerRegistryLookup registryLookup; + + public PackerWorkspaceFoundation() { + this(new FileSystemPackerRegistryRepository(), new PackerIdentityAllocator(), new PackerRegistryLookup()); + } + + public PackerWorkspaceFoundation( + PackerRegistryRepository registryRepository, + PackerIdentityAllocator identityAllocator, + PackerRegistryLookup registryLookup) { + this.registryRepository = Objects.requireNonNull(registryRepository, "registryRepository"); + this.identityAllocator = Objects.requireNonNull(identityAllocator, "identityAllocator"); + this.registryLookup = Objects.requireNonNull(registryLookup, "registryLookup"); + } + + public InitWorkspaceResult initWorkspace(InitWorkspaceRequest request) { + final PackerProjectContext project = Objects.requireNonNull(request, "request").project(); + try { + Files.createDirectories(PackerWorkspacePaths.assetsRoot(project)); + Files.createDirectories(PackerWorkspacePaths.registryDirectory(project)); + final PackerRegistryState registryState = registryRepository.load(project); + registryRepository.save(project, registryState); + return new InitWorkspaceResult( + PackerOperationStatus.SUCCESS, + "Workspace initialized for packer control.", + PackerWorkspacePaths.registryPath(project), + List.of()); + } catch (Exception exception) { + throw new PackerRegistryException("Unable to initialize workspace for " + project.projectId(), exception); + } + } + + public PackerRegistryState loadRegistry(PackerProjectContext project) { + return registryRepository.load(project); + } + + public void saveRegistry(PackerProjectContext project, PackerRegistryState state) { + registryRepository.save(project, state); + } + + public PackerRegistryEntry allocateIdentity(PackerProjectContext project, PackerRegistryState state, java.nio.file.Path assetRoot) { + return identityAllocator.allocate(project, state, assetRoot); + } + + public PackerRegistryState appendAllocatedEntry(PackerRegistryState state, PackerRegistryEntry entry) { + return identityAllocator.append(state, entry); + } + + public PackerRegistryLookup lookup() { + return registryLookup; + } +} diff --git a/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java b/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java new file mode 100644 index 00000000..b22109c1 --- /dev/null +++ b/prometeu-packer/src/main/java/p/packer/foundation/PackerWorkspacePaths.java @@ -0,0 +1,35 @@ +package p.packer.foundation; + +import p.packer.api.PackerProjectContext; + +import java.nio.file.Path; +import java.util.Objects; + +public final class PackerWorkspacePaths { + private static final String ASSETS_DIR = "assets"; + private static final String PROMETEU_DIR = ".prometeu"; + private static final String REGISTRY_FILE = "index.json"; + + private PackerWorkspacePaths() { + } + + public static Path assetsRoot(PackerProjectContext project) { + return Objects.requireNonNull(project, "project").rootPath().resolve(ASSETS_DIR).toAbsolutePath().normalize(); + } + + public static Path registryDirectory(PackerProjectContext project) { + return assetsRoot(project).resolve(PROMETEU_DIR).toAbsolutePath().normalize(); + } + + public static Path registryPath(PackerProjectContext project) { + return registryDirectory(project).resolve(REGISTRY_FILE).toAbsolutePath().normalize(); + } + + public static Path assetRoot(PackerProjectContext project, String relativeRoot) { + return assetsRoot(project).resolve(relativeRoot).toAbsolutePath().normalize(); + } + + public static String relativeAssetRoot(PackerProjectContext project, Path assetRoot) { + return assetsRoot(project).relativize(assetRoot.toAbsolutePath().normalize()).toString().replace('\\', '/'); + } +} diff --git a/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java b/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java new file mode 100644 index 00000000..7e2d573d --- /dev/null +++ b/prometeu-packer/src/test/java/p/packer/foundation/PackerWorkspaceFoundationTest.java @@ -0,0 +1,136 @@ +package p.packer.foundation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.packer.api.PackerProjectContext; +import p.packer.api.workspace.InitWorkspaceRequest; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +final class PackerWorkspaceFoundationTest { + @TempDir + Path tempDir; + + @Test + void initWorkspaceCreatesAssetsControlStructureAndRegistry() throws Exception { + final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation(); + final PackerProjectContext project = project(tempDir.resolve("main")); + + final var result = foundation.initWorkspace(new InitWorkspaceRequest(project)); + + assertEquals(Path.of("assets/.prometeu/index.json"), project.rootPath().relativize(result.registryPath())); + assertTrue(Files.isDirectory(project.rootPath().resolve("assets"))); + assertTrue(Files.isDirectory(project.rootPath().resolve("assets/.prometeu"))); + assertTrue(Files.isRegularFile(project.rootPath().resolve("assets/.prometeu/index.json"))); + final String registryJson = Files.readString(project.rootPath().resolve("assets/.prometeu/index.json")); + assertTrue(registryJson.contains("\"schema_version\"")); + assertTrue(registryJson.contains("\"next_asset_id\"")); + } + + @Test + void registryRoundTripPreservesAllocatorAndEntries() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation(); + final PackerProjectContext project = project(projectRoot); + foundation.initWorkspace(new InitWorkspaceRequest(project)); + + final PackerRegistryState state = new PackerRegistryState( + 1, + 3, + java.util.List.of( + new PackerRegistryEntry(1, "uuid-1", "ui/atlas"), + new PackerRegistryEntry(2, "uuid-2", "audio/ui_sounds"))); + foundation.saveRegistry(project, state); + + final PackerRegistryState reloaded = foundation.loadRegistry(project); + + assertEquals(1, reloaded.schemaVersion()); + assertEquals(3, reloaded.nextAssetId()); + assertEquals(2, reloaded.assets().size()); + assertEquals("audio/ui_sounds", reloaded.assets().get(1).root()); + } + + @Test + void allocatorIsMonotonicAndPersistedAcrossSaveLoad() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation(); + final PackerProjectContext project = project(projectRoot); + foundation.initWorkspace(new InitWorkspaceRequest(project)); + Files.createDirectories(projectRoot.resolve("assets/ui/atlas")); + + final PackerRegistryEntry entry = foundation.allocateIdentity(project, foundation.loadRegistry(project), projectRoot.resolve("assets/ui/atlas")); + final PackerRegistryState updated = foundation.appendAllocatedEntry(foundation.loadRegistry(project), entry); + foundation.saveRegistry(project, updated); + + final PackerRegistryState reloaded = foundation.loadRegistry(project); + + assertEquals(2, reloaded.nextAssetId()); + assertEquals(1, reloaded.assets().size()); + assertEquals(1, reloaded.assets().getFirst().assetId()); + assertEquals("ui/atlas", reloaded.assets().getFirst().root()); + } + + @Test + void duplicateRootsFailClearly() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), """ + { + "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": "ui/atlas" } + ] + } + """); + final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); + + final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + + assertTrue(exception.getMessage().contains("Duplicate asset root")); + } + + @Test + void malformedRegistryFailsClearly() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + Files.createDirectories(projectRoot.resolve("assets/.prometeu")); + Files.writeString(projectRoot.resolve("assets/.prometeu/index.json"), "{ nope "); + final FileSystemPackerRegistryRepository repository = new FileSystemPackerRegistryRepository(); + + final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> repository.load(project(projectRoot))); + + assertTrue(exception.getMessage().contains("Unable to load registry")); + } + + @Test + void lookupResolvesByIdUuidAndRootAndFailsOnMissingRoot() throws Exception { + final Path projectRoot = tempDir.resolve("main"); + final PackerWorkspaceFoundation foundation = new PackerWorkspaceFoundation(); + final PackerProjectContext project = project(projectRoot); + foundation.initWorkspace(new InitWorkspaceRequest(project)); + Files.createDirectories(projectRoot.resolve("assets/ui/atlas")); + final PackerRegistryState state = new PackerRegistryState( + 1, + 2, + java.util.List.of(new PackerRegistryEntry(1, "uuid-1", "ui/atlas"))); + + final PackerRegistryLookup lookup = foundation.lookup(); + + assertTrue(lookup.findByAssetId(state, 1).isPresent()); + assertTrue(lookup.findByAssetUuid(state, "uuid-1").isPresent()); + assertTrue(lookup.findByRoot(project, state, projectRoot.resolve("assets/ui/atlas")).isPresent()); + assertEquals(projectRoot.resolve("assets/ui/atlas").toAbsolutePath().normalize(), lookup.resolveExistingRoot(project, state.assets().getFirst())); + + Files.delete(projectRoot.resolve("assets/ui/atlas")); + final PackerRegistryException exception = assertThrows(PackerRegistryException.class, () -> lookup.resolveExistingRoot(project, state.assets().getFirst())); + assertTrue(exception.getMessage().contains("does not exist")); + } + + private PackerProjectContext project(Path root) { + return new PackerProjectContext("main", root); + } +}