implements PR-025 asset cache model and repository

This commit is contained in:
bQUARKz 2026-03-18 19:42:15 +00:00
parent 6a9e09bd88
commit 2870a0677b
Signed by: bquarkz
SSH Key Fingerprint: SHA256:Z7dgqoglWwoK6j6u4QC87OveEq74WOhFN+gitsxtkf8
5 changed files with 399 additions and 0 deletions

View File

@ -0,0 +1,22 @@
package p.packer.models;
import java.util.*;
public record PackerAssetCacheEntry(
int assetId,
List<PackerFileCacheEntry> files) {
public PackerAssetCacheEntry {
if (assetId <= 0) {
throw new IllegalArgumentException("assetId must be positive");
}
files = List.copyOf(Objects.requireNonNull(files, "files"));
}
public Optional<PackerFileCacheEntry> findFile(String relativePath) {
final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/');
return files.stream()
.filter(file -> file.relativePath().equals(safeRelativePath))
.findFirst();
}
}

View File

@ -0,0 +1,45 @@
package p.packer.models;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public record PackerFileCacheEntry(
String relativePath,
String mimeType,
long size,
long lastModified,
String fingerprint,
Map<String, Object> metadata) {
public PackerFileCacheEntry {
relativePath = normalizeRelativePath(relativePath);
mimeType = normalizeOptional(mimeType);
if (size < 0L) {
throw new IllegalArgumentException("size must be non-negative");
}
if (lastModified < 0L) {
throw new IllegalArgumentException("lastModified must be non-negative");
}
fingerprint = normalizeOptional(fingerprint);
metadata = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNull(metadata, "metadata")));
}
private static String normalizeRelativePath(String relativePath) {
final String safeRelativePath = Objects.requireNonNull(relativePath, "relativePath")
.trim()
.replace('\\', '/');
if (safeRelativePath.isEmpty()) {
throw new IllegalArgumentException("relativePath must not be blank");
}
return safeRelativePath;
}
private static String normalizeOptional(String value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@ -0,0 +1,25 @@
package p.packer.models;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record PackerWorkspaceCacheState(
int schemaVersion,
List<PackerAssetCacheEntry> assets) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public static final PackerWorkspaceCacheState EMPTY = new PackerWorkspaceCacheState(CURRENT_SCHEMA_VERSION, List.of());
public PackerWorkspaceCacheState {
if (schemaVersion <= 0) {
throw new IllegalArgumentException("schemaVersion must be positive");
}
assets = List.copyOf(Objects.requireNonNull(assets, "assets"));
}
public Optional<PackerAssetCacheEntry> findAsset(int assetId) {
return assets.stream()
.filter(asset -> asset.assetId() == assetId)
.findFirst();
}
}

View File

@ -0,0 +1,165 @@
package p.packer.repositories;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import p.packer.PackerWorkspacePaths;
import p.packer.exceptions.PackerRegistryException;
import p.packer.messages.PackerProjectContext;
import p.packer.models.PackerAssetCacheEntry;
import p.packer.models.PackerFileCacheEntry;
import p.packer.models.PackerWorkspaceCacheState;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
public final class FileSystemPackerCacheRepository {
private final ObjectMapper mapper;
public FileSystemPackerCacheRepository(ObjectMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
}
public PackerWorkspaceCacheState load(PackerProjectContext project) {
final Path cachePath = PackerWorkspacePaths.cachePath(project);
if (!Files.isRegularFile(cachePath)) {
return PackerWorkspaceCacheState.EMPTY;
}
try {
final CacheDocument document = mapper.readValue(cachePath.toFile(), CacheDocument.class);
final int schemaVersion = document.schemaVersion <= 0
? PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION
: document.schemaVersion;
if (schemaVersion != PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION) {
throw new PackerRegistryException("Unsupported cache schema_version: " + schemaVersion);
}
final List<PackerAssetCacheEntry> assets = new ArrayList<>();
if (document.assets != null) {
for (CacheAssetDocument asset : document.assets) {
if (asset == null) {
continue;
}
final List<PackerFileCacheEntry> files = new ArrayList<>();
if (asset.files != null) {
for (CacheFileDocument file : asset.files) {
if (file == null) {
continue;
}
files.add(new PackerFileCacheEntry(
normalizeRelativePath(file.relativePath),
file.mimeType,
file.size,
file.lastModified,
file.fingerprint,
file.metadata == null ? Map.of() : file.metadata));
}
}
validateAssetId(asset.assetId);
validateDuplicateRelativePaths(asset.assetId, files);
assets.add(new PackerAssetCacheEntry(asset.assetId, files));
}
}
validateDuplicateAssetIds(assets);
return new PackerWorkspaceCacheState(schemaVersion, assets.stream()
.sorted(Comparator.comparingInt(PackerAssetCacheEntry::assetId))
.toList());
} catch (IOException exception) {
throw new PackerRegistryException("Unable to load cache: " + cachePath, exception);
}
}
public void save(PackerProjectContext project, PackerWorkspaceCacheState state) {
final Path cachePath = PackerWorkspacePaths.cachePath(project);
final Path directory = PackerWorkspacePaths.registryDirectory(project);
final PackerWorkspaceCacheState safeState = Objects.requireNonNull(state, "state");
validateDuplicateAssetIds(safeState.assets());
safeState.assets().forEach(asset -> validateDuplicateRelativePaths(asset.assetId(), asset.files()));
try {
Files.createDirectories(directory);
final CacheDocument document = new CacheDocument();
document.schemaVersion = safeState.schemaVersion();
document.assets = safeState.assets().stream()
.map(asset -> new CacheAssetDocument(
asset.assetId(),
asset.files().stream()
.map(file -> new CacheFileDocument(
file.relativePath(),
file.mimeType(),
file.size(),
file.lastModified(),
file.fingerprint(),
file.metadata()))
.toList()))
.toList();
mapper.writerWithDefaultPrettyPrinter().writeValue(cachePath.toFile(), document);
} catch (IOException exception) {
throw new PackerRegistryException("Unable to save cache: " + cachePath, exception);
}
}
private void validateDuplicateAssetIds(List<PackerAssetCacheEntry> assets) {
final Set<Integer> assetIds = new HashSet<>();
for (PackerAssetCacheEntry asset : assets) {
validateAssetId(asset.assetId());
if (!assetIds.add(asset.assetId())) {
throw new PackerRegistryException("Duplicate asset_id in cache: " + asset.assetId());
}
}
}
private void validateDuplicateRelativePaths(int assetId, List<PackerFileCacheEntry> files) {
final Set<String> relativePaths = new HashSet<>();
for (PackerFileCacheEntry file : files) {
final String relativePath = normalizeRelativePath(file.relativePath());
if (!relativePaths.add(relativePath)) {
throw new PackerRegistryException("Duplicate cached file path for asset_id " + assetId + ": " + relativePath);
}
}
}
private void validateAssetId(int assetId) {
if (assetId <= 0) {
throw new PackerRegistryException("Cache asset_id must be positive");
}
}
private String normalizeRelativePath(String relativePath) {
final String normalized = Objects.requireNonNull(relativePath, "relativePath").trim().replace('\\', '/');
if (normalized.isBlank()) {
throw new PackerRegistryException("Cached relative path must not be blank");
}
final Path normalizedPath = Path.of(normalized).normalize();
if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) {
throw new PackerRegistryException("Cached relative path is outside the trusted asset boundary: " + normalized);
}
return normalizedPath.toString().replace('\\', '/');
}
@JsonIgnoreProperties(ignoreUnknown = true)
private static final class CacheDocument {
@JsonProperty("schema_version")
public int schemaVersion;
@JsonProperty("assets")
public List<CacheAssetDocument> assets = List.of();
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record CacheAssetDocument(
@JsonProperty("asset_id") int assetId,
@JsonProperty("files") List<CacheFileDocument> files) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
private record CacheFileDocument(
@JsonProperty("relative_path") String relativePath,
@JsonProperty("mime_type") String mimeType,
@JsonProperty("size") long size,
@JsonProperty("last_modified") long lastModified,
@JsonProperty("fingerprint") String fingerprint,
@JsonProperty("metadata") Map<String, Object> metadata) {
}
}

View File

@ -0,0 +1,142 @@
package p.packer.repositories;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import p.packer.PackerWorkspacePaths;
import p.packer.exceptions.PackerRegistryException;
import p.packer.messages.PackerProjectContext;
import p.packer.models.PackerAssetCacheEntry;
import p.packer.models.PackerFileCacheEntry;
import p.packer.models.PackerWorkspaceCacheState;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
final class FileSystemPackerCacheRepositoryTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@TempDir
Path tempDir;
@Test
void returnsEmptyStateWhenCacheIsAbsent() {
final var repository = new FileSystemPackerCacheRepository(MAPPER);
final var state = repository.load(project(tempDir.resolve("project")));
assertEquals(PackerWorkspaceCacheState.EMPTY, state);
}
@Test
void savesAndLoadsCacheRoundTrip() throws Exception {
final var repository = new FileSystemPackerCacheRepository(MAPPER);
final var project = project(tempDir.resolve("project"));
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(
new PackerAssetCacheEntry(3, List.of(
new PackerFileCacheEntry(
"tiles/ui.png",
"image/png",
128L,
42L,
"abc123",
Map.of("tile_count", 4)),
new PackerFileCacheEntry(
"tiles/ui-2.png",
"image/png",
256L,
84L,
"def456",
Map.of()))),
new PackerAssetCacheEntry(1, List.of(
new PackerFileCacheEntry(
"audio/click.wav",
"audio/wav",
512L,
21L,
null,
Map.of("sample_count", 8))))));
repository.save(project, state);
final var loaded = repository.load(project);
assertEquals(List.of(1, 3), loaded.assets().stream().map(PackerAssetCacheEntry::assetId).toList());
assertTrue(loaded.findAsset(3).flatMap(asset -> asset.findFile("tiles/ui.png")).isPresent());
assertEquals(128L, loaded.findAsset(3).orElseThrow().findFile("tiles/ui.png").orElseThrow().size());
}
@Test
void rejectsMalformedCacheFile() throws Exception {
final var repository = new FileSystemPackerCacheRepository(MAPPER);
final var project = project(tempDir.resolve("project"));
Files.createDirectories(PackerWorkspacePaths.registryDirectory(project));
Files.writeString(PackerWorkspacePaths.cachePath(project), "{ nope ");
final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project));
assertTrue(exception.getMessage().contains("Unable to load cache"));
}
@Test
void rejectsUnsupportedSchemaVersion() throws Exception {
final var repository = new FileSystemPackerCacheRepository(MAPPER);
final var project = project(tempDir.resolve("project"));
Files.createDirectories(PackerWorkspacePaths.registryDirectory(project));
Files.writeString(PackerWorkspacePaths.cachePath(project), """
{
"schema_version": 999,
"assets": []
}
""");
final var exception = assertThrows(PackerRegistryException.class, () -> repository.load(project));
assertTrue(exception.getMessage().contains("Unsupported cache schema_version"));
}
@Test
void alignsLookupByAssetId() {
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(
new PackerAssetCacheEntry(7, List.of()),
new PackerAssetCacheEntry(9, List.of())));
assertTrue(state.findAsset(7).isPresent());
assertTrue(state.findAsset(8).isEmpty());
}
@Test
void doesNotSerializeDiagnostics() throws Exception {
final var repository = new FileSystemPackerCacheRepository(MAPPER);
final var project = project(tempDir.resolve("project"));
final var state = new PackerWorkspaceCacheState(
PackerWorkspaceCacheState.CURRENT_SCHEMA_VERSION,
List.of(new PackerAssetCacheEntry(1, List.of(
new PackerFileCacheEntry(
"tiles/ui.png",
"image/png",
128L,
42L,
"abc123",
Map.of("warning_count", 2))))));
repository.save(project, state);
final String json = Files.readString(PackerWorkspacePaths.cachePath(project));
assertFalse(json.contains("diagnostic"));
assertFalse(json.contains("diagnostics"));
assertTrue(json.contains("\"asset_id\""));
assertTrue(json.contains("\"metadata\""));
}
private PackerProjectContext project(Path rootPath) {
return new PackerProjectContext("main", rootPath);
}
}