ExperimentLocalBindingService.java

package org.hammer.audio.experiment.document;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import org.hammer.audio.plugin.document.DocumentDiagnostic;

/** Verifies portable asset/output requirements against explicitly selected local resources. */
@SuppressWarnings("PMD.LooseCoupling")
public final class ExperimentLocalBindingService {

  /**
   * Inspect local bindings without modifying the document, selected files or output directory.
   *
   * <p>Asset hashes are streamed and may be expensive; callers must not invoke this method on a UI
   * or realtime thread.
   */
  public ExperimentLocalBindingPreview inspect(
      ExperimentDocumentPreview documentPreview, ExperimentLocalBindings bindings) {
    Objects.requireNonNull(documentPreview, "documentPreview");
    Objects.requireNonNull(bindings, "bindings");
    ArrayList<DocumentDiagnostic> diagnostics = new ArrayList<>();
    Set<String> requiredAssetIds = new HashSet<>();
    for (int index = 0; index < documentPreview.document().assets().size(); index++) {
      ExperimentDocument.AssetReference asset = documentPreview.document().assets().get(index);
      requiredAssetIds.add(asset.id());
      inspectAsset(asset, index, bindings, diagnostics);
    }
    bindings.assetPaths().keySet().stream()
        .filter(assetId -> !requiredAssetIds.contains(assetId))
        .forEach(
            assetId ->
                diagnostics.add(
                    warning(
                        "/localBindings/assets/" + escape(assetId),
                        "unused-asset-binding",
                        "Local asset binding does not match a document requirement")));
    inspectOutputDirectory(documentPreview.document(), bindings, diagnostics);
    boolean hasErrors =
        diagnostics.stream().anyMatch(item -> item.severity() == DocumentDiagnostic.Severity.ERROR);
    boolean ready = !hasErrors && documentPreview.executionAllowed() && !documentPreview.readOnly();
    return new ExperimentLocalBindingPreview(bindings, diagnostics, ready);
  }

  private static void inspectAsset(
      ExperimentDocument.AssetReference asset,
      int index,
      ExperimentLocalBindings bindings,
      ArrayList<DocumentDiagnostic> diagnostics) {
    String pointer = "/assets/" + index;
    Path local = bindings.assetPath(asset.id()).orElse(null);
    if (local == null) {
      diagnostics.add(
          error(
              pointer + "/localBinding",
              "asset-binding-missing",
              "No local file is selected for asset " + asset.id()));
      return;
    }
    if (!Files.isRegularFile(local)) {
      diagnostics.add(
          error(
              pointer + "/localBinding",
              "asset-file-unavailable",
              "Selected local asset is not a regular file"));
      return;
    }
    try {
      long size = Files.size(local);
      if (size != asset.sizeBytes()) {
        diagnostics.add(
            error(
                pointer + "/sizeBytes",
                "asset-size-mismatch",
                "Selected local asset size does not match the portable requirement"));
      }
      String sha256 = DocumentHashes.sha256(local);
      if (!sha256.equals(asset.sha256())) {
        diagnostics.add(
            error(
                pointer + "/sha256",
                "asset-hash-mismatch",
                "Selected local asset SHA-256 does not match the portable requirement"));
      }
    } catch (IOException exception) {
      diagnostics.add(
          error(
              pointer + "/localBinding",
              "asset-query-failed",
              "Could not inspect selected local asset: " + safeMessage(exception)));
    }
  }

  private static void inspectOutputDirectory(
      ExperimentDocument document,
      ExperimentLocalBindings bindings,
      ArrayList<DocumentDiagnostic> diagnostics) {
    if (document.outputs().isEmpty()) {
      return;
    }
    Path selected = bindings.selectedOutputDirectory().orElse(null);
    if (selected == null) {
      diagnostics.add(
          error(
              "/localBindings/outputDirectory",
              "output-binding-missing",
              "Select a local output directory for the requested outputs"));
      return;
    }
    if (Files.exists(selected) && !Files.isDirectory(selected)) {
      diagnostics.add(
          error(
              "/localBindings/outputDirectory",
              "output-not-directory",
              "Selected output path is not a directory"));
      return;
    }
    Path existing = nearestExistingAncestor(selected);
    if (existing == null || !Files.isDirectory(existing) || !Files.isWritable(existing)) {
      diagnostics.add(
          error(
              "/localBindings/outputDirectory",
              "output-not-writable",
              "Selected output directory or its nearest existing ancestor is not writable"));
    }
  }

  private static Path nearestExistingAncestor(Path path) {
    Path current = path;
    while (current != null && !Files.exists(current)) {
      current = current.getParent();
    }
    return current;
  }

  private static String escape(String value) {
    return value.replace("~", "~0").replace("/", "~1");
  }

  private static String safeMessage(IOException exception) {
    return exception.getMessage() == null
        ? exception.getClass().getSimpleName()
        : exception.getMessage();
  }

  private static DocumentDiagnostic error(String pointer, String code, String message) {
    return new DocumentDiagnostic(DocumentDiagnostic.Severity.ERROR, pointer, code, message);
  }

  private static DocumentDiagnostic warning(String pointer, String code, String message) {
    return new DocumentDiagnostic(DocumentDiagnostic.Severity.WARNING, pointer, code, message);
  }
}