This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 167d537c739b CAMEL-26352: camel-diagram - mark the steps with a
problem in a route diagram image (#28586)
167d537c739b is described below
commit 167d537c739b9dcf7faf6c50181e93f7016828cd
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 29 07:11:35 2026 +0100
CAMEL-26352: camel-diagram - mark the steps with a problem in a route
diagram image (#28586)
camel cmd route-diagram of source files marks the steps that the checks of
camel validate find a problem with: a yellow card border and a badge, and a
callout beside the route (a numbered list below it for routes with branches).
Option --problems, on by default; the camel_render_route_diagram MCP tool marks
problems too.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../org/apache/camel/catalog/docs/diagram.adoc | 15 ++
.../camel-diagram/src/main/docs/diagram.adoc | 15 ++
.../src/main/docs/route-diagram-problems.png | Bin 0 -> 73746 bytes
.../camel/diagram/RouteDiagramLayoutEngine.java | 132 +++++++++++++-
.../apache/camel/diagram/RouteDiagramRenderer.java | 85 ++++++++-
.../camel/diagram/RouteDiagramCalloutTest.java | 190 ++++++++++++++++++++
.../modules/ROOT/images/route-diagram-problems.png | 1 +
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 5 +
.../camel-jbang-cmd-route-diagram.adoc | 1 +
.../META-INF/camel-jbang-commands-metadata.json | 2 +-
.../commands/action/CamelRouteDiagramAction.java | 11 ++
.../core/commands/action/RouteDiagramProblems.java | 173 ++++++++++++++++++
.../commands/action/RouteDiagramProblemsTest.java | 194 +++++++++++++++++++++
.../jbang/core/commands/mcp/RouteDiagramTools.java | 2 +
14 files changed, 820 insertions(+), 6 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
index e402b3e59d24..57ff8bb46125 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/diagram.adoc
@@ -203,6 +203,21 @@ camel cmd route-diagram myapp --output=myapp.png
--body-types=false
A diagram of a source file has no body types, as its routes have not run.
+=== Problems of the Steps
+
+A diagram of source files marks the steps that the checks of `camel validate`
find a problem with: the card has a
+yellow border and a badge, and the problem is in a callout beside the route,
at the height of the step. For example a
+Groovy step that reads fields of the file as it was read, before it is
unmarshalled:
+
+image::ROOT:route-diagram-problems.png[The orders route: the setBody step
reads body.items of the file as it was read, and a callout beside it says to
unmarshal it first]
+
+In a route with branches (`choice`, `multicast`, `doTry`, ...) a callout
beside the route would cross the steps of
+another branch, so the badge has a number, and the problems are a numbered
list below the route.
+
+A YAML route is checked as `camel validate` does: the YAML DSL schema, the
endpoints and expressions against the
+catalog, and the body flow. A Java or XML route is checked with the checks
that read the source without compiling it,
+as the route diagram reads Java routes without compiling them. Turn the
problems off with `--problems=false`.
+
== ASCII Art Rendering
The ASCII art renderer produces plain text diagrams using box-drawing
characters.
diff --git a/components/camel-diagram/src/main/docs/diagram.adoc
b/components/camel-diagram/src/main/docs/diagram.adoc
index e402b3e59d24..57ff8bb46125 100644
--- a/components/camel-diagram/src/main/docs/diagram.adoc
+++ b/components/camel-diagram/src/main/docs/diagram.adoc
@@ -203,6 +203,21 @@ camel cmd route-diagram myapp --output=myapp.png
--body-types=false
A diagram of a source file has no body types, as its routes have not run.
+=== Problems of the Steps
+
+A diagram of source files marks the steps that the checks of `camel validate`
find a problem with: the card has a
+yellow border and a badge, and the problem is in a callout beside the route,
at the height of the step. For example a
+Groovy step that reads fields of the file as it was read, before it is
unmarshalled:
+
+image::ROOT:route-diagram-problems.png[The orders route: the setBody step
reads body.items of the file as it was read, and a callout beside it says to
unmarshal it first]
+
+In a route with branches (`choice`, `multicast`, `doTry`, ...) a callout
beside the route would cross the steps of
+another branch, so the badge has a number, and the problems are a numbered
list below the route.
+
+A YAML route is checked as `camel validate` does: the YAML DSL schema, the
endpoints and expressions against the
+catalog, and the body flow. A Java or XML route is checked with the checks
that read the source without compiling it,
+as the route diagram reads Java routes without compiling them. Turn the
problems off with `--problems=false`.
+
== ASCII Art Rendering
The ASCII art renderer produces plain text diagrams using box-drawing
characters.
diff --git a/components/camel-diagram/src/main/docs/route-diagram-problems.png
b/components/camel-diagram/src/main/docs/route-diagram-problems.png
new file mode 100644
index 000000000000..5a1b940cf1e8
Binary files /dev/null and
b/components/camel-diagram/src/main/docs/route-diagram-problems.png differ
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
index 1f6f3f48db56..c2f9680e4e5f 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramLayoutEngine.java
@@ -56,6 +56,14 @@ public class RouteDiagramLayoutEngine {
* label above the counters of the metrics. More with a larger font
({@link #getBodyTypesGap()}).
*/
public static final int BODY_TYPES_GAP = 30 * SCALE;
+ /** The space between a route and the callouts of its problems. */
+ public static final int CALLOUT_GAP = 16 * SCALE;
+ /** The space around the text of a callout. */
+ public static final int CALLOUT_PAD = 8 * SCALE;
+ /** The space before the text of an entry of the list of problems: room
for its number. */
+ public static final int PROBLEM_NUMBER_WIDTH = 26 * SCALE;
+ // the end of a problem is often what to do about it, so a callout has
room for most of it
+ private static final int MAX_CALLOUT_LINES = 6;
private static final Pattern CARD_EXPRESSION =
Pattern.compile("^([A-Za-z][\\w-]*)\\{(.*)\\}$", Pattern.DOTALL);
public enum NodeLabelMode {
@@ -74,6 +82,7 @@ public class RouteDiagramLayoutEngine {
private boolean tableLayout;
private boolean cardStyle;
private boolean bodyTypes;
+ private boolean problems;
private final int bodyTypesGap;
// the space between the steps of the route being laid out
private int stepGap = V_GAP;
@@ -223,6 +232,24 @@ public class RouteDiagramLayoutEngine {
return bodyTypes;
}
+ /**
+ * Places the problems of the steps ({@link NodeInfo#problems}): in a
callout beside the route, at the height of the
+ * step, or in a route with branches (where a callout would cross the
steps of another branch) in a numbered list
+ * below the steps. Off by default.
+ */
+ public void setProblems(boolean problems) {
+ this.problems = problems;
+ }
+
+ public boolean isProblems() {
+ return problems;
+ }
+
+ /** The width of a callout of the problems of a step. */
+ public static int calloutWidth(int nodeWidth) {
+ return nodeWidth * 3 / 2;
+ }
+
/**
* The widest a label of body types can be: wider than a step, as the
steps of two branches are one and a half steps
* apart, and still apart from the label of a step beside it.
@@ -295,6 +322,11 @@ public class RouteDiagramLayoutEngine {
* the class names, at most a few.
*/
public List<String> bodyTypes = List.of();
+ /**
+ * The problems of the step, as the checks of the source report them,
such as that a groovy step reads fields of
+ * the file as it was read: drawn in the diagram when it shows
problems ({@link #setProblems}).
+ */
+ public List<String> problems = List.of();
/**
* The kinds of those bodies: null, payload, data and collection. More
than one is a step that needs attention,
* as a step that writes the body fails for the data, and one that
reads fields fails for the payload.
@@ -353,6 +385,26 @@ public class RouteDiagramLayoutEngine {
public RouteErrorFrame errorFrame;
/** Whether the arrows have room for the body types of the steps
({@link #setBodyTypes}). */
public boolean bodyTypes;
+ /** The problems of the steps, in callouts beside the route or in a
list below it ({@link #setProblems}). */
+ public final List<Callout> callouts = new ArrayList<>();
+ /**
+ * Whether the problems are a numbered list below the steps (a route
with branches), not callouts beside them.
+ */
+ public boolean problemList;
+ }
+
+ /** The problems of a step: a callout beside the route, or an entry of the
list below it. */
+ public static class Callout {
+ /** The step the problems are of. */
+ public LayoutNode node;
+ /** The number of the entry in the list of problems, from 1; 0 for a
callout. */
+ public int number;
+ public int x;
+ public int y;
+ public int width;
+ public int height;
+ /** The text, wrapped to the width (the last line may need to be cut
with an ellipsis). */
+ public List<String> lines;
}
public static TreeNode buildTree(List<NodeInfo> nodes) {
@@ -408,12 +460,16 @@ public class RouteDiagramLayoutEngine {
}
private List<String> wrapLabel(String label, int maxTextWidth) {
+ return wrapLabel(label, maxTextWidth, MAX_WRAP_LINES);
+ }
+
+ private List<String> wrapLabel(String label, int maxTextWidth, int
maxLines) {
if (fontMetrics.stringWidth(label) <= maxTextWidth) {
return List.of(label);
}
List<String> lines = new ArrayList<>();
String remaining = label;
- while (!remaining.isEmpty() && lines.size() < MAX_WRAP_LINES) {
+ while (!remaining.isEmpty() && lines.size() < maxLines) {
if (fontMetrics.stringWidth(remaining) <= maxTextWidth) {
lines.add(remaining);
remaining = "";
@@ -604,9 +660,83 @@ public class RouteDiagramLayoutEngine {
lr.maxX = extent.maxX + overhang;
lr.maxY = Math.max(lr.maxY, extent.maxY);
+ if (problems) {
+ layoutProblems(lr);
+ }
return lr;
}
+ private void layoutProblems(LayoutRoute lr) {
+ // in the order of the steps in the route (the order of the source)
+ List<LayoutNode> withProblems = lr.nodes.stream()
+ .filter(ln -> !ln.tableRow && ln.treeNode != null &&
!ln.treeNode.info.problems.isEmpty())
+ .toList();
+ if (withProblems.isEmpty()) {
+ return;
+ }
+ int lineHeight = fontMetrics.getHeight();
+ lr.problemList = lr.nodes.stream().anyMatch(ln ->
isBranchingEip(ln.type));
+ if (!lr.problemList) {
+ // a column of callouts beside the route, each at the height of
its step, below the one above it
+ int x = lr.maxX + CALLOUT_GAP;
+ int width = calloutWidth(nodeWidth);
+ int bottom = Integer.MIN_VALUE;
+ // without branches the order of the steps is from the top down
+ for (LayoutNode ln : withProblems) {
+ Callout c = new Callout();
+ c.node = ln;
+ c.lines = problemLines(ln.treeNode.info.problems, width - 2 *
CALLOUT_PAD);
+ c.x = x;
+ c.width = width;
+ c.height = c.lines.size() * lineHeight + 2 * CALLOUT_PAD;
+ c.y = Math.max(ln.y + ln.height / 2 - c.height / 2, bottom +
CALLOUT_PAD);
+ bottom = c.y + c.height;
+ lr.callouts.add(c);
+ lr.maxY = Math.max(lr.maxY, bottom);
+ }
+ lr.maxX = x + width;
+ } else {
+ // a numbered list below the steps, as wide as the route
+ int width = Math.max(lr.maxX - PADDING, nodeWidth * 2);
+ int y = lr.maxY + V_GAP / 2;
+ int number = 0;
+ for (LayoutNode ln : withProblems) {
+ Callout c = new Callout();
+ c.node = ln;
+ c.number = ++number;
+ c.lines = problemLines(ln.treeNode.info.problems, width -
PROBLEM_NUMBER_WIDTH);
+ c.x = PADDING;
+ c.y = y;
+ c.width = width;
+ c.height = c.lines.size() * lineHeight;
+ y += c.height + CALLOUT_PAD;
+ lr.callouts.add(c);
+ }
+ lr.maxY = y;
+ lr.maxX = Math.max(lr.maxX, PADDING + width);
+ }
+ }
+
+ /**
+ * The problems wrapped to the width, each from a line of its own, at most
six lines: a problem that does not fit
+ * has the rest of its text on its last line, which the renderer cuts with
an ellipsis, and the problems after it
+ * are left out, which an ellipsis says. So a line can be wider than the
width: the renderer draws each line cut to
+ * the width of the callout, and must keep doing so.
+ */
+ List<String> problemLines(List<String> problems, int width) {
+ List<String> lines = new ArrayList<>();
+ for (String problem : problems) {
+ int room = MAX_CALLOUT_LINES - lines.size();
+ if (room == 0) {
+ int last = lines.size() - 1;
+ lines.set(last, lines.get(last) + " …");
+ break;
+ }
+ lines.addAll(wrapLabel(problem, width, room));
+ }
+ return lines;
+ }
+
private static void markTables(TreeNode node) {
node.table = TABLE_EIPS.contains(node.info.type) &&
!node.children.isEmpty();
for (TreeNode child : node.children) {
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
index 552bcd3d570b..e3b79f1f7a39 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/RouteDiagramRenderer.java
@@ -71,23 +71,24 @@ public class RouteDiagramRenderer {
// the steps are cards (card, cardBorder, cardText for the kind,
cardDetail for the detail) with an icon in a
// circle of the color of the kind of step (from, to, eip, ...); text is
the text in the nodes of the topology;
- // warning, warningBg and warningText are the colors of the body types of
a step reached with more than one kind
+ // warning, warningBg and warningText are the colors of the body types of
a step reached with more than one kind,
+ // and of the problems of a step; badgeText is the mark in the warning
circle of a problem
private static final String DARK_COLORS
=
"bg=#0d1117:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+
":card=#151b23:cardBorder=#3d444d:cardText=#f0f6fc:cardDetail=#9198a1"
- + ":warning=#d29922:warningBg=#272115:warningText=#e3b341";
+ +
":warning=#d29922:warningBg=#272115:warningText=#e3b341:badgeText=#1f2328";
private static final String LIGHT_COLORS
=
"bg=#f6f8fa:text=#f0f6fc:arrow=#59636e:label=#1f2328:from=#238636:to=#1f6feb:eip=#8957e5"
+
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e"
- + ":warning=#d4a72c:warningBg=#fff8c5:warningText=#7d4e00";
+ +
":warning=#d4a72c:warningBg=#fff8c5:warningText=#7d4e00:badgeText=#1f2328";
// white cards, readable on any background
private static final String TRANSPARENT_COLORS
=
"bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+
":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a"
+
":card=#ffffff:cardBorder=#d0d7de:cardText=#1f2328:cardDetail=#59636e"
- + ":warning=#d4a72c:warningBg=#fff8c5:warningText=#7d4e00";
+ +
":warning=#d4a72c:warningBg=#fff8c5:warningText=#7d4e00:badgeText=#1f2328";
private static final Map<String, String> COLOR_PRESETS = Map.of(
"dark", DARK_COLORS,
@@ -144,6 +145,7 @@ public class RouteDiagramRenderer {
private Color warning;
private Color warningBg;
private Color warningText;
+ private Color badgeText;
/**
* The colors of a theme: a preset (dark, light, transparent) or a
spec of {@code key=color} pairs separated by
@@ -187,6 +189,7 @@ public class RouteDiagramRenderer {
c.warning = parseColor(map.getOrDefault("warning", "#d29922"));
c.warningBg = parseColor(map.getOrDefault("warningBg", "#272115"));
c.warningText = parseColor(map.getOrDefault("warningText",
"#e3b341"));
+ c.badgeText = parseColor(map.getOrDefault("badgeText", "#1f2328"));
return c;
}
@@ -291,6 +294,10 @@ public class RouteDiagramRenderer {
public Color getWarningText() {
return warningText;
}
+
+ public Color getBadgeText() {
+ return badgeText;
+ }
}
public BufferedImage renderDiagram(
@@ -473,6 +480,76 @@ public class RouteDiagramRenderer {
for (LayoutNode ln : lr.nodes) {
drawNode(g, ln, colors, ln == lastHighlightedNode);
}
+
+ for (RouteDiagramLayoutEngine.Callout c : lr.callouts) {
+ drawProblems(g, c, colors, lr.problemList, c.node ==
lastHighlightedNode);
+ }
+ }
+
+ /**
+ * The problems of a step ({@link RouteDiagramLayoutEngine#setProblems}):
a warning border and a badge on its card,
+ * and their text in a callout beside the route, pointed at the card, or
in an entry of the numbered list below the
+ * route, with the number of the badge.
+ */
+ private void drawProblems(
+ Graphics2D g, RouteDiagramLayoutEngine.Callout c, DiagramColors
colors, boolean list,
+ boolean highlightFail) {
+ LayoutNode node = c.node;
+ if (!highlightFail) {
+ g.setColor(colors.getWarning());
+ g.setStroke(new BasicStroke(HIGHLIGHT_STROKE_WIDTH));
+ g.drawRoundRect(node.x, node.y, nodeWidth, node.height, ARC, ARC);
+ }
+ String mark = list ? Integer.toString(c.number) : "!";
+ drawBadge(g, node.x + nodeWidth - 2 * SCALE, node.y + 2 * SCALE, mark,
colors);
+
+ Font font = new Font("SansSerif", Font.PLAIN, fontSizeNode);
+ FontMetrics fm = g.getFontMetrics(font);
+ int pad = RouteDiagramLayoutEngine.CALLOUT_PAD;
+ int textX;
+ int textWidth;
+ int baseline;
+ if (list) {
+ drawBadge(g, c.x + RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH /
2, c.y + fm.getHeight() / 2, mark, colors);
+ textX = c.x + RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH;
+ textWidth = c.width -
RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH;
+ baseline = c.y + fm.getAscent();
+ g.setColor(colors.getRouteLabel());
+ } else {
+ // the pointer from the card to its callout
+ int cardY = node.y + node.height / 2;
+ int pointY = Math.max(c.y + pad, Math.min(cardY, c.y + c.height -
pad));
+ g.setColor(colors.getWarning());
+ g.setStroke(new BasicStroke(STROKE_WIDTH));
+ g.drawLine(node.x + nodeWidth, cardY, c.x, pointY);
+ g.setColor(colors.getWarningBg());
+ g.fillRoundRect(c.x, c.y, c.width, c.height, ARC, ARC);
+ g.setColor(colors.getWarning());
+ g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
+ g.drawRoundRect(c.x, c.y, c.width, c.height, ARC, ARC);
+ textX = c.x + pad;
+ textWidth = c.width - 2 * pad;
+ baseline = c.y + pad + fm.getAscent();
+ g.setColor(colors.getWarningText());
+ }
+ g.setFont(font);
+ for (String line : c.lines) {
+ g.drawString(fit(fm, line, textWidth), textX, baseline);
+ baseline += fm.getHeight();
+ }
+ g.setStroke(new BasicStroke(STROKE_WIDTH));
+ }
+
+ /** A badge of a problem: a circle of the warning color with a mark ("!"
or the number of the problem). */
+ private void drawBadge(Graphics2D g, int cx, int cy, String mark,
DiagramColors colors) {
+ int d = 18 * SCALE;
+ g.setColor(colors.getWarning());
+ g.fillOval(cx - d / 2, cy - d / 2, d, d);
+ Font font = new Font("SansSerif", Font.BOLD, fontSizeNode - SCALE);
+ FontMetrics fm = g.getFontMetrics(font);
+ g.setFont(font);
+ g.setColor(colors.getBadgeText());
+ g.drawString(mark, cx - fm.stringWidth(mark) / 2, cy - fm.getHeight()
/ 2 + fm.getAscent());
}
private void drawRoute(Graphics2D g, LayoutRoute lr, DiagramColors colors)
{
diff --git
a/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java
new file mode 100644
index 000000000000..23fb95948f07
--- /dev/null
+++
b/components/camel-diagram/src/test/java/org/apache/camel/diagram/RouteDiagramCalloutTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.diagram;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.Callout;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.diagram.RouteDiagramRenderer.DiagramColors;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The problems of the steps in a route diagram image: callouts beside a
route, a numbered list below a route with
+ * branches.
+ */
+class RouteDiagramCalloutTest {
+
+ private static final String GROOVY = "groovy reads fields of the body
(body.items), but the body here is the file as"
+ + " it was read (a GenericFile), not
parsed data - unmarshal it first";
+
+ private static NodeInfo node(String type, String code, int level,
String... problems) {
+ NodeInfo n = new NodeInfo();
+ n.type = type;
+ n.id = type + level + code.length();
+ n.code = code;
+ n.level = level;
+ n.problems = List.of(problems);
+ return n;
+ }
+
+ private static RouteInfo orders() {
+ RouteInfo r = new RouteInfo();
+ r.routeId = "orders";
+ r.nodes.add(node("route", "route[orders]", 0));
+ r.nodes.add(node("from", "from[file:orders]", 1));
+ r.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2, GROOVY));
+ r.nodes.add(node("to", "to[http:pricing]", 2, "the http producer
cannot send a Map"));
+ r.nodes.add(node("to", "to[jms:priced]", 2));
+ return r;
+ }
+
+ private static RouteInfo choice() {
+ RouteInfo r = new RouteInfo();
+ r.routeId = "choice";
+ r.nodes.add(node("route", "route[choice]", 0));
+ r.nodes.add(node("from", "from[timer:tick]", 1));
+ r.nodes.add(node("choice", "choice", 2));
+ r.nodes.add(node("when", "when[simple{${header.kind} == 0}]", 3));
+ r.nodes.add(node("to", "to[kafka:zero?brokerz=localhost]", 4, "kafka
has no option brokerz"));
+ r.nodes.add(node("otherwise", "otherwise", 3));
+ r.nodes.add(node("marshal", "marshal[jackson]", 4, "the body is a
String here"));
+ return r;
+ }
+
+ private static RouteDiagramLayoutEngine engine(boolean problems) {
+ RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+ engine.setCardStyle(true);
+ engine.setProblems(problems);
+ return engine;
+ }
+
+ @Test
+ void calloutsBesideARoute() {
+ RouteDiagramLayoutEngine engine = engine(true);
+ LayoutRoute lr = engine.layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+
+ assertThat(lr.problemList).isFalse();
+ assertThat(lr.callouts).hasSize(2);
+ Callout groovy = lr.callouts.get(0);
+ Callout http = lr.callouts.get(1);
+ assertThat(groovy.node.type).isEqualTo("setBody");
+ assertThat(groovy.number).isZero();
+ // right of the steps, the text wrapped to more than one line
+ int right = groovy.node.x + engine.getNodeWidth();
+ assertThat(groovy.x).isEqualTo(right +
RouteDiagramLayoutEngine.CALLOUT_GAP);
+ assertThat(groovy.lines).hasSizeGreaterThan(1);
+ assertThat(String.join(" ", groovy.lines)).contains("GenericFile");
+ // one below the other, and room for them in the image
+ assertThat(http.y).isGreaterThanOrEqualTo(groovy.y + groovy.height);
+ assertThat(lr.maxX).isEqualTo(groovy.x + groovy.width);
+
+ // without problems shown, the route does not change
+ LayoutRoute plain = engine(false).layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+ assertThat(plain.callouts).isEmpty();
+ assertThat(plain.maxX).isLessThan(lr.maxX);
+ }
+
+ @Test
+ void numberedListBelowARouteWithBranches() {
+ RouteDiagramLayoutEngine engine = engine(true);
+ LayoutRoute plain = engine(false).layoutRoute(choice(),
RouteDiagramLayoutEngine.PADDING);
+ LayoutRoute lr = engine.layoutRoute(choice(),
RouteDiagramLayoutEngine.PADDING);
+
+ assertThat(lr.problemList).isTrue();
+ assertThat(lr.callouts).hasSize(2);
+ // numbered in the order of the steps in the route, below the steps
+ assertThat(lr.callouts.get(0).number).isEqualTo(1);
+ assertThat(lr.callouts.get(0).node.type).isEqualTo("to");
+ assertThat(lr.callouts.get(1).number).isEqualTo(2);
+ assertThat(lr.callouts.get(1).node.type).isEqualTo("marshal");
+ assertThat(lr.callouts.get(0).y).isGreaterThan(plain.maxY);
+ assertThat(lr.maxY).isGreaterThan(plain.maxY);
+ }
+
+ @Test
+ void eachProblemFromALineOfItsOwn() {
+ RouteDiagramLayoutEngine engine = engine(true);
+ int width = engine.getNodeWidth();
+ String first = "the first problem " + "of the step ".repeat(6);
+ String second = "the second problem " + "of the step ".repeat(12);
+ String third = "the third problem";
+
+ // two that fit: the second from a new line, never on a line of the
first
+ List<String> two = engine.problemLines(List.of(third, second), width);
+ assertThat(two.get(0)).isEqualTo(third);
+ assertThat(two.get(1)).startsWith("the second problem");
+ assertThat(two).hasSizeLessThanOrEqualTo(6);
+
+ // more than six lines: the one cut has the rest of its own text on
its last line, the next is left out
+ List<String> lines = engine.problemLines(List.of(first, second,
third), width);
+ assertThat(lines).hasSize(6);
+ assertThat(lines).noneMatch(l -> l.contains("first") &&
l.contains("second"));
+ assertThat(lines).anyMatch(l -> l.startsWith("the second problem"));
+ assertThat(String.join(" ", lines)).doesNotContain("third");
+ assertThat(lines.get(5)).endsWith("…");
+ }
+
+ @Test
+ void drawTheNumberedList() {
+ DiagramColors colors = DiagramColors.parse("dark");
+ RouteDiagramLayoutEngine engine = engine(true);
+ LayoutRoute lr = engine.layoutRoute(choice(),
RouteDiagramLayoutEngine.PADDING);
+ BufferedImage image = new
RouteDiagramRenderer().renderDiagram(List.of(lr), lr.maxY, colors);
+
+ Callout kafka = lr.callouts.get(0);
+ assertThat(kafka.lines).hasSize(1);
+ // the badge with the number of the problem, in front of its text
+ int badgeX = kafka.x + RouteDiagramLayoutEngine.PROBLEM_NUMBER_WIDTH /
2;
+ int badgeY = kafka.y + kafka.height / 2;
+ assertThat(new Color(image.getRGB(badgeX - 6 *
RouteDiagramLayoutEngine.SCALE, badgeY)))
+ .isEqualTo(colors.getWarning());
+ // and the same badge on the card of its step, which has a warning
border
+ LayoutNode step = kafka.node;
+ assertThat(new Color(image.getRGB(step.x + 1, step.y + step.height /
2))).isEqualTo(colors.getWarning());
+ assertThat(new Color(
+ image.getRGB(step.x + engine.getNodeWidth() - 2 *
RouteDiagramLayoutEngine.SCALE
+ - 6 * RouteDiagramLayoutEngine.SCALE,
+ step.y + 2 * RouteDiagramLayoutEngine.SCALE)))
+ .isEqualTo(colors.getWarning());
+ }
+
+ @Test
+ void drawTheProblems() {
+ DiagramColors colors = DiagramColors.parse("light");
+ RouteDiagramLayoutEngine engine = engine(true);
+ LayoutRoute lr = engine.layoutRoute(orders(),
RouteDiagramLayoutEngine.PADDING);
+ BufferedImage image = new
RouteDiagramRenderer().renderDiagram(List.of(lr), lr.maxY, colors);
+
+ Callout groovy = lr.callouts.get(0);
+ // the callout is filled with the warning background, at its right end
where there is no text
+ assertThat(new Color(
+ image.getRGB(groovy.x + groovy.width - 4 *
RouteDiagramLayoutEngine.SCALE,
+ groovy.y + groovy.height / 2)))
+ .isEqualTo(colors.getWarningBg());
+ // the card of the step has a warning border
+ assertThat(new Color(image.getRGB(groovy.node.x + 1, groovy.node.y +
groovy.node.height / 2)))
+ .isEqualTo(colors.getWarning());
+ }
+}
diff --git a/docs/components/modules/ROOT/images/route-diagram-problems.png
b/docs/components/modules/ROOT/images/route-diagram-problems.png
new file mode 120000
index 000000000000..0c43b836a791
--- /dev/null
+++ b/docs/components/modules/ROOT/images/route-diagram-problems.png
@@ -0,0 +1 @@
+../../../../../components/camel-diagram/src/main/docs/route-diagram-problems.png
\ No newline at end of file
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 1b930324f724..f571efbce259 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -2685,6 +2685,11 @@ A program that lays out routes with
`RouteDiagramLayoutEngine` and draws them wi
profile) shows them on the arrows, with longer arrows to hold them. Use
`--body-types=false` for the diagram without
them.
+`camel cmd route-diagram` of source files, and the
`camel_render_route_diagram` MCP tool, check the routes as
+`camel validate` does, and mark the steps with a problem: a yellow border and
a callout beside the route (a numbered
+list below a route with branches). An image of routes with problems is wider
or taller. Use `--problems=false` for the
+diagram without them.
+
`camel cmd route-diagram --output=diagram.png` without `--theme` now saves the
PNG image in the `light` theme. Before,
the default theme was `unicode` also for a file, so the command saved the text
diagram in `diagram.txt` instead. A
diagram in the terminal is still text (`unicode`) by default, and a file that
does not end with `.png` still gets the
diff --git
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
index 6becab7fd9e1..55c31b82ebd6 100644
---
a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
+++
b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
@@ -32,6 +32,7 @@ camel cmd route-diagram [options]
| `--metric` | Whether to include live metrics (only possible for running
Camel application) | true | boolean
| `--node-label` | What text to display in diagram nodes: code, description,
or both (default) | both | String
| `--output` | Save diagram to a file (PNG for image themes, text for ascii
theme) | | String
+| `--problems` | Whether to mark the steps that the checks of camel validate
find a problem with, and say what it is beside the route, in an image of source
files | true | boolean
| `--theme` | Color theme preset (dark, light, transparent, ascii, unicode) or
custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or
ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent.
Use ascii/unicode for plain text output. Can also be set via DIAGRAM_COLORS env
var. Default: unicode in the terminal, and light when --output is a .png file.
| | String
| `--timeout` | How long to wait for the integration to answer, in millis or
as a duration such as 30s | 10000 | long
| `--watch` | Execute periodically and showing output fullscreen | | boolean
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index 7baca55b3dfa..b85749921ba7 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++
b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -3,7 +3,7 @@
{ "name": "ask", "fullName": "ask", "description": "Ask a question about a
running Camel application using AI", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Ask", "options": [ { "names":
"--api-key", "description": "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, WATSONX_APIKEY, or LLM_API_KEY env vars", "javaType":
"java.lang.String", "type": "string" }, { "names": "--api-type", "description":
"API type: 'ollama', 'openai', 'anthropic', or 'watsonx'", "javaType": [...]
{ "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind
source and sink Kamelets as a new Camel integration", "deprecated": true,
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options":
[ { "names": "--error-handler", "description": "Add error handler
(none|log|sink:<endpoint>). Sink endpoints are expected in the format
[[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet
name.", "javaType": "java.lang.String", "type": "stri [...]
{ "name": "catalog", "fullName": "catalog", "description": "List artifacts
from Camel Catalog", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [
{ "names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"component", "fullName": "catalog component", "description": "List components
from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
- { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
+ { "name": "cmd", "fullName": "cmd", "description": "Performs commands in
the running Camel integrations, such as start\/stop route, or change logging
levels.", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name":
"browse", "fullName": "cmd browse", "description": "Browse pending messages on
endpoints [...]
{ "name": "completion", "fullName": "completion", "description": "Generate
completion script for bash\/zsh", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names":
"-h,--help", "description": "Display the help and sub-commands", "javaType":
"boolean", "type": "boolean" } ] },
{ "name": "config", "fullName": "config", "description": "Get and set user
configuration values", "sourceClass":
"org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ {
"names": "-h,--help", "description": "Display the help and sub-commands",
"javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get",
"fullName": "config get", "description": "Display user configuration value",
"sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
{ "name": "debug", "fullName": "debug", "description": "Debug local Camel
integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug",
"options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd
HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names":
"--background", "description": "Run in the background", "defaultValue":
"false", "javaType": "boolean", "type": "boolean" }, { "names":
"--background-wait", "description": "How [...]
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
index 4c84f85f7c09..c35b2db4d625 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
@@ -131,6 +131,11 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
+ " history recorded them, such as in
the dev profile)")
boolean bodyTypes;
+ @CommandLine.Option(names = { "--problems" }, defaultValue = "true", arity
= "0..1", fallbackValue = "true",
+ description = "Whether to mark the steps that the
checks of camel validate find a problem with, and"
+ + " say what it is beside the route, in
an image of source files")
+ boolean problems;
+
@CommandLine.Option(names = { "--highlight" },
description = "Comma-separated node IDs to highlight
in the diagram")
String highlight;
@@ -229,6 +234,9 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
if (routes == null) {
return 1;
}
+ if (problems && !isTextTheme()) {
+ RouteDiagramProblems.attach(routes, files);
+ }
} else {
// more than one match is an error that says which integrations
match
CamelProcess process = processes.size() > 1 ?
ProcessFinder.requireOne(nameOrPid) : processes.get(0);
@@ -283,6 +291,8 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
// an image draws the steps as cards: a title and its detail beside an
icon
engine.setCardStyle(!isTextTheme());
engine.setBodyTypes(bodyTypes && !isTextTheme());
+ // the problems are those of the source files, checked without running
them
+ engine.setProblems(problems && sourceMode && !isTextTheme());
List<LayoutRoute> layoutRoutes = new ArrayList<>();
int currentY = RouteDiagramLayoutEngine.PADDING;
@@ -543,6 +553,7 @@ public class CamelRouteDiagramAction extends
ActionWatchCommand {
this.nodeLabel = nodeLabel;
}
this.metric = false;
+ this.problems = true;
return doCall();
}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java
new file mode 100644
index 000000000000..a5cb33ba8e34
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblems.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.io.IOException;
+import java.nio.charset.CharacterCodingException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.ai.RouteAssist;
+import org.apache.camel.dsl.jbang.core.commands.ai.SourceValidator;
+
+/**
+ * The problems of the source files of a route diagram, on the steps they are
of: the checks of {@code camel validate}
+ * for a YAML route, and for Java and XML the checks that read the source
without compiling it ({@link RouteAssist}), as
+ * the route diagram reads Java routes without compiling them.
+ */
+final class RouteDiagramProblems {
+
+ /** A problem of a line of the source; line 0 for a problem of the file. */
+ record Problem(int line, String text) {
+ }
+
+ /** The checks of a source file: {@link #problems}. */
+ @FunctionalInterface
+ interface Checks {
+ List<Problem> problems(String name, String content, CamelCatalog
catalog, Path directory);
+ }
+
+ private static final Pattern MESSAGE_LINE = Pattern.compile("^Line (\\d+):
(.*)$", Pattern.DOTALL);
+ // the step and route a message names first, which the diagram shows
already: "setBody: route orders: "
+ private static final Pattern STEP_PREFIX =
Pattern.compile("^[A-Za-z][\\w-]*: route [^:\\s]+: ");
+
+ private RouteDiagramProblems() {
+ }
+
+ /**
+ * Puts the problems of the source files on the steps of their routes
({@link NodeInfo#problems}): a problem on the
+ * step whose line is the last one before it (the line of the option or
expression that is wrong is inside its
+ * step), a problem of the file on the first step: also that the file
cannot be read or checked.
+ */
+ static void attach(List<RouteInfo> routes, List<String> files) {
+ attach(routes, files, RouteDiagramProblems::problems);
+ }
+
+ static void attach(List<RouteInfo> routes, List<String> files, Checks
checks) {
+ CamelCatalog catalog = null;
+ for (String file : files) {
+ Path path = Path.of(file);
+ if (!Files.isRegularFile(path)) {
+ continue;
+ }
+ String name = path.getFileName().toString();
+ List<NodeInfo> nodes = routes.stream()
+ .filter(r -> sourceOf(r, name))
+ .flatMap(r -> r.nodes.stream())
+ .toList();
+ if (nodes.isEmpty()) {
+ continue;
+ }
+ String content;
+ try {
+ content = Files.readString(path);
+ } catch (IOException e) {
+ // the diagram shows the routes of the file, so it says why
their problems are not there
+ add(nodes.get(0), "the file cannot be read to check it: "
+ + (e instanceof CharacterCodingException ?
"it is not UTF-8 text" : e.getMessage()));
+ continue;
+ }
+ if (catalog == null) {
+ catalog = new DefaultCamelCatalog();
+ }
+ List<Problem> problems;
+ try {
+ problems = checks.problems(name, content, catalog,
path.toAbsolutePath().getParent());
+ } catch (RuntimeException e) {
+ // a check that fails (a parser bug on a broken file) must not
fail the diagram, which is drawn anyway
+ String message = e.getMessage();
+ add(nodes.get(0), "the file could not be checked: "
+ + (message != null && !message.isBlank() ?
text(message) : e.getClass().getSimpleName()));
+ continue;
+ }
+ for (Problem p : problems) {
+ add(stepAt(nodes, p.line()), p.text());
+ }
+ }
+ }
+
+ private static void add(NodeInfo node, String problem) {
+ List<String> list = new ArrayList<>(node.problems);
+ list.add(problem);
+ node.problems = List.copyOf(list);
+ }
+
+ /** The problems of a source file. */
+ static List<Problem> problems(String name, String content, CamelCatalog
catalog, Path directory) {
+ String lower = name.toLowerCase(Locale.ROOT);
+ List<Problem> answer = new ArrayList<>();
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
+ for (String msg : SourceValidator.validate(name, content, catalog,
null, directory)) {
+ answer.add(problem(msg));
+ }
+ } else if (RouteAssist.supports(name, content)) {
+ for (RouteAssist.Diagnostic d : RouteAssist.diagnostics(name,
content, catalog, directory)) {
+ if (d.severity() == RouteAssist.Severity.ERROR) {
+ answer.add(new Problem(d.line(), text(d.message())));
+ }
+ }
+ }
+ return answer;
+ }
+
+ /** A message of the validator, "Line 7: setBody: route orders: groovy
reads ...", as a problem. */
+ static Problem problem(String message) {
+ Matcher m = MESSAGE_LINE.matcher(message);
+ if (m.matches()) {
+ return new Problem(Integer.parseInt(m.group(1)), text(m.group(2)));
+ }
+ return new Problem(0, text(message));
+ }
+
+ /**
+ * The text of a problem for a callout: its first line (more lines are the
source to write, which the diagram has no
+ * room for), without the step and route it names first, which the callout
points at.
+ */
+ static String text(String message) {
+ String text = message.strip();
+ int nl = text.indexOf('\n');
+ if (nl > 0) {
+ text = text.substring(0, nl).strip();
+ }
+ return STEP_PREFIX.matcher(text).replaceFirst("");
+ }
+
+ /** The step of a line: the last step that starts at or before it, or the
first step. */
+ static NodeInfo stepAt(List<NodeInfo> nodes, int line) {
+ NodeInfo answer = nodes.get(0);
+ for (NodeInfo n : nodes) {
+ if (n.line > 0 && n.line <= line && n.line >= answer.line) {
+ answer = n;
+ }
+ }
+ return answer;
+ }
+
+ /** Whether the route comes from the file: its source is the file name, or
the class name of a Java file. */
+ private static boolean sourceOf(RouteInfo route, String fileName) {
+ return route.source != null && (route.source.equals(fileName) ||
fileName.startsWith(route.source + "."));
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java
new file mode 100644
index 000000000000..8eef66d3cb04
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramProblemsTest.java
@@ -0,0 +1,194 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.action;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.diagram.RouteDiagramLayoutEngine.RouteInfo;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class RouteDiagramProblemsTest {
+
+ @TempDir
+ Path dir;
+
+ private static NodeInfo node(String type, String code, int level, int
line) {
+ NodeInfo n = new NodeInfo();
+ n.type = type;
+ n.code = code;
+ n.level = level;
+ n.line = line;
+ return n;
+ }
+
+ @Test
+ void textOfAProblem() {
+ // the step and route it names first are where the callout points
+ assertThat(RouteDiagramProblems.text("setBody: route orders: groovy
reads fields of the body"))
+ .isEqualTo("groovy reads fields of the body");
+ // the lines to write after it are left out
+ assertThat(RouteDiagramProblems.text("an option at the column of its
EIP\n - to:\n uri: log:x"))
+ .isEqualTo("an option at the column of its EIP");
+ assertThat(RouteDiagramProblems.problem("Line 7: setBody: route
orders: groovy reads fields"))
+ .isEqualTo(new RouteDiagramProblems.Problem(7, "groovy reads
fields"));
+ assertThat(RouteDiagramProblems.problem("the file is empty"))
+ .isEqualTo(new RouteDiagramProblems.Problem(0, "the file is
empty"));
+ }
+
+ @Test
+ void stepOfALine() {
+ List<NodeInfo> nodes = List.of(node("route", "route[orders]", 0, 4),
node("from", "from[file:orders]", 1, 4),
+ node("setBody", "setBody[groovy{body.items}]", 2, 6),
node("to", "to[http:pricing]", 2, 9));
+ // the line of the expression is inside its step
+ assertThat(RouteDiagramProblems.stepAt(nodes,
7).type).isEqualTo("setBody");
+ assertThat(RouteDiagramProblems.stepAt(nodes, 9).type).isEqualTo("to");
+ assertThat(RouteDiagramProblems.stepAt(nodes,
4).type).isEqualTo("from");
+ // before the first step, or of the file: the first step
+ assertThat(RouteDiagramProblems.stepAt(nodes,
1).type).isEqualTo("route");
+ assertThat(RouteDiagramProblems.stepAt(nodes,
0).type).isEqualTo("route");
+ }
+
+ @Test
+ void problemsOfAYamlRoute() throws Exception {
+ Path file = dir.resolve("orders.camel.yaml");
+ Files.writeString(file, """
+ - route:
+ id: orders
+ from:
+ uri: file:orders
+ steps:
+ - setBody:
+ groovy:
+ expression: body.items
+ - to:
+ uri: log:orders
+ """);
+ RouteInfo route = new RouteInfo();
+ route.routeId = "orders";
+ route.source = "orders.camel.yaml";
+ route.nodes.add(node("route", "route[orders]", 0, 4));
+ route.nodes.add(node("from", "from[file:orders]", 1, 4));
+ route.nodes.add(node("setBody", "setBody[groovy{body.items}]", 2, 6));
+ route.nodes.add(node("to", "to[log:orders]", 2, 9));
+
+ RouteDiagramProblems.attach(List.of(route), List.of(file.toString()));
+
+ // groovy reads fields of the file as it was read
+ assertThat(route.nodes.get(2).problems).singleElement().asString()
+
.contains("body.items").contains("GenericFile").doesNotStartWith("setBody:
route");
+ assertThat(route.nodes.get(0).problems).isEmpty();
+ assertThat(route.nodes.get(3).problems).isEmpty();
+ }
+
+ @Test
+ void problemsOfAJavaRoute() throws Exception {
+ Path file = dir.resolve("MyRoute.java");
+ Files.writeString(file, """
+ import org.apache.camel.builder.RouteBuilder;
+
+ public class MyRoute extends RouteBuilder {
+ @Override
+ public void configure() {
+ from("timer:tick")
+ .log("tick")
+ .to("kafka:orders?brokerz=localhost:9092");
+ }
+ }
+ """);
+ RouteInfo route = new RouteInfo();
+ route.routeId = "route1";
+ route.source = "MyRoute";
+ route.nodes.add(node("route", "route[route1]", 0, 6));
+ route.nodes.add(node("from", "from[timer:tick]", 1, 6));
+ route.nodes.add(node("log", "log[tick]", 2, 7));
+ route.nodes.add(node("to", "to[kafka:orders?brokerz=localhost:9092]",
2, 8));
+
+ RouteDiagramProblems.attach(List.of(route), List.of(file.toString()));
+
+ // the option the kafka component does not have, found without
compiling the source
+
assertThat(route.nodes.get(3).problems).singleElement().asString().contains("brokerz");
+ assertThat(route.nodes.get(2).problems).isEmpty();
+ }
+
+ @Test
+ void aFileThatCannotBeRead() throws Exception {
+ Path file = dir.resolve("orders.camel.yaml");
+ // not UTF-8 text
+ Files.write(file, new byte[] { '-', ' ', (byte) 0xff, (byte) 0xfe,
'\n' });
+ RouteInfo route = new RouteInfo();
+ route.routeId = "orders";
+ route.source = "orders.camel.yaml";
+ route.nodes.add(node("route", "route[orders]", 0, 1));
+ route.nodes.add(node("from", "from[file:orders]", 1, 2));
+
+ RouteDiagramProblems.attach(List.of(route), List.of(file.toString()));
+
+ // on the first step, as a problem of the file, not as a file without
problems
+ assertThat(route.nodes.get(0).problems)
+ .containsExactly("the file cannot be read to check it: it is
not UTF-8 text");
+ assertThat(route.nodes.get(1).problems).isEmpty();
+ }
+
+ @Test
+ void aCheckThatFails() throws Exception {
+ Path file = dir.resolve("orders.camel.yaml");
+ Files.writeString(file, "- route:\n id: orders\n");
+ Path other = dir.resolve("other.camel.yaml");
+ Files.writeString(other, "- route:\n id: other\n");
+ RouteInfo route = new RouteInfo();
+ route.routeId = "orders";
+ route.source = "orders.camel.yaml";
+ route.nodes.add(node("route", "route[orders]", 0, 1));
+ route.nodes.add(node("from", "from[file:orders]", 1, 2));
+ RouteInfo otherRoute = new RouteInfo();
+ otherRoute.routeId = "other";
+ otherRoute.source = "other.camel.yaml";
+ otherRoute.nodes.add(node("route", "route[other]", 0, 1));
+
+ RouteDiagramProblems.attach(List.of(route, otherRoute),
List.of(file.toString(), other.toString()),
+ (name, content, catalog, directory) -> {
+ throw name.startsWith("orders") ? new
IllegalStateException("parser bug") : new NullPointerException();
+ });
+
+ // the diagram is still drawn, and says why the problems of the file
are not there
+ assertThat(route.nodes.get(0).problems).containsExactly("the file
could not be checked: parser bug");
+ assertThat(route.nodes.get(1).problems).isEmpty();
+ // the next file is checked too; an exception without a message is
named by its class
+ assertThat(otherRoute.nodes.get(0).problems)
+ .containsExactly("the file could not be checked:
NullPointerException");
+ }
+
+ @Test
+ void aFileWithoutRoutesOfTheDiagramIsNotChecked() throws Exception {
+ Path file = dir.resolve("other.camel.yaml");
+ Files.writeString(file, "- route:\n from:\n uri: nope:\n");
+ RouteInfo route = new RouteInfo();
+ route.routeId = "orders";
+ route.source = "orders.camel.yaml";
+ route.nodes.add(node("route", "route[orders]", 0, 1));
+
+ RouteDiagramProblems.attach(List.of(route), List.of(file.toString(),
dir.resolve("missing.yaml").toString()));
+
+ assertThat(route.nodes.get(0).problems).isEmpty();
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
index 6c810a18bda2..4207f1d6eb9b 100644
---
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/RouteDiagramTools.java
@@ -46,6 +46,8 @@ public class RouteDiagramTools {
+ "Java routes are read by the Java DSL parser,
without compiling them; a step it cannot "
+ "work out (a lambda, a processor) is shown as a
placeholder, and the parts it could not read "
+ "are listed in the message. "
+ + "An image marks the steps that the checks of camel
validate find a problem with, and says "
+ + "what the problem is beside the route. "
+ "For image themes (light, dark, transparent) the PNG
is written to disk and the absolute path "
+ "is returned. For text themes (ascii, unicode) the
diagram is returned directly as text so "
+ "it can be read and understood by the caller. "