prometeu-studio/prometeu-packer/src/main/java/p/packer/mutations/FileSystemPackerMutationService.java

396 lines
19 KiB
Java

package p.packer.mutations;
import p.packer.api.PackerOperationClass;
import p.packer.api.PackerOperationStatus;
import p.packer.api.PackerProjectContext;
import p.packer.api.assets.PackerAssetDetails;
import p.packer.api.assets.PackerAssetState;
import p.packer.api.diagnostics.PackerDiagnostic;
import p.packer.api.events.PackerEvent;
import p.packer.api.events.PackerEventKind;
import p.packer.api.events.PackerEventSink;
import p.packer.api.mutations.PackerMutationPreview;
import p.packer.api.mutations.PackerMutationRequest;
import p.packer.api.mutations.PackerMutationResult;
import p.packer.api.mutations.PackerMutationService;
import p.packer.api.mutations.PackerMutationType;
import p.packer.api.mutations.PackerProposedAction;
import p.packer.api.workspace.GetAssetDetailsRequest;
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.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Stream;
public final class FileSystemPackerMutationService implements PackerMutationService {
private final PackerWorkspaceFoundation workspaceFoundation;
private final PackerAssetDetailsService detailsService;
private final PackerProjectWriteCoordinator writeCoordinator;
private final PackerEventSink eventSink;
public FileSystemPackerMutationService() {
this(new PackerWorkspaceFoundation(), new PackerAssetDetailsService(), new PackerProjectWriteCoordinator(), PackerEventSink.noop());
}
public FileSystemPackerMutationService(
PackerWorkspaceFoundation workspaceFoundation,
PackerAssetDetailsService detailsService,
PackerProjectWriteCoordinator writeCoordinator,
PackerEventSink eventSink) {
this.workspaceFoundation = Objects.requireNonNull(workspaceFoundation, "workspaceFoundation");
this.detailsService = Objects.requireNonNull(detailsService, "detailsService");
this.writeCoordinator = Objects.requireNonNull(writeCoordinator, "writeCoordinator");
this.eventSink = Objects.requireNonNull(eventSink, "eventSink");
}
@Override
public PackerOperationClass operationClass() {
return PackerOperationClass.WORKSPACE_MUTATION;
}
@Override
public PackerMutationPreview preview(PackerMutationRequest request) {
final ResolvedMutationContext context = resolveContext(Objects.requireNonNull(request, "request"));
final PackerMutationPreview preview = buildPreview(context);
emit(context.project(), preview.operationId(), 0L, PackerEventKind.PREVIEW_READY, preview.summary(), affectedAssets(preview));
return preview;
}
@Override
public PackerMutationResult apply(PackerMutationPreview preview) {
Objects.requireNonNull(preview, "preview");
final PackerProjectContext project = preview.request().project();
try {
if (!preview.canApply()) {
throw new PackerMutationException("Cannot apply mutation preview with blockers");
}
final PackerMutationResult result = writeCoordinator.withWriteLock(project, () -> applyLocked(preview));
emit(project, result.operationId(), 1L, PackerEventKind.ASSET_CHANGED, "Asset state changed.", affectedAssets(preview));
emit(project, result.operationId(), 2L, PackerEventKind.ACTION_APPLIED, result.summary(), affectedAssets(preview));
return result;
} catch (RuntimeException exception) {
emit(project, preview.operationId(), 2L, PackerEventKind.ACTION_FAILED, rootCauseMessage(exception), affectedAssets(preview));
throw exception;
}
}
private PackerMutationResult applyLocked(PackerMutationPreview preview) {
final PackerProjectContext project = preview.request().project();
final PackerRegistryState registry = workspaceFoundation.loadRegistry(project);
final ResolvedMutationContext context = resolveContext(preview.request());
final Path assetRoot = context.assetDetails().summary().identity().assetRoot();
return switch (preview.request().type()) {
case REGISTER_ASSET -> applyRegister(project, registry, assetRoot, preview);
case INCLUDE_ASSET_IN_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, true);
case EXCLUDE_ASSET_FROM_BUILD -> applyBuildParticipationChange(project, registry, assetRoot, preview, false);
case REMOVE_ASSET -> applyRemove(project, registry, assetRoot, preview);
case RELOCATE_ASSET -> applyRelocate(project, registry, assetRoot, preview);
};
}
private PackerMutationResult applyRegister(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(project, assetRoot);
final Optional<PackerRegistryEntry> existing = workspaceFoundation.lookup().findByRoot(project, registry, assetRoot);
if (existing.isPresent()) {
return new PackerMutationResult(
PackerOperationStatus.SUCCESS,
"Asset is already registered.",
preview.operationId(),
List.of(),
List.of());
}
final PackerRegistryEntry entry = workspaceFoundation.allocateIdentity(project, registry, assetRoot);
final PackerRegistryState updated = workspaceFoundation.appendAllocatedEntry(registry, entry);
workspaceFoundation.saveRegistry(project, updated);
return new PackerMutationResult(
PackerOperationStatus.SUCCESS,
"Asset registered: " + relativeRoot,
preview.operationId(),
preview.proposedActions(),
List.of());
}
private PackerMutationResult applyBuildParticipationChange(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview,
boolean includedInBuild) {
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)
? new PackerRegistryEntry(entry.assetId(), entry.assetUuid(), entry.root(), includedInBuild)
: entry)
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
return new PackerMutationResult(
PackerOperationStatus.SUCCESS,
includedInBuild ? "Asset included in builds." : "Asset excluded from builds.",
preview.operationId(),
preview.proposedActions(),
List.of());
}
private PackerMutationResult applyRemove(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.filter(entry -> !PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot))
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
deleteRecursively(assetRoot);
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset removed from workspace.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationResult applyRelocate(
PackerProjectContext project,
PackerRegistryState registry,
Path assetRoot,
PackerMutationPreview preview) {
final Path targetRoot = requireTarget(preview);
final PackerRegistryState updated = registry.withAssets(
registry.assets().stream()
.map(entry -> PackerWorkspacePaths.assetRoot(project, entry.root()).equals(assetRoot)
? new PackerRegistryEntry(
entry.assetId(),
entry.assetUuid(),
PackerWorkspacePaths.relativeAssetRoot(project, targetRoot),
entry.includedInBuild())
: entry)
.toList(),
registry.nextAssetId());
workspaceFoundation.saveRegistry(project, updated);
moveAssetRoot(assetRoot, targetRoot);
return new PackerMutationResult(PackerOperationStatus.SUCCESS, "Asset root relocated.", preview.operationId(), preview.proposedActions(), List.of());
}
private PackerMutationPreview buildPreview(ResolvedMutationContext context) {
final PackerMutationRequest request = context.request();
final PackerAssetDetails assetDetails = context.assetDetails();
final Path assetRoot = assetDetails.summary().identity().assetRoot();
final String relativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), assetRoot);
final boolean managed = assetDetails.summary().identity().assetId() != null;
final List<PackerProposedAction> actions = new ArrayList<>();
final List<String> blockers = new ArrayList<>(context.initialBlockers());
final List<String> warnings = new ArrayList<>();
final List<String> safeFixes = new ArrayList<>();
Path targetAssetRoot = null;
switch (request.type()) {
case REGISTER_ASSET -> {
if (managed) {
blockers.add("Asset is already registered.");
}
if (assetDetails.diagnostics().stream().anyMatch(PackerDiagnostic::blocking)) {
blockers.add("Asset declaration must be valid before registration.");
}
if (!blockers.isEmpty()) {
break;
}
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "ADD", relativeRoot));
if (assetDetails.summary().hasDiagnostics()) {
warnings.add("Asset currently reports diagnostics and will still be registered.");
}
}
case INCLUDE_ASSET_IN_BUILD -> {
if (!managed) {
blockers.add("Only registered assets can be included in builds.");
} else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.INCLUDED) {
blockers.add("Asset is already included in builds.");
} else {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot));
warnings.add("The asset will remain registered and return to the build set.");
}
}
case EXCLUDE_ASSET_FROM_BUILD -> {
if (!managed) {
blockers.add("Only registered assets can be excluded from builds.");
} else if (assetDetails.summary().buildParticipation() == p.packer.api.assets.PackerBuildParticipation.EXCLUDED) {
blockers.add("Asset is already excluded from builds.");
} else {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot));
warnings.add("The asset will remain registered but leave the build set.");
}
}
case REMOVE_ASSET -> {
if (managed) {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "REMOVE", relativeRoot));
}
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "DELETE", relativeRoot));
warnings.add("Physical files inside the asset root will be deleted.");
}
case RELOCATE_ASSET -> {
targetAssetRoot = request.targetRoot() != null
? request.targetRoot()
: relocationTarget(context.project(), assetRoot, assetDetails.summary().identity().assetName());
final String targetRelativeRoot = PackerWorkspacePaths.relativeAssetRoot(context.project(), targetAssetRoot);
if (assetRoot.equals(targetAssetRoot)) {
blockers.add("Asset is already at the planned relocation target.");
} else {
if (managed) {
actions.add(new PackerProposedAction(PackerOperationClass.REGISTRY_MUTATION, "UPDATE", relativeRoot + " -> " + targetRelativeRoot));
}
actions.add(new PackerProposedAction(PackerOperationClass.WORKSPACE_MUTATION, "MOVE", relativeRoot + " -> " + targetRelativeRoot));
warnings.add("Relocation preserves asset identity, but it changes the root path seen by the workspace.");
}
}
}
return new PackerMutationPreview(
blockers.isEmpty() ? PackerOperationStatus.SUCCESS : PackerOperationStatus.PARTIAL,
"Mutation preview ready for " + request.type().name().toLowerCase(),
UUID.randomUUID().toString(),
request,
actions,
assetDetails.diagnostics(),
blockers,
warnings,
safeFixes,
request.type() == PackerMutationType.REMOVE_ASSET || request.type() == PackerMutationType.RELOCATE_ASSET,
targetAssetRoot);
}
private ResolvedMutationContext resolveContext(PackerMutationRequest request) {
final PackerAssetDetails assetDetails = detailsService.getAssetDetails(
new GetAssetDetailsRequest(request.project(), request.assetReference())).details();
final List<String> initialBlockers = assetDetails.diagnostics().stream()
.filter(PackerDiagnostic::blocking)
.map(PackerDiagnostic::message)
.filter(message -> request.type() != PackerMutationType.INCLUDE_ASSET_IN_BUILD
&& request.type() != PackerMutationType.EXCLUDE_ASSET_FROM_BUILD
&& request.type() != PackerMutationType.REMOVE_ASSET
&& request.type() != PackerMutationType.RELOCATE_ASSET)
.toList();
return new ResolvedMutationContext(request.project(), request, assetDetails, initialBlockers);
}
private Path relocationTarget(PackerProjectContext project, Path assetRoot, String assetName) {
final Path siblingParent = assetRoot.getParent() == null ? PackerWorkspacePaths.assetsRoot(project) : assetRoot.getParent();
return nextAvailablePath(siblingParent, assetRoot.getFileName().toString() + "-relocated");
}
private Path nextAvailablePath(Path parent, String baseName) {
final Path normalizedParent = parent.toAbsolutePath().normalize();
Path candidate = normalizedParent.resolve(baseName);
int index = 2;
while (Files.exists(candidate)) {
candidate = normalizedParent.resolve(baseName + "-" + index);
index += 1;
}
return candidate;
}
private String sanitizeSegment(String value) {
final String sanitized = value == null
? "asset"
: value.trim()
.replaceAll("[^A-Za-z0-9._-]+", "-")
.replaceAll("-{2,}", "-")
.replaceAll("^[.-]+|[.-]+$", "");
return sanitized.isBlank() ? "asset" : sanitized;
}
private void moveAssetRoot(Path sourceRoot, Path targetRoot) {
if (sourceRoot.equals(targetRoot)) {
return;
}
try {
Files.createDirectories(targetRoot.getParent());
Files.move(sourceRoot, targetRoot);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
private void deleteRecursively(Path root) {
if (!Files.exists(root)) {
return;
}
try (Stream<Path> stream = Files.walk(root)) {
stream.sorted(Comparator.reverseOrder()).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
});
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
private Path requireTarget(PackerMutationPreview preview) {
if (preview.targetAssetRoot() == null) {
throw new PackerMutationException("Mutation preview does not define a target asset root");
}
final Path targetRoot = preview.targetAssetRoot();
final Path projectRoot = preview.request().project().rootPath().toAbsolutePath().normalize();
if (!targetRoot.toAbsolutePath().normalize().startsWith(projectRoot.resolve("assets").toAbsolutePath().normalize())) {
throw new PackerMutationException("Mutation target root is outside the trusted assets boundary");
}
return targetRoot;
}
private void emit(
PackerProjectContext project,
String operationId,
long sequence,
PackerEventKind kind,
String summary,
List<String> affectedAssets) {
eventSink.publish(new PackerEvent(
project.projectId(),
operationId,
sequence,
kind,
Instant.now(),
summary,
null,
affectedAssets));
}
private List<String> affectedAssets(PackerMutationPreview preview) {
return List.of(preview.request().assetReference());
}
private String rootCauseMessage(Throwable throwable) {
Throwable current = throwable;
while (current.getCause() != null) {
current = current.getCause();
}
return current.getMessage() == null || current.getMessage().isBlank()
? current.getClass().getSimpleName()
: current.getMessage();
}
private record ResolvedMutationContext(
PackerProjectContext project,
PackerMutationRequest request,
PackerAssetDetails assetDetails,
List<String> initialBlockers) {
}
}