396 lines
19 KiB
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) {
|
|
}
|
|
}
|