ReproducibilityBundleExporter.java

package org.hammer.audio.export;

import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.hammer.audio.workflow.Node;
import org.hammer.audio.workflow.execution.ExecutionResult;
import org.hammer.audio.workflow.execution.ExecutionSnapshot;
import org.hammer.audio.workflow.execution.ExecutionStatus;
import org.hammer.audio.workflow.execution.ReproducibilityBundle;

/**
 * Writes a {@link ReproducibilityBundle} as a versioned directory of plain-text artifacts.
 *
 * <p>Given a parent directory and an immutable {@link ReproducibilityBundle} payload, creates a
 * {@code run-YYYYMMDD-HHMMSS} sub-directory containing:
 *
 * <ul>
 *   <li>{@code metadata.json} — bundle-level metadata: snapshot ID, workflow ID, commit ID,
 *       creation timestamp, overall execution status and node count.
 *   <li>{@code execution-result.json} — per-run outcome: execution ID, plan ID, started/completed
 *       instants, overall status and per-node terminal statuses.
 *   <li>{@code workflow-nodes.csv} — one row per workflow node: node ID, type and label.
 * </ul>
 *
 * <p>All methods are pure I/O functions. No workflow DSL serialisation, no Swing/UI code and no
 * JGit internals are used here. The class is intentionally kept narrow; new artifact types can be
 * added without changing the exporter contract.
 *
 * <p>Duplicate bundle names in the same parent directory are resolved by appending a numeric suffix
 * (e.g. {@code run-20240101-120000-1}), matching the behaviour of {@link EvidenceBundleExporter}.
 */
public final class ReproducibilityBundleExporter {

  private static final DateTimeFormatter DIR_TIMESTAMP =
      DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss", Locale.ROOT);

  private final ZoneId zoneId;

  /** Creates an exporter using the system default time zone. */
  public ReproducibilityBundleExporter() {
    this(ZoneId.systemDefault());
  }

  /**
   * Creates an exporter with a fixed time zone used to format the bundle directory name.
   *
   * @param zoneId time zone for the directory timestamp
   */
  public ReproducibilityBundleExporter(ZoneId zoneId) {
    if (zoneId == null) {
      throw new IllegalArgumentException("zoneId must not be null");
    }
    this.zoneId = zoneId;
  }

  /**
   * Writes a reproducibility bundle to a new sub-directory under {@code parentDirectory}.
   *
   * @param parentDirectory directory under which the bundle directory is created
   * @param bundle reproducibility evidence to export
   * @return the path to the created bundle directory
   * @throws IOException if writing any artifact fails
   * @throws IllegalArgumentException if bundle is null
   */
  public Path export(Path parentDirectory, ReproducibilityBundle bundle) throws IOException {
    if (parentDirectory == null) {
      throw new IllegalArgumentException("parentDirectory must not be null");
    }
    if (bundle == null) {
      throw new IllegalArgumentException("bundle must not be null");
    }

    Files.createDirectories(parentDirectory);
    Instant timestamp = bundle.result().completedAt();
    String baseName = "run-" + LocalDateTime.ofInstant(timestamp, zoneId).format(DIR_TIMESTAMP);
    Path bundleDir = parentDirectory.resolve(baseName);
    int suffix = 1;
    while (Files.exists(bundleDir)) {
      bundleDir = parentDirectory.resolve(baseName + "-" + suffix);
      suffix++;
    }
    Files.createDirectory(bundleDir);

    writeMetadata(bundleDir.resolve("metadata.json"), bundle);
    writeExecutionResult(bundleDir.resolve("execution-result.json"), bundle.result());
    writeWorkflowNodes(bundleDir.resolve("workflow-nodes.csv"), bundle.snapshot());

    return bundleDir;
  }

  private static void writeMetadata(Path file, ReproducibilityBundle bundle) throws IOException {
    ExecutionSnapshot snapshot = bundle.snapshot();
    ExecutionResult result = bundle.result();
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("{");
      w.printf(Locale.ROOT, "  \"snapshotId\": \"%s\",%n", escape(snapshot.snapshotId()));
      w.printf(Locale.ROOT, "  \"workflowId\": \"%s\",%n", escape(snapshot.workflowId()));
      w.printf(Locale.ROOT, "  \"snapshotCreatedAt\": \"%s\",%n", snapshot.createdAt());
      if (bundle.commitId() != null) {
        w.printf(Locale.ROOT, "  \"commitId\": \"%s\",%n", escape(bundle.commitId().value()));
      } else {
        w.printf(Locale.ROOT, "  \"commitId\": null,%n");
      }
      if (bundle.commitInfo() != null) {
        w.printf(
            Locale.ROOT,
            "  \"commitAuthor\": \"%s\",%n",
            escape(bundle.commitInfo().metadata().author()));
        w.printf(
            Locale.ROOT,
            "  \"commitMessage\": \"%s\",%n",
            escape(bundle.commitInfo().metadata().message()));
        w.printf(
            Locale.ROOT,
            "  \"commitTimestamp\": \"%s\",%n",
            bundle.commitInfo().metadata().timestamp());
      }
      w.printf(Locale.ROOT, "  \"overallStatus\": \"%s\",%n", result.overallStatus());
      w.printf(Locale.ROOT, "  \"nodeCount\": %d%n", snapshot.nodes().size());
      w.println("}");
    }
  }

  private static void writeExecutionResult(Path file, ExecutionResult result) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("{");
      w.printf(Locale.ROOT, "  \"executionId\": \"%s\",%n", escape(result.executionId()));
      w.printf(Locale.ROOT, "  \"planId\": \"%s\",%n", escape(result.planId()));
      w.printf(Locale.ROOT, "  \"startedAt\": \"%s\",%n", result.startedAt());
      w.printf(Locale.ROOT, "  \"completedAt\": \"%s\",%n", result.completedAt());
      w.printf(Locale.ROOT, "  \"overallStatus\": \"%s\",%n", result.overallStatus());
      w.println("  \"nodeStatuses\": {");
      List<Map.Entry<String, ExecutionStatus>> entries =
          result.nodeStatuses().entrySet().stream().sorted(Map.Entry.comparingByKey()).toList();
      for (int i = 0; i < entries.size(); i++) {
        Map.Entry<String, ExecutionStatus> entry = entries.get(i);
        String comma = (i < entries.size() - 1) ? "," : "";
        w.printf(
            Locale.ROOT, "    \"%s\": \"%s\"%s%n", escape(entry.getKey()), entry.getValue(), comma);
      }
      w.println("  }");
      w.println("}");
    }
  }

  private static void writeWorkflowNodes(Path file, ExecutionSnapshot snapshot) throws IOException {
    try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(file, StandardCharsets.UTF_8))) {
      w.println("nodeId,type,label");
      for (Node node : snapshot.nodes()) {
        w.printf(
            Locale.ROOT,
            "%s,%s,%s%n",
            escapeCsv(node.id()),
            escapeCsv(node.type()),
            escapeCsv(node.label()));
      }
    }
  }

  private static String escape(String s) {
    StringBuilder out = new StringBuilder(s.length() + 8);
    for (int i = 0; i < s.length(); i++) {
      char c = s.charAt(i);
      switch (c) {
        case '\\' -> out.append("\\\\");
        case '"' -> out.append("\\\"");
        case '\b' -> out.append("\\b");
        case '\f' -> out.append("\\f");
        case '\n' -> out.append("\\n");
        case '\r' -> out.append("\\r");
        case '\t' -> out.append("\\t");
        default -> {
          if (c < 0x20) {
            out.append(String.format(Locale.ROOT, "\\u%04x", (int) c));
          } else {
            out.append(c);
          }
        }
      }
    }
    return out.toString();
  }

  private static String escapeCsv(String s) {
    if (s == null) {
      return "";
    }
    if (s.contains(",") || s.contains("\"") || s.contains("\n")) {
      return "\"" + s.replace("\"", "\"\"") + "\"";
    }
    return s;
  }
}