StructurizrExportService.java

package com.taxonomy.export;

import com.taxonomy.diagram.DiagramEdge;
import com.taxonomy.diagram.DiagramModel;
import com.taxonomy.diagram.DiagramNode;

import java.util.HashMap;
import java.util.Map;

/**
 * Exports a {@link DiagramModel} as Structurizr DSL text.
 *
 * <p>This is the reverse of {@code StructurizrDslParser}: given an internal architecture
 * model, it produces a valid Structurizr workspace definition that can be imported into
 * Structurizr Lite or other C4-compatible tools.</p>
 */
public class StructurizrExportService {

    private static final Map<String, String> TYPE_MAPPING = Map.ofEntries(
            Map.entry("Capabilities", "container"),
            Map.entry("Services", "container"),
            Map.entry("Core Services", "container"),
            Map.entry("COI Services", "container"),
            Map.entry("Infrastructure", "softwareSystem"),
            Map.entry("User Applications", "softwareSystem"),
            Map.entry("Business Processes", "softwareSystem"),
            Map.entry("Business Roles", "person"),
            Map.entry("Communications", "component"),
            Map.entry("Communications Services", "component"),
            Map.entry("Applications", "softwareSystem"),
            Map.entry("Information Products", "softwareSystem")
    );

    /**
     * Exports the given {@link DiagramModel} as a Structurizr DSL workspace string.
     *
     * @param model the neutral diagram model
     * @return Structurizr DSL text
     */
    public String export(DiagramModel model) {
        StringBuilder sb = new StringBuilder();
        sb.append("workspace {\n\n");
        sb.append("    model {\n");

        Map<String, String> idMap = new HashMap<>();
        int counter = 1;

        for (DiagramNode node : model.nodes()) {
            // Container-only nodes are visual grouping constructs — skip them
            // so that they do not become false architecture semantics in the export.
            if (node.container()) continue;
            // Use node.id() to guarantee uniqueness; fall back to label for readability
            String id = sanitizeId(node.id(), counter++);
            // Ensure uniqueness even after sanitization
            while (idMap.containsValue(id)) {
                id = sanitizeId(node.id(), counter++);
            }
            idMap.put(node.id(), id);
            String c4Type = mapToC4Type(node.type());
            sb.append("        ").append(id).append(" = ").append(c4Type)
                    .append(" \"").append(escapeQuotes(node.label())).append("\"");
            if (node.type() != null && !node.type().isEmpty()) {
                sb.append(" \"").append(escapeQuotes(node.type())).append("\"");
            }
            sb.append('\n');
        }

        if (!model.nodes().isEmpty() && !model.edges().isEmpty()) {
            sb.append('\n');
        }

        for (DiagramEdge edge : model.edges()) {
            // Skip edges referencing nodes not declared in the model
            if (!idMap.containsKey(edge.sourceId()) || !idMap.containsKey(edge.targetId())) {
                continue;
            }
            String src = idMap.get(edge.sourceId());
            String tgt = idMap.get(edge.targetId());
            String desc = edge.relationType() != null ? edge.relationType() : "";
            sb.append("        ").append(src).append(" -> ").append(tgt)
                    .append(" \"").append(escapeQuotes(desc)).append("\"")
                    .append('\n');
        }

        sb.append("    }\n\n");
        sb.append("    views {\n");
        sb.append("        systemLandscape \"overview\" {\n");
        sb.append("            include *\n");
        sb.append("            autoLayout\n");
        sb.append("        }\n");
        sb.append("    }\n");
        sb.append("}\n");

        return sb.toString();
    }

    private String mapToC4Type(String taxonomyType) {
        if (taxonomyType == null) {
            return "softwareSystem";
        }
        return TYPE_MAPPING.getOrDefault(taxonomyType, "softwareSystem");
    }

    String sanitizeId(String label, int counter) {
        if (label == null || label.isBlank()) {
            return "element" + counter;
        }
        String id = label.replaceAll("[^a-zA-Z0-9]", "");
        if (id.isEmpty()) {
            return "element" + counter;
        }
        return Character.toLowerCase(id.charAt(0)) + id.substring(1);
    }

    private String escapeQuotes(String s) {
        return s == null ? "" : s.replace("\"", "\\\"");
    }
}