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 ddd31099c9a5 CAMEL-25532: camel-jbang - the AI write and edit tools
repair YAML shapes that have one reading, and match lines written from memory
(#27998)
ddd31099c9a5 is described below
commit ddd31099c9a58a22aca55c2a5c45b722b982df77
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Oct 17 22:04:18 2026 +0200
CAMEL-25532: camel-jbang - the AI write and edit tools repair YAML shapes
that have one reading, and match lines written from memory (#27998)
Signed-off-by: Claus Ibsen <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 +
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 10 +-
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 371 ++++++++++++-
.../dsl/jbang/core/commands/ai/LenientRepairs.java | 575 +++++++++++++++++++++
.../core/commands/ai/AuthoringToolsEditTest.java | 3 +-
.../commands/ai/AuthoringToolsLenientTest.java | 513 ++++++++++++++++++
.../core/commands/ai/AuthoringToolsLoopTest.java | 20 +-
.../jbang/core/commands/ai/AuthoringToolsTest.java | 6 +-
8 files changed, 1492 insertions(+), 18 deletions(-)
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 1e5463becddd..a9f9da1ef268 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
@@ -1569,3 +1569,15 @@ Some runtime tools of the Camel MCP server now do what
their description says, w
refused.
* `camel_runtime_route_dump`, `camel_runtime_route_structure` and
`camel_runtime_route_analysis` return the routes
that `routeId` names; they returned every route whatever `routeId` was.
+
+=== camel-jbang - the AI write and edit tools repair YAML shapes that have one
reading
+
+The `camel_write_file` and `camel_edit_file` tools used to refuse content with
a YAML shape that a model often gets
+wrong but that has only one reading, such as `steps:` written at the column of
its EIP, or an option at the column of
+the EIP it belongs to. Such content is now repaired and written, and
`repaired` in the answer lists the repairs.
+Content with more than one reading is refused as before.
+
+`camel_edit_file` now matches lines to find that are not in the file as given,
as when a model writes them from memory,
+to the closest lines of the file when exactly one place matches well. It
changes there the lines the model changed and
+keeps the file's version of the other lines. The edit stands only when its
result is valid; otherwise the answer is
+the one of a miss, with the lines the file has there. `matchedLines` in the
answer names the lines it matched.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index db1b82730279..b1de8a6580cb 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -381,8 +381,10 @@ project `directory` as an argument, the runtime tools take
the integration `name
| `camel_write_file`
| Writes the complete content of a file in the project directory. YAML and
`.properties` content is validated
- first; invalid content is not written and the errors are returned. Only a
- plain file name in the directory is accepted. Nobody is asked before the
write: the MCP client (Claude Code,
+ first; invalid content is not written and the errors are returned. A YAML
shape that a model often gets wrong but
+ that has only one reading, such as `steps:` at the column of its EIP or an
option at the column of the EIP it
+ belongs to, is repaired instead: the file is written with the repairs, and
`repaired` in the answer lists them.
+ Only a plain file name in the directory is accepted. Nobody is asked before
the write: the MCP client (Claude Code,
Cursor and the others ask before a tool that is not read-only runs) is where
the human sits, and the
`read-only` access level of the security layer hides the tool altogether.
With the security layer on, content longer than
`camel.mcp.security.max-argument-length` is rejected, never cut,
@@ -395,6 +397,10 @@ project `directory` as an argument, the runtime tools take
the integration `name
| `camel_edit_file`
| Changes a file by replacing one snippet: the lines to find (they must name
one place; other indentation is fine)
and what to put there.
+ Lines to find that are not in the file as given, as when a model writes them
from memory, are matched to the
+ closest lines of the file when exactly one place matches well
(`matchedLines` in the answer names them). The lines
+ the model changed are changed there, and the other lines stay as the file
has them. Such an edit stands only when
+ its result is valid; otherwise the answer is the one of a miss, with the
lines the file has there.
Validated and reloaded as a write is, and the answer says at which line it
changed. A model that rewrites a whole
file to add one step corrupts the lines it did not mean to touch, so this is
the tool for a change to an existing
file; `camel_write_file` writes a new one.
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
index 20e0f79b2692..de55433771e8 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java
@@ -26,6 +26,7 @@ import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.EnumSet;
@@ -643,9 +644,34 @@ public final class AuthoringTools {
if (content == null) {
return edit; // not-found or ambiguous: the answer says what to do
instead
}
+ Path refusedKey = resolveFile(dir, file).toAbsolutePath().normalize();
+ Refused refusedBefore = LAST_REFUSED.get(refusedKey);
JsonObject result = writeFile(ctx, dir, file, content, true);
+ if ("invalid".equals(result.getString("status")) &&
edit.get("matchedLines") != null) {
+ // CAMEL-25532: a match to the closest lines stands only when its
result is valid. After a refused edit the
+ // next text to find is often the model's own replacement, which
the file never got: matched to the closest
+ // lines it is refused again, and the model goes on editing a file
that exists only in its memory. The
+ // answer of an exact match, not found with the lines the file has
there, puts it back in step with the file
+ JsonObject exact = editedContent(dir, file, find, replace, edits,
false);
+ if (exact.getString("content") == null) {
+ if (refusedBefore != null) {
+ LAST_REFUSED.put(refusedKey, refusedBefore);
+ } else {
+ LAST_REFUSED.remove(refusedKey);
+ }
+ return exact;
+ }
+ }
if (!"invalid".equals(result.getString("status"))) {
result.put("status", "edited");
+ if (edit.get("matchedLines") instanceof JsonArray matched) {
+ result.put("matchedLines", matched);
+ result.put("message", "The text to find was not in the file as
given; it was matched to the closest"
+ + " lines of the file (" +
String.join(", ", matched.stream().map(String::valueOf)
+ .toList())
+ + "), and the lines you changed were
changed there. Where the file has other text"
+ + " in lines you did not change, it was
kept. " + result.getString("message"));
+ }
result.put("editedAtLine", edit.getInteger("editedAtLine"));
result.put("replacedLines", edit.getInteger("replacedLines"));
if (edit.get("edits") != null) {
@@ -681,6 +707,14 @@ public final class AuthoringTools {
* and the file is not changed.
*/
public static JsonObject editedContent(Path dir, String file, String find,
String replace, String edits) {
+ return editedContent(dir, file, find, replace, edits, true);
+ }
+
+ /**
+ * As {@link #editedContent(Path, String, String, String, String)}; {@code
closest} says whether a snippet that is
+ * not in the file may be matched to its closest lines (CAMEL-25532).
+ */
+ static JsonObject editedContent(Path dir, String file, String find, String
replace, String edits, boolean closest) {
Path path = resolveFile(dir, file);
if (!Files.isRegularFile(path)) {
throw new ToolExecutionException(file + " does not exist: write
the whole file with camel_write_file");
@@ -703,9 +737,10 @@ public final class AuthoringTools {
}
JsonObject result = null;
JsonArray lines = new JsonArray();
+ JsonArray matched = new JsonArray();
int replaced = 0;
for (int i = 0; i < all.size(); i++) {
- JsonObject one = editedSnippet(file, content, all.get(i)[0],
all.get(i)[1]);
+ JsonObject one = editedSnippet(file, content, all.get(i)[0],
all.get(i)[1], closest);
if (one.getString("content") == null) {
if (all.size() > 1) {
one.put("edit", i + 1);
@@ -719,10 +754,16 @@ public final class AuthoringTools {
content = one.getString("content");
lines.add(one.getInteger("editedAtLine"));
replaced += one.getInteger("replacedLines");
+ if (one.get("matchedLines") != null) {
+ matched.add((all.size() > 1 ? "edit " + (i + 1) + ": " : "") +
"lines " + one.getString("matchedLines"));
+ }
if (result == null) {
result = one;
}
}
+ if (!matched.isEmpty()) {
+ result.put("matchedLines", matched);
+ }
result.put("content", content);
result.put("replacedLines", replaced);
if (all.size() > 1) {
@@ -768,7 +809,7 @@ public final class AuthoringTools {
+ " there\"}";
/** One snippet replaced in the content: the {@link #editedContent(Path,
String, String, String)} answer. */
- private static JsonObject editedSnippet(String file, String content,
String find, String replace) {
+ private static JsonObject editedSnippet(String file, String content,
String find, String replace, boolean closest) {
String wanted = find;
String put = replace;
boolean trimmedMatch = false;
@@ -808,6 +849,20 @@ public final class AuthoringTools {
}
JsonObject result = new JsonObject();
result.put("file", file);
+ if (first < 0 && closest) {
+ // CAMEL-25532: the model writes the snippet from memory, and
remembers a line, not the lines around it;
+ // the window of the file that best matches the snippet, when it
holds a distinctive line of it and no
+ // other window is as good, gets the changes the model made
between find and replace
+ int[] window = fuzzyWindow(content, wanted);
+ ClosestEdit merged = window != null ? mergeIntoWindow(content,
window, wanted, put) : null;
+ if (merged != null) {
+ result.put("matchedLines", merged.firstLine() + "-" +
merged.lastLine());
+ result.put("content", content.substring(0, merged.start()) +
merged.text() + content.substring(merged.end()));
+ result.put("editedAtLine", merged.firstLine());
+ result.put("replacedLines", merged.lastLine() -
merged.firstLine() + 1);
+ return result;
+ }
+ }
if (first < 0) {
result.put("status", "not-found");
String nearest = nearestBlock(content, wanted);
@@ -947,6 +1002,263 @@ public final class AuthoringTools {
return found;
}
+ /** A line that is only a key, as "steps:" or "- log:": not distinctive. */
+ private static final Pattern BARE_KEY =
Pattern.compile("^-?\\s*[\\w.-]+:\\s*$");
+
+ private static String fuzzyLine(String line) {
+ return line.strip().replaceAll("\\s+", " ").replace("\"",
"").replace("'", "");
+ }
+
+ /**
+ * The window of the content that best matches the lines of find (their
longest common subsequence), as {start
+ * offset, end offset, first line, last line}; null unless it holds a line
of find that has a value and is unique in
+ * the content, has at least two lines of find, matches at least 60%, and
no window apart from it matches nearly as
+ * well (CAMEL-25532).
+ */
+ static int[] fuzzyWindow(String content, String find) {
+ String[] lines = content.split("\n", -1);
+ List<String> norm = new ArrayList<>(lines.length);
+ // how often each line occurs, so a line of find that is unique in the
content is known without a scan per line
+ Map<String, Integer> occurrences = new HashMap<>();
+ for (String l : lines) {
+ String n = fuzzyLine(l);
+ norm.add(n);
+ occurrences.merge(n, 1, Integer::sum);
+ }
+ List<String> want =
find.lines().map(AuthoringTools::fuzzyLine).filter(l -> !l.isEmpty()).toList();
+ if (want.isEmpty()) {
+ return null;
+ }
+ Set<String> distinct = new LinkedHashSet<>();
+ for (String w : want) {
+ if (w.length() > 6 && !BARE_KEY.matcher(w).matches() &&
occurrences.getOrDefault(w, 0) == 1) {
+ distinct.add(w);
+ }
+ }
+ if (distinct.isEmpty()) {
+ return null;
+ }
+ int n = want.size();
+ double best = -1;
+ int bestAt = -1;
+ int bestSize = 0;
+ int bestCommon = 0;
+ List<double[]> scored = new ArrayList<>();
+ for (int size = Math.max(1, n - 2); size <= n + 2; size++) {
+ for (int i = 0; i + size <= lines.length; i++) {
+ List<String> window = norm.subList(i, i +
size).stream().filter(l -> !l.isEmpty()).toList();
+ if (window.isEmpty() ||
window.stream().noneMatch(distinct::contains)) {
+ continue;
+ }
+ int common = commonLines(want, window);
+ double ratio = 2.0 * common / (want.size() + window.size());
+ scored.add(new double[] { ratio, i, size });
+ if (ratio > best || (ratio == best && Math.abs(size - n) <
Math.abs(bestSize - n))) {
+ best = ratio;
+ bestAt = i;
+ bestSize = size;
+ bestCommon = common;
+ }
+ }
+ }
+ if (best < 0.6 || bestCommon < 2) {
+ // one line in common says where the snippet was aimed, not where
the rest of it goes
+ return null;
+ }
+ for (double[] other : scored) {
+ int at = (int) other[1];
+ int size = (int) other[2];
+ boolean apart = at + size <= bestAt || at >= bestAt + bestSize;
+ if (apart && other[0] >= best - 0.1) {
+ return null; // two places match about as well: not certain
+ }
+ }
+ int[] offsets = lineOffsets(content, lines);
+ int last = bestAt + bestSize - 1;
+ int end = Math.min(offsets[last] + lines[last].length() + 1,
content.length());
+ return new int[] { offsets[bestAt], end, bestAt + 1, last + 1 };
+ }
+
+ /**
+ * A snippet merged into the closest lines of a file: the offsets it
replaces, its text, and those lines (from 1).
+ */
+ record ClosestEdit(int start, int end, String text, int firstLine, int
lastLine) {
+ }
+
+ /** A line of YAML as {@code key:} or {@code - key:}, with its key: the
{@code key} group. */
+ private static final Pattern YAML_KEY =
Pattern.compile("^(?:-\\s+)?([\\w.$-]+):(?:\\s|$)");
+
+ /**
+ * The changes the model made between find and replace, made in the window
of the file that find was matched to
+ * (CAMEL-25532). find is the model's memory of those lines and can be
wrong, so replace does not overwrite the
+ * window. The lines of find that are in the file and that replace keeps
are anchors, and stay as the file has them.
+ * Each stretch between two anchors stays the file's where replace leaves
the model's version of it unchanged, or
+ * where the file has replace's version already, and becomes replace's
version otherwise, at the file's indentation.
+ * A line at either end of find that the file has just past the window
(the same line, or the same key at the same
+ * depth) belongs to the window, so that a line the model changed is not
left there twice. Null when find and the
+ * window have no line in common.
+ */
+ static ClosestEdit mergeIntoWindow(String content, int[] window, String
find, String replace) {
+ String[] lines = content.split("\n", -1);
+ // the element after a final newline is not a line of the file
+ int lineCount = content.endsWith("\n") ? lines.length - 1 :
lines.length;
+ int ws = window[2] - 1;
+ int we = window[3] - 1;
+ List<String> f = find.lines().filter(l -> !l.isBlank()).toList();
+ List<String> r = replace.lines().toList();
+ List<String> fNorm =
f.stream().map(AuthoringTools::fuzzyLine).toList();
+ List<String> rNorm =
r.stream().map(AuthoringTools::fuzzyLine).toList();
+ List<String> windowNorm = new ArrayList<>();
+ for (int i = ws; i <= we; i++) {
+ windowNorm.add(fuzzyLine(lines[i]));
+ }
+ // the line of the file each line of find is, or -1
+ int[] fw = alignLines(fNorm, windowNorm);
+ int firstF = -1;
+ int lastF = -1;
+ for (int i = 0; i < fw.length; i++) {
+ if (fw[i] >= 0) {
+ fw[i] += ws;
+ firstF = firstF < 0 ? i : firstF;
+ lastF = i;
+ }
+ }
+ if (firstF < 0) {
+ return null;
+ }
+ int delta = leadingSpaces(lines[fw[lastF]]) -
leadingSpaces(f.get(lastF));
+ for (int i = lastF + 1, at = fw[lastF] + 1; i < f.size() && at <
lineCount; i++, at++) {
+ if (!sameLineOrKey(f.get(i), lines[at], delta)) {
+ break;
+ }
+ fw[i] = at;
+ we = Math.max(we, at);
+ }
+ delta = leadingSpaces(lines[fw[firstF]]) -
leadingSpaces(f.get(firstF));
+ for (int i = firstF - 1, at = fw[firstF] - 1; i >= 0 && at >= 0; i--,
at--) {
+ if (!sameLineOrKey(f.get(i), lines[at], delta)) {
+ break;
+ }
+ fw[i] = at;
+ ws = Math.min(ws, at);
+ }
+ int[] fr = alignLines(fNorm, rNorm);
+ // the anchors, then the end of the window: {line of find, line of the
file, line of replace}
+ List<int[]> anchors = new ArrayList<>();
+ for (int i = 0; i < f.size(); i++) {
+ if (fw[i] >= 0 && fr[i] >= 0) {
+ anchors.add(new int[] { i, fw[i], fr[i] });
+ }
+ }
+ anchors.add(new int[] { f.size(), we + 1, r.size() });
+ int firstDelta = leadingSpaces(lines[fw[firstF]]) -
leadingSpaces(f.get(firstF));
+ List<String> out = new ArrayList<>();
+ int pf = -1;
+ int pw = ws - 1;
+ int pr = -1;
+ for (int[] a : anchors) {
+ List<String> stretchF = fNorm.subList(pf + 1, a[0]);
+ List<String> stretchW = new ArrayList<>();
+ for (int i = pw + 1; i < a[1]; i++) {
+ stretchW.add(fuzzyLine(lines[i]));
+ }
+ List<String> stretchR = rNorm.subList(pr + 1, a[2]);
+ if (sameLines(stretchF, stretchR) || sameLines(stretchW,
stretchR)) {
+ for (int i = pw + 1; i < a[1]; i++) {
+ out.add(lines[i]);
+ }
+ } else {
+ // the depth of the replacement where it goes: from the anchor
before it, or after it for the first
+ int d = pf >= 0 ? leadingSpaces(lines[fw[pf]]) -
leadingSpaces(f.get(pf))
+ : a[0] < f.size() ? leadingSpaces(lines[a[1]]) -
leadingSpaces(f.get(a[0])) : firstDelta;
+ for (String line : r.subList(pr + 1, a[2])) {
+ out.add(shiftIndent(line, d));
+ }
+ }
+ if (a[0] < f.size()) {
+ out.add(lines[a[1]]);
+ }
+ pf = a[0];
+ pw = a[1];
+ pr = a[2];
+ }
+ int[] offsets = lineOffsets(content, lines);
+ int start = offsets[ws];
+ int end = we + 1 < lines.length ? offsets[we + 1] : content.length();
+ boolean newline = end > start && content.charAt(end - 1) == '\n';
+ return new ClosestEdit(start, end, String.join("\n", out) + (newline ?
"\n" : ""), ws + 1, we + 1);
+ }
+
+ /** Which line of b each line of a is (their longest common subsequence),
or -1; empty lines match nothing. */
+ static int[] alignLines(List<String> a, List<String> b) {
+ int[][] t = new int[a.size() + 1][b.size() + 1];
+ for (int i = a.size() - 1; i >= 0; i--) {
+ for (int j = b.size() - 1; j >= 0; j--) {
+ t[i][j] = !a.get(i).isEmpty() && a.get(i).equals(b.get(j))
+ ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j +
1]);
+ }
+ }
+ int[] answer = new int[a.size()];
+ Arrays.fill(answer, -1);
+ for (int i = 0, j = 0; i < a.size() && j < b.size();) {
+ if (!a.get(i).isEmpty() && a.get(i).equals(b.get(j))) {
+ answer[i++] = j++;
+ } else if (t[i + 1][j] >= t[i][j + 1]) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+ return answer;
+ }
+
+ /** Whether a line of find is a line of the file: the same text, or the
same key at the depth of its neighbours. */
+ private static boolean sameLineOrKey(String findLine, String fileLine, int
delta) {
+ String a = fuzzyLine(findLine);
+ if (!a.isEmpty() && a.equals(fuzzyLine(fileLine))) {
+ return true;
+ }
+ Matcher m1 = YAML_KEY.matcher(findLine.strip());
+ Matcher m2 = YAML_KEY.matcher(fileLine.strip());
+ return m1.find() && m2.find() && m1.group(1).equals(m2.group(1))
+ && leadingSpaces(fileLine) - leadingSpaces(findLine) == delta;
+ }
+
+ /** Whether two lists of normalized lines are the same, blank lines aside.
*/
+ private static boolean sameLines(List<String> a, List<String> b) {
+ return a.stream().filter(l ->
!l.isEmpty()).toList().equals(b.stream().filter(l -> !l.isEmpty()).toList());
+ }
+
+ private static int leadingSpaces(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return i;
+ }
+
+ /** The line moved right by delta spaces, or left by as many of its
leading spaces as it has up to -delta. */
+ private static String shiftIndent(String line, int delta) {
+ if (delta == 0 || line.isBlank()) {
+ return line;
+ }
+ if (delta > 0) {
+ return " ".repeat(delta) + line;
+ }
+ return line.substring(Math.min(-delta, leadingSpaces(line)));
+ }
+
+ /** The length of the longest common subsequence of two lists of lines. */
+ private static int commonLines(List<String> a, List<String> b) {
+ int[][] t = new int[a.size() + 1][b.size() + 1];
+ for (int i = 1; i <= a.size(); i++) {
+ for (int j = 1; j <= b.size(); j++) {
+ t[i][j] = a.get(i - 1).equals(b.get(j - 1)) ? t[i - 1][j - 1]
+ 1 : Math.max(t[i - 1][j], t[i][j - 1]);
+ }
+ }
+ return t[a.size()][b.size()];
+ }
+
/**
* The lines of the file that come closest to the wanted ones, so the
answer of a miss shows what is there (the next
* attempt then copies it). Null when nothing matches at all.
@@ -1227,19 +1539,32 @@ public final class AuthoringTools {
}
}
List<String> problemsBefore = List.of();
+ List<String> repaired = List.of();
if (validate && SourceValidator.isValidatableFile(file)) {
// a missing consumer of a direct: endpoint does not refuse the
write: it is often a file not written yet
List<String> errors = SourceValidator.validate(file, content,
ctx.catalog(), ctx.propertyLineValidator(), dir,
null, false);
+ List<String> before = List.of();
if (!errors.isEmpty() && exists) {
// the problems the file already had do not refuse the write:
an edit that fixes one problem would be
// refused for the others, and the agent made to fix them all;
only the problems the write brings are
- problemsBefore = SourceValidator.validate(file, read(path),
ctx.catalog(), ctx.propertyLineValidator(),
+ before = SourceValidator.validate(file, read(path),
ctx.catalog(), ctx.propertyLineValidator(),
dir, null, false);
List<String> stillThere = new ArrayList<>();
- errors = newProblems(problemsBefore, errors, stillThere);
+ errors = newProblems(before, errors, stillThere);
problemsBefore = stillThere;
}
+ if (!errors.isEmpty()) {
+ // CAMEL-25532: the shapes with one reading are repaired, and
the write goes ahead when that leaves no
+ // new problem; the answer says what was repaired
+ Repaired r = repair(ctx, dir, file, content, errors, before,
exists);
+ if (r != null) {
+ content = r.content();
+ repaired = r.applied();
+ problemsBefore = r.stillThere();
+ errors = List.of();
+ }
+ }
if (!errors.isEmpty()) {
JsonObject result = new JsonObject();
result.put("status", "invalid");
@@ -1354,6 +1679,11 @@ public final class AuthoringTools {
if (waiting != null) {
result.put("message", result.getString("message") + " Note: " +
waiting.summary() + " (see notes).");
}
+ if (!repaired.isEmpty()) {
+ result.put("repaired", new JsonArray(repaired));
+ result.put("message", "Written with these repairs of the YAML (the
file has them now): "
+ + String.join("; ", repaired) + ". " +
result.getString("message"));
+ }
return result;
}
@@ -1370,6 +1700,39 @@ public final class AuthoringTools {
.times();
}
+ /** How many times the repairs are applied and the content validated again
(a repair can reveal the next one). */
+ private static final int REPAIR_ROUNDS = 8;
+
+ private record Repaired(String content, List<String> applied, List<String>
stillThere) {
+ }
+
+ /**
+ * The content with the shapes that have one reading repaired, when that
leaves no problem the file did not have;
+ * null otherwise (the write is refused as it was, with the problems of
the content as written).
+ */
+ private static Repaired repair(
+ ToolContext ctx, Path dir, String file, String content,
List<String> errors, List<String> before,
+ boolean exists) {
+ String current = content;
+ List<String> applied = new ArrayList<>();
+ List<String> left = errors;
+ List<String> stillThere = new ArrayList<>();
+ for (int round = 0; round < REPAIR_ROUNDS && !left.isEmpty(); round++)
{
+ LenientRepairs.Result r = LenientRepairs.repair(current, left);
+ if (r == null) {
+ return null;
+ }
+ current = r.content();
+ applied.addAll(r.applied());
+ left = SourceValidator.validate(file, current, ctx.catalog(),
ctx.propertyLineValidator(), dir, null, false);
+ stillThere = new ArrayList<>();
+ if (!left.isEmpty() && exists) {
+ left = newProblems(before, left, stillThere);
+ }
+ }
+ return left.isEmpty() ? new Repaired(current, applied, stillThere) :
null;
+ }
+
private static final Pattern UNCONSUMED_DIRECT = Pattern.compile("sends to
(direct:[^,\\s]+), and no route consumes it");
/**
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
new file mode 100644
index 000000000000..6d640fb0869a
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/LenientRepairs.java
@@ -0,0 +1,575 @@
+/*
+ * 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.ai;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * CAMEL-25532: the YAML shapes a model writes for a Camel route that have one
reading, repaired before a write is
+ * refused for them: an option at the column of its EIP, a plain value where
an expression goes, a list where a map goes
+ * (and the other way round), a list item or a key one column off, an unquoted
placeholder, an enum value in another
+ * case, and the names models use for an option that has another name (from
the benchmark traces). Each repair is driven
+ * by the validator's message for that shape and changes only the lines it
names; a name is never guessed by similarity.
+ * The caller validates the result again, and keeps it only when no new
problem is left.
+ */
+final class LenientRepairs {
+
+ /** The repaired content and what was done, one line each ("line 13:
steps: indented under split:"). */
+ record Result(String content, List<String> applied) {
+ }
+
+ /** A repair of one place of the content; a move changes the lines above
it, so it goes alone. */
+ private record Repair(int line, boolean move, String description, Action
action) {
+ }
+
+ @FunctionalInterface
+ private interface Action {
+ boolean apply(List<String> lines);
+ }
+
+ private LenientRepairs() {
+ }
+
+ private static final Pattern OPTION_AT_EIP = Pattern.compile(
+ "(\\w+): lines up with (\\w+):, so it is read as a second key of
the - item; it is an option of \\2:");
+ private static final Pattern PLAIN_VALUE = Pattern.compile(
+ "^Line (\\d+): (\\w+): a plain value \\((.*)\\) found, an
expression expected");
+ private static final Pattern PLAIN_EXPRESSION = Pattern.compile(
+ "^Line (\\d+): expression: a plain value \\((.*)\\) found, a
language expected: .*write expression:"
+ + "
\\{(\\w+): \\{expression: \"(.*)\"\\}\\}");
+ private static final Pattern ENUM = Pattern.compile(
+ "^Line (\\d+): (\\w+): does not have a value in the enumeration
\\[([^\\]]*)\\]");
+ /** A value in double quotes, as the values of an enumeration are listed
in the validator's message. */
+ private static final Pattern QUOTED = Pattern.compile("\"([^\"]+)\"");
+ private static final Pattern LIST_FOR_MAP = Pattern.compile(
+ "^Line (\\d+): (otherwise|onFallback|doFinally): array found,
object expected");
+ private static final Pattern MAP_FOR_LIST = Pattern.compile(
+ "^Line (\\d+): (get|post|put|delete|patch|head): object found,
array expected");
+ private static final Pattern UNKNOWN_PROPERTY = Pattern.compile(
+ "^Line (\\d+): (\\w+): property '(\\w+)' is not defined in the
schema");
+ private static final Pattern NO_ERROR_HANDLER = Pattern.compile(
+ "^Line (\\d+): noErrorHandler: boolean found, object expected");
+ private static final Pattern BEFORE_ROUTES = Pattern.compile(
+ "^Line (\\d+): (\\w+) must come before the routes: move this entry
above the first - route: \\(line (\\d+)\\)");
+ private static final Pattern PLACEHOLDER = Pattern.compile(
+ "^line (\\d+): .* without quotes is read as a YAML map .*write the
line as (.+)$");
+ private static final Pattern BRACES = Pattern.compile(
+ "^Line (\\d+): .*\\((\\{[^}]*\\}) without quotes is a YAML map");
+ private static final Pattern ITEM_COLUMN = Pattern.compile(
+ "^line (\\d+): this list item starts in column (\\d+), but the
list that starts at line \\d+ has its items in"
+ + " column
(\\d+)");
+ private static final Pattern KEY_COLUMN = Pattern.compile(
+ "^line (\\d+): (\\S+) starts in column (\\d+), but the keys of the
mapping that starts at line \\d+ are in"
+ + " column
(\\d+)");
+
+ /** How many columns a list item or key may be off to be moved: a slip,
not another structure. */
+ private static final int MAX_SHIFT = 2;
+
+ /** body, header.foo, exchangeProperty.x, body.size(): a name the Simple
language reads, written without ${ }. */
+ private static final Pattern BARE_NAME =
Pattern.compile("^[A-Za-z_]\\w*(\\.[\\w\\[\\]()]+)*$");
+
+ private static boolean isBareName(String value) {
+ String v = value.strip();
+ return BARE_NAME.matcher(v).matches() && !"true".equalsIgnoreCase(v)
&& !"false".equalsIgnoreCase(v)
+ && !"null".equals(v);
+ }
+
+ /** The names models write for an option, by EIP, and the option's name
(seen in the benchmark traces). */
+ private static final Map<String, String> ALIASES = Map.ofEntries(
+ Map.entry("setHeader.headerName", "name"),
Map.entry("setHeader.header", "name"),
+ Map.entry("setProperty.propertyName", "name"),
Map.entry("setProperty.property", "name"),
+ Map.entry("setVariable.variableName", "name"),
Map.entry("setVariable.variable", "name"),
+ Map.entry("removeHeader.headerName", "name"),
Map.entry("removeProperty.propertyName", "name"),
+ Map.entry("removeVariable.variableName", "name"),
+ Map.entry("log.level", "loggingLevel"), Map.entry("log.logLevel",
"loggingLevel"),
+ Map.entry("log.loggerName", "logName"),
+ Map.entry("onException.exceptionType", "exception"),
Map.entry("onException.exceptions", "exception"));
+
+ /** The repairs for the messages that have one, applied to the content;
null when none applies. */
+ static Result repair(String content, List<String> errors) {
+ List<String> lines = new ArrayList<>(Arrays.asList(content.split("\n",
-1)));
+ List<Repair> repairs = new ArrayList<>();
+ for (String error : errors) {
+ repairs.addAll(repairsFor(error, lines));
+ }
+ if (repairs.isEmpty()) {
+ return null;
+ }
+ // a move changes the lines above it: alone, when nothing else is left
to repair
+ List<Repair> local = repairs.stream().filter(r -> !r.move()).toList();
+ List<Repair> todo = local.isEmpty() ? List.of(repairs.get(0)) : new
ArrayList<>(local);
+ // from the bottom, so the line numbers of the ones above stay right;
one repair per line
+ todo =
todo.stream().sorted(Comparator.comparingInt(Repair::line).reversed()).toList();
+ Set<Integer> done = new HashSet<>();
+ List<String> applied = new ArrayList<>();
+ for (Repair r : todo) {
+ if (done.add(r.line()) && r.action().apply(lines)) {
+ applied.add(0, r.description());
+ }
+ }
+ return applied.isEmpty() ? null : new Result(String.join("\n", lines),
applied);
+ }
+
+ private static List<Repair> repairsFor(String error, List<String> lines) {
+ List<Repair> answer = new ArrayList<>();
+ Matcher m;
+ if (error.contains("lines up with") && error.startsWith("Line ")) {
+ int line = leadingLine(error);
+ Matcher o = OPTION_AT_EIP.matcher(error);
+ while (o.find()) {
+ String key = o.group(1);
+ String eip = o.group(2);
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + key + ": indented
two spaces, as an option of " + eip + ":",
+ ls -> indentOption(ls, line - 1, key)));
+ }
+ return answer;
+ }
+ if ((m = PLAIN_EXPRESSION.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String language = m.group(3);
+ String text = m.group(4).replace("\\\"", "\"");
+ if (!"simple".equals(language) && m.group(2).equals(text) &&
isBareName(text)) {
+ // expression: body - the text "body" or ${body}: not one
reading (r9L-2 split the text "body")
+ return answer;
+ }
+ answer.add(new Repair(
+ line, false, "line " + line + ": expression: written as "
+ language + ":",
+ ls -> wrapExpression(ls, line - 1, "expression", language,
text)));
+ } else if ((m = PLAIN_VALUE.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String key = m.group(2);
+ String value = m.group(3);
+ if (isBareName(value)) {
+ return answer;
+ }
+ String language = value.contains("${") ? "simple" : "constant";
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + key + ": written as "
+ language + ":",
+ ls -> wrapExpression(ls, line - 1, key, language, null)));
+ } else if ((m = ENUM.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String key = m.group(2);
+ List<String> choices = new ArrayList<>();
+ Matcher q = QUOTED.matcher(m.group(3));
+ while (q.find()) {
+ choices.add(q.group(1));
+ }
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + key + ": the value in
the case of the enumeration",
+ ls -> enumCase(ls, line - 1, key, choices)));
+ } else if ((m = LIST_FOR_MAP.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String key = m.group(2);
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + key + ": its steps
put under steps:",
+ ls -> listUnderSteps(ls, line - 1, key)));
+ } else if ((m = MAP_FOR_LIST.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String key = m.group(2);
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + key + ": written as a
list item",
+ ls -> mapAsListItem(ls, line - 1, key)));
+ } else if ((m = NO_ERROR_HANDLER.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ answer.add(new Repair(
+ line, false, "line " + line + ": noErrorHandler: {}",
+ ls -> replaceValue(ls, line - 1, "noErrorHandler", "true",
"{}")));
+ } else if ((m = UNKNOWN_PROPERTY.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String eip = m.group(2);
+ String property = m.group(3);
+ String alias = ALIASES.get(eip + "." + property);
+ if (alias != null) {
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + property + ":
written as " + alias + ":",
+ ls -> rename(ls, line - 1, property, alias)));
+ } else if (("marshal".equals(eip) || "unmarshal".equals(eip)) &&
"jackson".equals(property)) {
+ answer.add(new Repair(
+ line, false, "line " + line + ": jackson: written as
json: with library: Jackson",
+ ls -> jacksonAsJson(ls, line - 1)));
+ }
+ } else if ((m = BEFORE_ROUTES.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ int route = Integer.parseInt(m.group(3));
+ String entry = m.group(2);
+ answer.add(new Repair(
+ line, true, "line " + line + ": - " + entry + ": moved
above the first - route:",
+ ls -> moveEntry(ls, line - 1, route - 1)));
+ } else if ((m = PLACEHOLDER.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String written = m.group(2);
+ answer.add(new Repair(
+ line, false, "line " + line + ": the placeholder quoted",
+ ls -> replaceLine(ls, line - 1, written)));
+ } else if ((m = BRACES.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ String braces = m.group(2);
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + braces + " quoted",
+ ls -> quoteBraces(ls, line - 1, braces)));
+ } else if ((m = ITEM_COLUMN.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ int delta = Integer.parseInt(m.group(3)) -
Integer.parseInt(m.group(2));
+ if (Math.abs(delta) > MAX_SHIFT) {
+ return answer; // further off is another structure, not a slip
+ }
+ answer.add(new Repair(
+ line, false, "line " + line + ": the list item moved to
the column of the list",
+ ls -> shiftBlock(ls, line - 1, delta)));
+ } else if ((m = KEY_COLUMN.matcher(error)).find()) {
+ int line = Integer.parseInt(m.group(1));
+ int delta = Integer.parseInt(m.group(4)) -
Integer.parseInt(m.group(3));
+ if (Math.abs(delta) > MAX_SHIFT) {
+ return answer;
+ }
+ answer.add(new Repair(
+ line, false, "line " + line + ": " + m.group(2)
+ + " moved to the column of the keys of its
mapping",
+ ls -> shiftBlock(ls, line - 1, delta)));
+ }
+ return answer;
+ }
+
+ private static int leadingLine(String error) {
+ int colon = error.indexOf(':');
+ try {
+ return Integer.parseInt(error.substring(5, colon).trim());
+ } catch (RuntimeException e) {
+ return -1;
+ }
+ }
+
+ //
---------------------------------------------------------------------------------------------------------------
+ // the repairs
+ //
---------------------------------------------------------------------------------------------------------------
+
+ static int indent(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return i;
+ }
+
+ private static boolean valid(List<String> lines, int index) {
+ return index >= 0 && index < lines.size();
+ }
+
+ /**
+ * The lines of the block that starts at {@code start}: the lines below it
that are more indented, and the items of
+ * a list at its own column when the key has no value on its line (YAML
allows the items of a key's list there).
+ */
+ static int blockEnd(List<String> lines, int start) {
+ int col = indent(lines.get(start));
+ String head = lines.get(start).strip();
+ boolean keyWithoutValue = head.endsWith(":");
+ int end = start + 1;
+ while (end < lines.size()) {
+ String l = lines.get(end);
+ if (l.isBlank()) {
+ end++;
+ continue;
+ }
+ int i = indent(l);
+ if (i > col || (keyWithoutValue && i == col &&
l.strip().startsWith("- ") && !head.startsWith("- "))) {
+ end++;
+ continue;
+ }
+ break;
+ }
+ while (end > start + 1 && lines.get(end - 1).isBlank()) {
+ end--;
+ }
+ return end;
+ }
+
+ /** steps: written at the column of - split: is indented two spaces, with
its lines (CAMEL-25529 says which). */
+ private static boolean indentOption(List<String> lines, int itemIndex,
String key) {
+ if (!valid(lines, itemIndex)) {
+ return false;
+ }
+ String item = lines.get(itemIndex);
+ int dash = item.indexOf("- ");
+ if (dash < 0 || dash != indent(item)) {
+ return false;
+ }
+ int col = dash + 2;
+ int itemEnd = blockEnd(lines, itemIndex);
+ for (int j = itemIndex + 1; j < itemEnd; j++) {
+ String l = lines.get(j);
+ if (!l.isBlank() && indent(l) == col && l.strip().startsWith(key +
":")) {
+ int end = blockEnd(lines, j);
+ for (int k = j; k < end; k++) {
+ if (!lines.get(k).isBlank()) {
+ lines.set(k, " " + lines.get(k));
+ }
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static final Pattern KEY_VALUE =
Pattern.compile("^(\\s*)(-\\s+)?([\\w.-]+):\\s*(.*?)\\s*$");
+
+ /** handled: true as handled: {constant: {expression: "true"}};
expression: "${body}" as simple. */
+ private static boolean wrapExpression(List<String> lines, int index,
String key, String language, String text) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ Matcher m = KEY_VALUE.matcher(lines.get(index));
+ if (!m.matches() || !key.equals(m.group(3)) || m.group(4).isEmpty()) {
+ return false;
+ }
+ String raw = m.group(4);
+ String value;
+ if (text != null && !raw.startsWith("\"") && !raw.startsWith("'")) {
+ value = quote(text); // expression: constant 404: the text without
its language
+ } else {
+ value = raw.startsWith("\"") || raw.startsWith("'") ? raw :
quote(raw);
+ }
+ int keyCol = m.group(1).length() + (m.group(2) != null ?
m.group(2).length() : 0);
+ String pad = " ".repeat(keyCol + 2);
+ lines.set(index, m.group(1) + (m.group(2) != null ? m.group(2) : "") +
key + ":");
+ lines.add(index + 1, pad + language + ":");
+ lines.add(index + 2, pad + " expression: " + value);
+ return true;
+ }
+
+ static String quote(String text) {
+ if (!text.contains("\"")) {
+ return "\"" + text + "\"";
+ }
+ if (!text.contains("'")) {
+ return "'" + text + "'";
+ }
+ return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
+ }
+
+ /** library: jackson as library: Jackson, when one value of the
enumeration has that name in another case. */
+ private static boolean enumCase(List<String> lines, int index, String key,
List<String> choices) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ Matcher m = KEY_VALUE.matcher(lines.get(index));
+ if (!m.matches() || !key.equals(m.group(3))) {
+ return false;
+ }
+ String value = m.group(4).replace("\"", "").replace("'", "");
+ List<String> same = choices.stream().filter(c ->
c.equalsIgnoreCase(value)).toList();
+ if (same.size() != 1) {
+ return false;
+ }
+ lines.set(index, m.group(1) + (m.group(2) != null ? m.group(2) : "") +
key + ": " + same.get(0));
+ return true;
+ }
+
+ /** The key line above a line of its block: the nearest line above, less
indented, that is that key. */
+ private static int keyAbove(List<String> lines, int index, String key) {
+ int col = valid(lines, index) ? indent(lines.get(index)) :
Integer.MAX_VALUE;
+ for (int j = index - 1; j >= 0; j--) {
+ String l = lines.get(j);
+ if (l.isBlank()) {
+ continue;
+ }
+ int i = indent(l);
+ String s = l.strip();
+ if (i <= col && (s.equals(key + ":") || s.equals("- " + key +
":"))) {
+ return j;
+ }
+ if (i < col) {
+ return -1;
+ }
+ }
+ return -1;
+ }
+
+ /** otherwise: [- log: ...] as otherwise: {steps: [- log: ...]}. */
+ private static boolean listUnderSteps(List<String> lines, int itemIndex,
String key) {
+ int k = keyAbove(lines, itemIndex, key);
+ if (k < 0) {
+ return false;
+ }
+ int end = blockEnd(lines, k);
+ int keyCol = indent(lines.get(k)) +
(lines.get(k).strip().startsWith("- ") ? 2 : 0);
+ for (int j = k + 1; j < end; j++) {
+ if (!lines.get(j).isBlank()) {
+ lines.set(j, " " + lines.get(j));
+ }
+ }
+ lines.add(k + 1, " ".repeat(keyCol + 2) + "steps:");
+ return true;
+ }
+
+ /** post: {path: ..., to: ...} as post: [- path: ..., to: ...]. */
+ private static boolean mapAsListItem(List<String> lines, int firstIndex,
String key) {
+ int k = keyAbove(lines, firstIndex, key);
+ if (k < 0) {
+ return false;
+ }
+ int end = blockEnd(lines, k);
+ boolean first = true;
+ for (int j = k + 1; j < end; j++) {
+ String l = lines.get(j);
+ if (l.isBlank()) {
+ continue;
+ }
+ if (first) {
+ int i = indent(l);
+ lines.set(j, l.substring(0, i) + "- " + l.substring(i));
+ first = false;
+ } else {
+ lines.set(j, " " + l);
+ }
+ }
+ return !first;
+ }
+
+ private static boolean replaceValue(List<String> lines, int index, String
key, String from, String to) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ Matcher m = KEY_VALUE.matcher(lines.get(index));
+ if (!m.matches() || !key.equals(m.group(3)) ||
!from.equalsIgnoreCase(m.group(4))) {
+ return false;
+ }
+ lines.set(index, m.group(1) + (m.group(2) != null ? m.group(2) : "") +
key + ": " + to);
+ return true;
+ }
+
+ /** headerName: x as name: x; exceptionType: X as exception: [- X]. */
+ private static boolean rename(List<String> lines, int index, String from,
String to) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ Matcher m = KEY_VALUE.matcher(lines.get(index));
+ if (!m.matches() || !from.equals(m.group(3))) {
+ return false;
+ }
+ String head = m.group(1) + (m.group(2) != null ? m.group(2) : "");
+ String value = m.group(4);
+ if ("exception".equals(to) && !value.isEmpty() &&
!value.startsWith("[")) {
+ // onException's exception is a list of class names
+ int keyCol = head.length();
+ lines.set(index, head + "exception:");
+ lines.add(index + 1, " ".repeat(keyCol + 2) + "- " + value);
+ return true;
+ }
+ lines.set(index, head + to + ":" + (value.isEmpty() ? "" : " " +
value));
+ return true;
+ }
+
+ /** unmarshal: {jackson: {}} as unmarshal: {json: {library: Jackson}}. */
+ private static boolean jacksonAsJson(List<String> lines, int index) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ Matcher m = KEY_VALUE.matcher(lines.get(index));
+ if (!m.matches() || !"jackson".equals(m.group(3))) {
+ return false;
+ }
+ String head = m.group(1) + (m.group(2) != null ? m.group(2) : "");
+ String value = m.group(4);
+ if (!value.isEmpty() && !"{}".equals(value)) {
+ return false; // options written inline: not this
+ }
+ int end = blockEnd(lines, index);
+ for (int j = index + 1; j < end; j++) {
+ if (lines.get(j).strip().startsWith("library:")) {
+ return false;
+ }
+ }
+ lines.set(index, head + "json:");
+ lines.add(index + 1, " ".repeat(head.length() + 2) + "library:
Jackson");
+ return true;
+ }
+
+ /** - onException: below the routes moved above the first - route:, as the
runtime wants it. */
+ private static boolean moveEntry(List<String> lines, int index, int
routeIndex) {
+ if (!valid(lines, index) || !valid(lines, routeIndex) || routeIndex >=
index
+ || !lines.get(index).startsWith("- ")) {
+ return false;
+ }
+ int end = blockEnd(lines, index);
+ List<String> block = new ArrayList<>(lines.subList(index, end));
+ lines.subList(index, end).clear();
+ // the blank line it leaves behind, when there are two now
+ if (valid(lines, index) && valid(lines, index - 1) &&
lines.get(index).isBlank() && lines.get(index - 1).isBlank()) {
+ lines.remove(index);
+ }
+ block.add("");
+ lines.addAll(routeIndex, block);
+ return true;
+ }
+
+ /** The line the validator says to write, at the indentation the line has.
*/
+ private static boolean replaceLine(List<String> lines, int index, String
written) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ String l = lines.get(index);
+ lines.set(index, l.substring(0, indent(l)) + written.strip());
+ return true;
+ }
+
+ /** path: {sku} as path: "{sku}". */
+ private static boolean quoteBraces(List<String> lines, int index, String
braces) {
+ if (!valid(lines, index)) {
+ return false;
+ }
+ String l = lines.get(index);
+ int at = l.indexOf(": " + braces);
+ if (at < 0 || l.contains("\"" + braces) || l.contains("'" + braces)) {
+ return false;
+ }
+ lines.set(index, l.substring(0, at + 2) + "\"" + braces + "\"" +
l.substring(at + 2 + braces.length()));
+ return true;
+ }
+
+ /** A list item or a key one column off, moved with its lines to the
column the validator names. */
+ private static boolean shiftBlock(List<String> lines, int index, int
delta) {
+ if (!valid(lines, index) || delta == 0) {
+ return false;
+ }
+ int end = blockEnd(lines, index);
+ if (delta < 0) {
+ for (int j = index; j < end; j++) {
+ String l = lines.get(j);
+ if (!l.isBlank() && indent(l) < -delta) {
+ return false;
+ }
+ }
+ }
+ for (int j = index; j < end; j++) {
+ String l = lines.get(j);
+ if (l.isBlank()) {
+ continue;
+ }
+ lines.set(j, delta > 0 ? " ".repeat(delta) + l :
l.substring(-delta));
+ }
+ return true;
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
index 3ba478e272f4..2784e3791b58 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
@@ -207,7 +207,8 @@ class AuthoringToolsEditTest {
void aMissShowsTheLinesTheFileHasThere(@TempDir Path dir) throws
IOException {
Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
- JsonObject result = edit(dir, "- log:\n message: \"one\"\n id:
nope", "x");
+ // one line in common (CAMEL-25532 matches a snippet from memory on
two or more)
+ JsonObject result = edit(dir, "message: \"one\"\n id: nope", "x");
assertThat(result.getString("status")).isEqualTo("not-found");
assertThat(result.getString("nearest")).contains("message: \"one\"");
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
new file mode 100644
index 000000000000..0dad73019998
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLenientTest.java
@@ -0,0 +1,513 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.catalog.DefaultCamelCatalog;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * CAMEL-25532: the shapes a model wrote in the benchmark that have one
reading are repaired by the write, which then
+ * goes ahead and says what it repaired; a snippet to find that the model
wrote from memory is matched to the closest
+ * lines of the file when that is certain.
+ */
+class AuthoringToolsLenientTest {
+
+ private static final String FILE = "demo.camel.yaml";
+
+ private static JsonObject write(Path dir, String content) {
+ return AuthoringTools.writeFile(new ToolContext(), dir, FILE, content,
true);
+ }
+
+ private static void assertRepairedAndValid(Path dir, JsonObject result,
String... repairs) throws IOException {
+
assertThat(result.getString("status")).as(String.valueOf(result)).isIn("created",
"overwritten");
+ JsonArray repaired = (JsonArray) result.get("repaired");
+ assertThat(repaired).as(String.valueOf(result)).isNotNull();
+ for (String r : repairs) {
+ assertThat(repaired).anySatisfy(x ->
assertThat(String.valueOf(x)).contains(r));
+ }
+ assertThat(result.getString("message")).startsWith("Written with these
repairs of the YAML");
+ String written = Files.readString(dir.resolve(FILE));
+ List<String> errors = SourceValidator.validate(FILE, written, new
DefaultCamelCatalog(), null, dir, null, false);
+ assertThat(errors).as(written).isEmpty();
+ }
+
+ @Test
+ void anOptionAtTheColumnOfItsEip(@TempDir Path dir) throws IOException {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - split:
+ expression:
+ simple: "${body}"
+ steps:
+ - log:
+ message: "a"
+ - to:
+ uri: "sql:INSERT INTO t (id) VALUES (:#id)"
+ parameters:
+ allowNamedParameters: true
+ """);
+ assertRepairedAndValid(dir, result, "steps: indented two spaces, as an
option of split:",
+ "parameters: indented two spaces, as an option of to:");
+ assertThat(Files.readString(dir.resolve(FILE)))
+ .contains(" steps:\n - log:")
+ .contains(" parameters:\n
allowNamedParameters: true");
+ }
+
+ @Test
+ void plainValuesListsAndCase(@TempDir Path dir) throws IOException {
+ JsonObject result = write(dir, """
+ - onException:
+ exception:
+ - java.lang.IllegalStateException
+ handled: true
+ steps:
+ - log:
+ message: "x"
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - unmarshal:
+ json:
+ library: jackson
+ - setBody:
+ expression: "${body}"
+ - filter:
+ expression: constant true
+ steps:
+ - log:
+ message: "f"
+ - choice:
+ when:
+ - expression:
+ simple: "${body} != null"
+ steps:
+ - log:
+ message: "a"
+ otherwise:
+ - log:
+ message: "b"
+ """);
+ assertRepairedAndValid(dir, result, "handled: written as constant:",
"library: the value in the case",
+ "expression: written as simple:", "expression: written as
constant:", "otherwise: its steps put under steps:");
+ assertThat(Files.readString(dir.resolve(FILE)))
+ .contains(" handled:\n constant:\n expression:
\"true\"")
+ .contains("library: Jackson")
+ .contains(" expression:\n constant:\n
expression: \"true\"")
+ .contains("otherwise:\n steps:\n -
log:");
+ }
+
+ @Test
+ void theNamesModelsUseForAnOption(@TempDir Path dir) throws IOException {
+ JsonObject result = write(dir, """
+ - onException:
+ exceptionType: java.lang.IllegalStateException
+ steps:
+ - log:
+ message: "x"
+ - route:
+ errorHandler:
+ noErrorHandler: true
+ from:
+ uri: timer:x
+ steps:
+ - setHeader:
+ headerName: a
+ constant: "b"
+ - setProperty:
+ propertyName: c
+ expression:
+ constant:
+ expression: "d"
+ - log:
+ level: WARN
+ message: "x"
+ - unmarshal:
+ jackson: {}
+ """);
+ assertRepairedAndValid(dir, result, "exceptionType: written as
exception:", "noErrorHandler: {}",
+ "headerName: written as name:", "propertyName: written as
name:", "level: written as loggingLevel:",
+ "jackson: written as json: with library: Jackson");
+ assertThat(Files.readString(dir.resolve(FILE)))
+ .contains(" exception:\n -
java.lang.IllegalStateException")
+ .contains("json:\n library: Jackson");
+ }
+
+ @Test
+ void entriesQuotesAndColumns(@TempDir Path dir) throws IOException {
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ parameters:
+ period: {{welcome.period}}
+ steps:
+ - log:
+ message: "x"
+ - log:
+ message: "y"
+ - onException:
+ exception:
+ - java.lang.IllegalStateException
+ steps:
+ - log:
+ message: "z"
+ - rest:
+ path: /stock
+ post:
+ path: {sku}
+ to:
+ uri: direct:x
+ - route:
+ from:
+ uri: direct:x
+ steps:
+ - log:
+ message: "x"
+ logName: foo
+ """);
+ assertRepairedAndValid(dir, result, "the placeholder quoted", "the
list item moved to the column of the list",
+ "moved above the first - route:", "post: written as a list
item", "{sku} quoted",
+ "logName moved to the column of the keys of its mapping");
+ String written = Files.readString(dir.resolve(FILE));
+ assertThat(written).startsWith("- onException:").contains("period:
\"{{welcome.period}}\"")
+ .contains("post:\n - path: \"{sku}\"\n to:");
+ }
+
+ @Test
+ void aBareSimpleNameIsNotTakenForAConstant(@TempDir Path dir) {
+ // r9L-2: split: {expression: body} written as constant "body" split
the text "body"
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - split:
+ expression: body
+ steps:
+ - log:
+ message: "x"
+ """);
+ assertThat(result.getString("status")).isEqualTo("invalid");
+ assertThat(result.get("repaired")).isNull();
+ }
+
+ @Test
+ void aConstantThatIsNotANameIsStillRepaired(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve("stock.json"), "[]");
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - setBody:
+ expression: resource:file:stock.json
+ - setHeader:
+ name: CamelHttpResponseCode
+ expression: "404"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isIn("created",
"overwritten");
+ assertThat(Files.readString(dir.resolve(FILE)))
+ .contains("constant:\n expression:
\"resource:file:stock.json\"")
+ .contains("constant:\n expression: \"404\"");
+ }
+
+ @Test
+ void aListItemFarOffIsNotMoved(@TempDir Path dir) {
+ // six columns off is another structure, not a slip
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - log:
+ message: "a"
+ - log:
+ message: "b"
+ """);
+ assertThat(result.getString("status")).isEqualTo("invalid");
+ assertThat(result.get("repaired")).isNull();
+ }
+
+ @Test
+ void whatHasNoSingleReadingIsStillRefused(@TempDir Path dir) {
+ // a name the alias table does not have: the write is refused as
before, nothing guessed
+ JsonObject result = write(dir, """
+ - route:
+ from:
+ uri: timer:x
+ steps:
+ - log:
+ mesage: "x"
+ """);
+ assertThat(result.getString("status")).isEqualTo("invalid");
+ assertThat(result.get("repaired")).isNull();
+ assertThat(result.get("errors").toString()).contains("mesage");
+ }
+
+ //
-------------------------------------------------------------------------------------------------------------
+ // the snippet to find, written from memory
+ //
-------------------------------------------------------------------------------------------------------------
+
+ private static final String ROUTE = """
+ - route:
+ id: invoices
+ from:
+ uri: file:inbox
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}"
+ - to:
+ uri: file:archive
+ """;
+
+ @Test
+ void aSnippetFromMemoryIsMatchedToTheClosestLines(@TempDir Path dir)
throws IOException {
+ Files.writeString(dir.resolve(FILE), ROUTE);
+ // the model remembers the log line, and the unmarshal above it as it
was before (jackson, not json)
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - unmarshal:
+ jackson: {}
+ - log:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}"
+ """, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}: ${body[amount]} EUR"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+ assertThat(result.getString("message")).startsWith("The text to find
was not in the file as given");
+ String after = Files.readString(dir.resolve(FILE));
+ assertThat(after).contains("message: \"Invoice ${body[invoiceId]} for
${body[orderId]}: ${body[amount]} EUR\"")
+ .contains("uri: file:archive").contains("uri: file:inbox");
+ }
+
+ @Test
+ void aSnippetWithoutADistinctiveLineIsNotGuessed(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve(FILE), ROUTE);
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - log:
+ message: "something else"
+ """, "- log: \"x\"");
+ assertThat(result.getString("status")).isEqualTo("not-found");
+ assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(ROUTE);
+ }
+
+ @Test
+ void twoPlacesThatMatchAsWellAreNotGuessed() {
+ String content = """
+ - route:
+ from:
+ uri: timer:a
+ steps:
+ - log:
+ message: "same line in both"
+ - to:
+ uri: direct:a
+ - route:
+ from:
+ uri: timer:b
+ steps:
+ - log:
+ message: "same line in both"
+ - to:
+ uri: direct:b
+ """;
+ // the distinctive line is in the file twice: no window is certain
+ assertThat(AuthoringTools.fuzzyWindow(content, """
+ - log:
+ message: "same line in both"
+ - to:
+ uri: direct:c
+ """)).isNull();
+ }
+
+ private static final String ORDER_LINES = """
+ - route:
+ id: order-lines
+ from:
+ uri: file
+ parameters:
+ directoryName: orders
+ noop: true
+ sortBy: "file:name"
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - split:
+ expression:
+ simple: "${body[lines]}"
+ steps:
+ - log:
+ message: " pick ${body[qty]} x ${body[sku]}"
+ """;
+
+ @Test
+ void theLinesTheModelChangedAreChangedOnceInTheFile(@TempDir Path dir)
throws IOException {
+ // r11L-1 route-order-lines step 2: the model remembered the steps of
the split two columns to the left, and
+ // the message without its leading spaces. The closest window ended
before the message line, the window was
+ // replaced, and the old message stayed below the new one: the key
twice
+ Files.writeString(dir.resolve(FILE), ORDER_LINES);
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - split:
+ expression:
+ simple: "${body[lines]}"
+ steps:
+ - log:
+ message: "pick ${body[qty]} x ${body[sku]}"
+ """, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - setHeader:
+ name: orderId
+ simple: "${body[orderId]}"
+ - split:
+ expression:
+ simple: "${body[lines]}"
+ steps:
+ - log:
+ message: " pick ${body[qty]} x ${body[sku]}
for ${header.orderId}"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+ assertThat(result.getString("message")).contains("the lines you
changed were changed there");
+ assertThat(Files.readString(dir.resolve(FILE))).isEqualTo("""
+ - route:
+ id: order-lines
+ from:
+ uri: file
+ parameters:
+ directoryName: orders
+ noop: true
+ sortBy: "file:name"
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - setHeader:
+ name: orderId
+ simple: "${body[orderId]}"
+ - split:
+ expression:
+ simple: "${body[lines]}"
+ steps:
+ - log:
+ message: " pick ${body[qty]} x ${body[sku]}
for ${header.orderId}"
+ """);
+ }
+
+ @Test
+ void aLineRememberedWronglyThatTheModelChangesIsReplacedNotAdded(@TempDir
Path dir) throws IOException {
+ Files.writeString(dir.resolve(FILE), ROUTE);
+ // the model remembers the log message wrongly, and changes it: the
file's message is the one it changes
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]}"
+ """, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}: ${body[amount]} EUR"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+ String after = Files.readString(dir.resolve(FILE));
+ assertThat(after).contains("message: \"Invoice ${body[invoiceId]} for
${body[orderId]}: ${body[amount]} EUR\"")
+ .doesNotContain("message: \"Invoice ${body[invoiceId]} for
${body[orderId]}\"\n")
+ .contains("uri: file:archive");
+ assertThat(after.split("message:", -1)).hasSize(2);
+ }
+
+ @Test
+ void aLineRememberedWronglyThatTheModelKeepsStaysAsTheFileHasIt(@TempDir
Path dir) throws IOException {
+ Files.writeString(dir.resolve(FILE), ROUTE);
+ // the model remembers the log message wrongly and does not change it:
the file keeps its own message, and the
+ // step the model adds after it goes in
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]}"
+ - to:
+ uri: file:archive
+ """, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Invoice ${body[invoiceId]}"
+ - to:
+ uri: file:archive
+ - log:
+ message: "archived"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("edited");
+ String after = Files.readString(dir.resolve(FILE));
+ assertThat(after).contains("message: \"Invoice ${body[invoiceId]} for
${body[orderId]}\"")
+ .doesNotContain("message: \"Invoice ${body[invoiceId]}\"\n")
+ .contains("message: \"archived\"");
+ }
+
+ @Test
+ void aClosestMatchWithAnInvalidResultIsNotFound(@TempDir Path dir) throws
IOException {
+ Files.writeString(dir.resolve(FILE), ROUTE);
+ // after a refused edit, the next text to find is the model's own
replacement, which the file never got: the
+ // answer is the one of an exact match, not found with the lines the
file has there, and the file is unchanged
+ JsonObject result = AuthoringTools.editFile(new ToolContext(), dir,
FILE, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - lgo:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}"
+ """, """
+ - unmarshal:
+ json:
+ library: Jackson
+ - lgo:
+ message: "Invoice ${body[invoiceId]} for
${body[orderId]}: ${body[amount]} EUR"
+ """);
+
assertThat(result.getString("status")).as(String.valueOf(result)).isEqualTo("not-found");
+ assertThat(result.getString("fileWindow")).contains("- log:");
+ assertThat(Files.readString(dir.resolve(FILE))).isEqualTo(ROUTE);
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
index 7e215063cfa7..c868988918b0 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsLoopTest.java
@@ -63,24 +63,26 @@ class AuthoringToolsLoopTest {
@Test
void theSameRefusedContentAgainIsSaidToBeTheSame(@TempDir Path dir) throws
IOException {
- JsonObject first = write(dir, STEPS_NEXT_TO_SPLIT);
+ // a misspelled option: a write repairs steps: next to split:
(CAMEL-25532), not this
+ String refused = STEPS_NEXT_TO_SPLIT.replace("message:", "mesage:")
+ .replace(" steps:\n - log:\n
mesage: \"pick ${body}\"",
+ " steps:\n - log:\n
mesage: \"pick ${body}\"");
+ JsonObject first = write(dir, refused);
assertThat(first.getString("status")).isEqualTo("invalid");
assertThat(first.get("repeated")).isNull();
- JsonObject second = write(dir, STEPS_NEXT_TO_SPLIT);
+ JsonObject second = write(dir, refused);
assertThat(second.getInteger("repeated")).isEqualTo(2);
assertThat(second.getString("message"))
.contains("the same as your last write of it, refused 2 times
now")
.contains("where an error shows the lines to write, copy
them");
- assertThat(write(dir,
STEPS_NEXT_TO_SPLIT).getInteger("repeated")).isEqualTo(3);
+ assertThat(write(dir, refused).getInteger("repeated")).isEqualTo(3);
// other content starts again, and a write that goes through forgets it
- assertThat(write(dir, STEPS_NEXT_TO_SPLIT.replace("pick",
"take")).get("repeated")).isNull();
- assertThat(write(dir,
- STEPS_NEXT_TO_SPLIT.replace(" steps:\n -
log:\n message: \"pick ${body}\"",
- " steps:\n - log:\n
message: \"pick ${body}\""))
- .getString("status")).isIn("created", "overwritten");
- assertThat(write(dir, STEPS_NEXT_TO_SPLIT).get("repeated")).isNull();
+ assertThat(write(dir, refused.replace("pick",
"take")).get("repeated")).isNull();
+ assertThat(write(dir, refused.replace("mesage:",
"message:")).getString("status"))
+ .isIn("created", "overwritten");
+ assertThat(write(dir, refused).get("repeated")).isNull();
}
@Test
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
index 86a0883b39d3..ef477780cfcb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsTest.java
@@ -148,10 +148,12 @@ class AuthoringToolsTest {
@Test
void writeValidatesFirstAndRefusesInvalidContent(@TempDir Path dir) throws
IOException {
Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");
+ // a misspelling no repair reads (a write repairs logLevel to
loggingLevel, CAMEL-25532)
JsonObject invalid = call("camel_write_file", new ToolContext(),
- Map.of("directory", dir.toString(), "file", "demo.camel.yaml",
"content", INVALID_ROUTE));
+ Map.of("directory", dir.toString(), "file", "demo.camel.yaml",
"content",
+ VALID_ROUTE.replace("loggingLevel", "loggingLevl")));
assertEquals("invalid", invalid.getString("status"));
- assertTrue(invalid.getCollection("errors").stream().anyMatch(m ->
m.toString().contains("logLevel")),
+ assertTrue(invalid.getCollection("errors").stream().anyMatch(m ->
m.toString().contains("loggingLevl")),
invalid.toJson());
assertEquals("- route: {}\n",
Files.readString(dir.resolve("demo.camel.yaml"), StandardCharsets.UTF_8));