WorkflowDslSerializer.java

package org.hammer.audio.workflow.dsl;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import org.hammer.audio.workflow.Edge;
import org.hammer.audio.workflow.Metadata;
import org.hammer.audio.workflow.Node;
import org.hammer.audio.workflow.Port;
import org.hammer.audio.workflow.Workflow;

/**
 * Deterministic text serializer for {@link Workflow} objects.
 *
 * <p>Produces byte-identical output for semantically equivalent workflows. Node, edge, port and
 * metadata entries are sorted by their stable identifiers so that Git diffs remain meaningful.
 * Layout, presence and viewport state are excluded.
 *
 * <p>Owned by the DSL layer. May be used by the persistence facade and application services, but
 * must not depend on UI, JGit or execution internals.
 *
 * @see WorkflowDslParser
 */
public final class WorkflowDslSerializer {

  private static final String INDENT = "  ";
  private static final String NL = "\n";

  /** Serializes {@code workflow} to a deterministic UTF-8 text string. */
  public String serialize(Workflow workflow) {
    StringBuilder sb = new StringBuilder();
    sb.append("workflow").append(NL);
    sb.append(INDENT).append("id: ").append(escapeValue(workflow.id())).append(NL);
    sb.append(INDENT).append("name: ").append(escapeValue(workflow.name())).append(NL);
    appendMetadata(sb, INDENT, workflow.metadata());
    sb.append(INDENT).append("nodes:").append(NL);
    List<Node> sortedNodes = sortedById(workflow.nodes(), Node::id);
    for (Node node : sortedNodes) {
      appendNode(sb, node);
    }
    sb.append(INDENT).append("edges:").append(NL);
    List<Edge> sortedEdges = sortedById(workflow.edges(), Edge::id);
    for (Edge edge : sortedEdges) {
      appendEdge(sb, edge);
    }
    return sb.toString();
  }

  private void appendNode(StringBuilder sb, Node node) {
    String i2 = INDENT + INDENT;
    String i3 = i2 + INDENT;
    sb.append(i2).append("- id: ").append(escapeValue(node.id())).append(NL);
    sb.append(i3).append("type: ").append(escapeValue(node.type())).append(NL);
    sb.append(i3).append("label: ").append(escapeValue(node.label())).append(NL);
    appendMetadata(sb, i3, node.metadata());
    if (!node.inputPorts().isEmpty()) {
      sb.append(i3).append("inputPorts:").append(NL);
      for (Port port : sortedById(node.inputPorts(), Port::id)) {
        appendPort(sb, i3 + INDENT, port);
      }
    }
    if (!node.outputPorts().isEmpty()) {
      sb.append(i3).append("outputPorts:").append(NL);
      for (Port port : sortedById(node.outputPorts(), Port::id)) {
        appendPort(sb, i3 + INDENT, port);
      }
    }
  }

  private void appendPort(StringBuilder sb, String indent, Port port) {
    String i2 = indent + INDENT;
    sb.append(indent).append("- id: ").append(escapeValue(port.id())).append(NL);
    sb.append(i2).append("name: ").append(escapeValue(port.name())).append(NL);
    sb.append(i2).append("direction: ").append(port.direction().name()).append(NL);
    sb.append(i2).append("dataType: ").append(escapeValue(port.dataType().id())).append(NL);
    sb.append(i2).append("required: ").append(port.required()).append(NL);
    sb.append(i2).append("multiplicity: ").append(port.multiplicity().name()).append(NL);
    appendMetadata(sb, i2, port.metadata());
  }

  private void appendEdge(StringBuilder sb, Edge edge) {
    String i2 = INDENT + INDENT;
    String i3 = i2 + INDENT;
    sb.append(i2).append("- id: ").append(escapeValue(edge.id())).append(NL);
    sb.append(i3).append("sourceNodeId: ").append(escapeValue(edge.sourceNodeId())).append(NL);
    sb.append(i3).append("sourcePortId: ").append(escapeValue(edge.sourcePortId())).append(NL);
    sb.append(i3).append("targetNodeId: ").append(escapeValue(edge.targetNodeId())).append(NL);
    sb.append(i3).append("targetPortId: ").append(escapeValue(edge.targetPortId())).append(NL);
    appendMetadata(sb, i3, edge.metadata());
  }

  private void appendMetadata(StringBuilder sb, String indent, Metadata metadata) {
    if (metadata == null || metadata.entries().isEmpty()) {
      return;
    }
    sb.append(indent).append("metadata:").append(NL);
    String i2 = indent + INDENT;
    Map<String, String> sorted = new TreeMap<>(metadata.entries());
    for (Map.Entry<String, String> entry : sorted.entrySet()) {
      sb.append(i2)
          .append(escapeValue(entry.getKey()))
          .append(": ")
          .append(escapeValue(entry.getValue()))
          .append(NL);
    }
  }

  /** Escapes a string value for safe inclusion in the DSL text. */
  static String escapeValue(String value) {
    if (value == null) {
      return "~";
    }
    if (needsQuoting(value)) {
      return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
    }
    return value;
  }

  private static boolean needsQuoting(String value) {
    if (value.isEmpty()) {
      return true;
    }
    for (int i = 0; i < value.length(); i++) {
      char c = value.charAt(i);
      if (c == ':' || c == '"' || c == '\\' || c == '\n' || c == '#' || c == '[' || c == ']') {
        return true;
      }
    }
    return false;
  }

  @FunctionalInterface
  private interface IdGetter<T> {
    String id(T item);
  }

  private <T> List<T> sortedById(List<T> items, IdGetter<T> getter) {
    List<T> sorted = new ArrayList<>(items);
    sorted.sort((a, b) -> getter.id(a).compareTo(getter.id(b)));
    return sorted;
  }
}