DotGraphSupport.java
package network.ike.plugin.ws;
import network.ike.workspace.Subproject;
import network.ike.workspace.WorkspaceGraph;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.zip.Deflater;
/**
* Graphviz DOT rendering for the workspace dependency graph.
*
* <p>Pure, stateless helpers — no mojo state, no I/O. Shared by
* {@link GraphWorkspaceMojo} ({@code ws:graph}) and
* {@link OverviewWorkspaceMojo} ({@code ws:overview}), both of which
* emit the graph as DOT on the console ({@code -Dformat=dot}) and as a
* fenced {@code ```dot} block in their markdown reports.
*
* <p>Extracted from {@code GraphWorkspaceMojo} so diagram rendering
* lives in a support class rather than on a mojo, and so
* {@code ws:overview} no longer reaches into a sibling mojo statically
* (IKE-Network/ike-issues#408, closing the #406 leftover).
*
* <p>IKE-DIAGRAMS.md mandates GraphViz for dependency graphs and
* IKE-DOC.md discourages Mermaid; this class is the single place the
* workspace graph becomes DOT.
*/
final class DotGraphSupport {
private DotGraphSupport() {
}
/**
* Build Graphviz DOT source for a whole workspace graph.
*
* <p>Extracts the subproject names and dependency edges from the
* {@link WorkspaceGraph} and delegates to {@link #buildDotGraph}.
*
* @param graph the workspace graph
* @return complete DOT source (a {@code digraph} block)
*/
static String dotFromGraph(WorkspaceGraph graph) {
List<String> subprojectNames = graph.manifest().subprojects()
.values().stream()
.map(Subproject::name)
.toList();
Map<String, List<String[]>> edges = new LinkedHashMap<>();
for (Subproject sub : graph.manifest().subprojects().values()) {
List<String[]> compEdges = sub.dependsOn().stream()
.map(dep -> new String[]{
dep.subproject(), dep.relationship()})
.toList();
if (!compEdges.isEmpty()) {
edges.put(sub.name(), compEdges);
}
}
return buildDotGraph("workspace", subprojectNames, edges);
}
/**
* Build a fenced Graphviz DOT block for a markdown report.
*
* <p>The workspace reports embed the graph as a {@code ```dot}
* block (IKE-Network/ike-issues#406).
*
* @param graph the workspace graph
* @return a fenced {@code ```dot} block, newline-terminated
*/
static String buildDotReportBlock(WorkspaceGraph graph) {
return "```dot\n" + dotFromGraph(graph) + "```\n";
}
/**
* Build the dependency-graph markdown section for a workspace
* report. Renders a Kroki-served SVG image for any markdown
* viewer that loads images (GitHub web, VS Code preview, Claude
* Code's session viewer) and keeps the DOT source under a
* collapsible {@code <details>} block for the IntelliJ Kroki /
* Graphviz IDE plugins and for offline source-of-truth. Replaces
* the bare {@code ```dot} fence that most markdown renderers
* showed as a wall of unformatted source (#533).
*
* @param graph the workspace graph
* @param krokiBase Kroki server base URL (e.g. {@code https://kroki.io}),
* trailing slash optional. When {@code null} or blank
* the rendered image is omitted and only the DOT
* block remains, matching the pre-#533 shape — useful
* for fully air-gapped runs.
* @return markdown for the graph section, newline-terminated
*/
static String buildDotReportSection(WorkspaceGraph graph, String krokiBase) {
String dot = dotFromGraph(graph);
StringBuilder out = new StringBuilder();
if (krokiBase != null && !krokiBase.isBlank()) {
out.append("
.append(krokiSvgUrl(dot, krokiBase))
.append(")\n\n");
out.append("<details><summary>DOT source</summary>\n\n");
out.append("```dot\n").append(dot).append("```\n\n");
out.append("</details>\n");
} else {
out.append("```dot\n").append(dot).append("```\n");
}
return out.toString();
}
/**
* Encode a DOT source string as a Kroki SVG image URL.
*
* <p>Kroki accepts the diagram source as a zlib-deflated, URL-safe-
* base64-encoded path segment, so the URL itself carries the
* complete diagram — no client-side state, no server-side
* persistence. See <a href="https://docs.kroki.io/kroki/setup/encode-diagram/">
* the Kroki encoding docs</a>.
*
* @param dot Graphviz DOT source
* @param krokiBase Kroki server base URL (trailing slash optional);
* e.g. {@code https://kroki.io} or an internal
* self-hosted endpoint
* @return full image URL ready to drop into a markdown
* {@code } link
*/
static String krokiSvgUrl(String dot, String krokiBase) {
String base = krokiBase.endsWith("/")
? krokiBase.substring(0, krokiBase.length() - 1)
: krokiBase;
return base + "/graphviz/svg/" + krokiEncode(dot);
}
/**
* Encode a diagram source string the way Kroki expects: zlib
* deflate at best compression, then URL-safe base64 without
* padding.
*
* @param source diagram source text
* @return encoded path segment for the Kroki URL
*/
static String krokiEncode(String source) {
byte[] input = source.getBytes(StandardCharsets.UTF_8);
Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION);
deflater.setInput(input);
deflater.finish();
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
while (!deflater.finished()) {
int n = deflater.deflate(buf);
out.write(buf, 0, n);
}
deflater.end();
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(out.toByteArray());
}
/**
* Build a Graphviz DOT graph from subproject names and edges.
*
* <p>This is a pure function with no workspace-model dependencies,
* suitable for direct unit testing.
*
* @param title graph name used in {@code digraph <title>}
* @param subprojectNames names of subprojects to include as nodes
* @param edges map of source subproject to list of
* {@code [target, relationship]} pairs
* @return complete DOT source
*/
static String buildDotGraph(String title,
List<String> subprojectNames,
Map<String, List<String[]>> edges) {
StringBuilder dot = new StringBuilder(1024);
dot.append("digraph ").append(title).append(" {\n");
dot.append(" rankdir=BT;\n");
dot.append(" node [shape=box, style=rounded, "
+ "fontname=\"Helvetica\"];\n");
dot.append("\n");
// Node declarations
for (String subName : subprojectNames) {
dot.append(" \"").append(subName).append("\";\n");
}
dot.append("\n");
// Edges
for (Map.Entry<String, List<String[]>> entry : edges.entrySet()) {
String source = entry.getKey();
for (String[] edge : entry.getValue()) {
String target = edge[0];
String relationship = edge[1];
String style = "content".equals(relationship)
? " [style=dashed]" : "";
dot.append(" \"").append(source).append("\" -> \"")
.append(target).append("\"").append(style)
.append(";\n");
}
}
dot.append("}\n");
return dot.toString();
}
}