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));
 

Reply via email to