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 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 actions = new ArrayList<>(); final List blockers = new ArrayList<>(context.initialBlockers()); final List warnings = new ArrayList<>(); final List 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 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 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 affectedAssets) { eventSink.publish(new PackerEvent( project.projectId(), operationId, sequence, kind, Instant.now(), summary, null, affectedAssets)); } private List 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 initialBlockers) { } }