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 f1501978c833 CAMEL-24909: camel-jbang - camel_edit_file changes a file 
by replacing one snippet (#26750)
f1501978c833 is described below

commit f1501978c83399514ce3b7d72f1a1b2a8e214827
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Sep 23 10:29:14 2026 +0200

    CAMEL-24909: camel-jbang - camel_edit_file changes a file by replacing one 
snippet (#26750)
    
    A model that rewrites a whole file to change one step corrupts the lines it 
did not mean to touch. camel_edit_file replaces the exact text to find, 
validated and reloaded as a write is, and it is confirmed and replayed in the 
camel-jbang views like a write. A miss is recoverable: escapes left in the 
snippet are read as the newlines they stand for, a match at another indentation 
is put in at the file's own, and the lines around the place are handed back so 
the next attempt copies them.
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |   5 +
 .../modules/ROOT/pages/camel-jbang-mcp.adoc        |   7 +
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   4 +
 .../dsl/jbang/core/commands/ai/AuthoringTools.java | 330 +++++++++++++++++++++
 .../core/commands/ai/AuthoringToolsEditTest.java   | 232 +++++++++++++++
 .../jbang/core/commands/ai/AuthoringToolsTest.java |   2 +-
 .../jbang/core/commands/mcp/AuthoringTools.java    |  16 +
 .../dsl/jbang/core/commands/tui/McpFacade.java     |  29 ++
 .../jbang/core/commands/tui/TuiToolRegistry.java   |  12 +
 .../core/commands/tui/AiPanelPromptBudgetTest.java |   4 +-
 .../core/commands/tui/McpFacadeWriteFileTest.java  |  18 ++
 11 files changed, 657 insertions(+), 2 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
index 4eb4fa17c4d4..f92b5daa8346 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -895,6 +895,11 @@ did not load. A write with no selected integration answers 
as before.
 The `validate` argument of `camel_write_file` is gone: the write always 
validates the content and refuses an
 invalid file. A model given the switch turned it off on its own, and the file 
then failed to load.
 
+The authoring set has a new tool, `camel_edit_file`: it replaces one snippet 
of a file, given the exact text to find
+and what to put there, and validates and reloads the result as a write does. A 
model that rewrites a whole file to
+change 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.
+
 The `camel_run` tool, when no files are named, starts the project with `camel 
run --source-dir=.` instead of
 listing the directory's files: the directory is watched, so a file added 
afterwards (a bean file, a Java class
 under `src/main/java`) is part of the app and reloaded in dev mode, and a 
`restart` starts the same way. Naming
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 e364f24b4cff..8cf2d973f870 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -338,6 +338,13 @@ project `directory` as an argument, the runtime tools take 
the integration `name
   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.
 
+| `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.
+  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.
+
 | `camel_run`
 | Starts an integration from a project directory with `camel run --source-dir` 
in a separate process, in dev
   mode by default: the directory is watched, so a changed or added file (a 
route, a bean file, a Java class) is
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 792689b6575b..14d101a0fd20 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -1341,6 +1341,10 @@ you edited is skipped and reported to the agent. When 
the replay is over you rev
 save with *Ctrl+S* or *F5*, which is the confirmation, or discard with *Esc*. 
The agent waits until then
 and is told what was applied, what was skipped, and the content of the saved 
file if you changed it.
 
+An edit (`camel_edit_file`, which the agent uses to change a file without 
rewriting it) goes the same way: the
+snippet is replaced in the file's content and the result is confirmed or 
replayed like any write, so what you see
+in the editor is the finished file either way.
+
 While the replay pauses you can also ask the agent about the change it just 
made: *F8* opens the AI panel
 with the question prefilled (`About edit 2 of 3:`), you complete it and press 
*Enter*. The waiting
 `camel_write_file` call returns to the agent with the question, the edits 
applied so far and the editor's
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 91793577caf4..36adb55dd749 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
@@ -218,6 +218,26 @@ public final class AuthoringTools {
                     return writeFile(ctx, dir, required(args, "file"), 
required(args, "content"), true).toJson();
                 }));
 
+        registry.accept(tool("camel_edit_file",
+                "Changes a file by replacing one snippet: the exact text to 
find (it must occur once) and what to "
+                                                + "put there. Validated and 
reloaded as a write is. Use it to change an existing file, "
+                                                + "camel_write_file for a new 
one.")
+                .param("directory", "string", DIRECTORY_DESC, false)
+                .param("file", "string", FILE_PATH_DESC, true)
+                .param("find", "string", "The lines to replace as they stand 
in the file; other indentation is fine "
+                                         + "when the lines name one place",
+                        true)
+                .param("replace", "string", "The text to put there; empty 
removes it", true)
+                .param("camelVersion", "string", VERSION_DESC, false)
+                .readOnly(false)
+                .core(true)
+                .executor((ctx, args) -> {
+                    applyVersion(ctx, args);
+                    Path dir = ctx.resolveDirectory(args.get("directory"));
+                    return editFile(ctx, dir, required(args, "file"), 
required(args, "find"),
+                            args.get("replace") == null ? "" : 
args.get("replace")).toJson();
+                }));
+
         registry.accept(tool("camel_run",
                 "Starts an integration with camel run in a separate process, 
in dev mode by default (a changed or added file is reloaded). Returns the pid 
and log file; camel_get_log and camel_control follow it.")
                 .param("directory", "string", "Project directory to run in", 
true)
@@ -364,6 +384,316 @@ public final class AuthoringTools {
     /** How long a write waits for the running integration's reload record 
before answering without it. */
     static final long RELOAD_WAIT_MILLIS = 8000;
 
+    /**
+     * Replaces one snippet of a file and writes the result through {@link 
#writeFile}, so a change to an existing file
+     * does not rewrite every line of it: a model that re-emits a whole file 
corrupts the lines it did not mean to touch
+     * (CAMEL-24909). The snippet must occur exactly once; the answer says 
what was replaced.
+     */
+    /** How many lines of the file are handed back on either side of the place 
an edit was aiming at. */
+    private static final int EDIT_WINDOW_LINES = 20;
+
+    public static JsonObject editFile(ToolContext ctx, Path dir, String file, 
String find, String replace) {
+        JsonObject edit = editedContent(dir, file, find, replace);
+        String content = edit.getString("content");
+        if (content == null) {
+            return edit; // not-found or ambiguous: the answer says what to do 
instead
+        }
+        JsonObject result = writeFile(ctx, dir, file, content, true);
+        if (!"invalid".equals(result.getString("status"))) {
+            result.put("status", "edited");
+            result.put("editedAtLine", edit.getInteger("editedAtLine"));
+            result.put("replacedLines", edit.getInteger("replacedLines"));
+        } else {
+            result.put("message", "The file was not changed: the result has 
validation errors. Fix them and call"
+                                  + " camel_edit_file again.");
+        }
+        return result;
+    }
+
+    /**
+     * The content of the file with the snippet replaced, in {@code content}, 
with the line it changed and how many
+     * lines it replaced; or the answer of a miss (not-found, with the nearest 
lines) or of an ambiguous snippet. The
+     * TUI writes that content itself, so an edit is confirmed and replayed in 
the editor like a write.
+     */
+    public static JsonObject editedContent(Path dir, String file, String find, 
String replace) {
+        Path path = resolveFile(dir, file);
+        if (!Files.isRegularFile(path)) {
+            throw new ToolExecutionException(file + " does not exist: write 
the whole file with camel_write_file");
+        }
+        String content;
+        try {
+            content = Files.readString(path, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            throw new ToolExecutionException("Failed to read " + path + ": " + 
e.getMessage());
+        }
+        if (find == null || find.isEmpty()) {
+            throw new ToolExecutionException("find is required: the text to 
replace, as it stands in the file");
+        }
+        String wanted = find;
+        String put = replace;
+        boolean trimmedMatch = false;
+        int first = content.indexOf(wanted);
+        int length = wanted.length();
+        if (first < 0) {
+            // the same lines with different indentation or trailing spaces: a 
model composes the snippet from the
+            // shape it has in mind rather than from the file (CAMEL-24909), 
so match on the trimmed lines when that
+            // names exactly one place
+            int[] window = uniqueTrimmedWindow(content, wanted);
+            if (window != null) {
+                first = window[0];
+                length = window[1] - window[0];
+                trimmedMatch = true;
+            }
+        }
+        if (first < 0 && hasLiteralEscapes(wanted)) {
+            // the snippet was built as a JSON string and its escapes were 
left in it, so the text holds a literal
+            // \n where the file has a newline: read it the way it was meant 
(CAMEL-24909)
+            String unescaped = unescapeLiterals(wanted);
+            int retry = content.indexOf(unescaped);
+            int retryLength = unescaped.length();
+            if (retry < 0) {
+                int[] window = uniqueTrimmedWindow(content, unescaped);
+                if (window != null) {
+                    retry = window[0];
+                    retryLength = window[1] - window[0];
+                    trimmedMatch = true;
+                }
+            }
+            if (retry >= 0) {
+                first = retry;
+                length = retryLength;
+                wanted = unescaped;
+                put = unescapeLiterals(put);
+            }
+        }
+        JsonObject result = new JsonObject();
+        result.put("file", file);
+        if (first < 0) {
+            result.put("status", "not-found");
+            String nearest = nearestBlock(content, wanted);
+            String message = "The text to find is not in the file as given; 
copy the lines from the file"
+                             + (nearest != null ? ", which has there:\n" + 
nearest : "");
+            if (nearest != null) {
+                result.put("nearest", nearest);
+            }
+            // a model that misses is writing the snippet from memory, so hand 
back the part of the file it was
+            // aiming at, rather than sending it to camel_get_files - or, as 
the runs showed, to a whole-file
+            // rewrite, which is what corrupts the lines it did not mean to 
touch (CAMEL-24909)
+            int[] window = aroundNearest(content, wanted);
+            String[] lines = content.split("\n", -1);
+            result.put("fileWindow", join(lines, window[0], window[1]));
+            result.put("windowFromLine", window[0] + 1);
+            result.put("windowToLine", window[1]);
+            message += (nearest != null ? ". L" : ". The file's l") + "ines " 
+ (window[0] + 1) + " to " + window[1]
+                       + " are in fileWindow"
+                       + (window[0] == 0 && window[1] >= (int) 
content.lines().count() ? " (the whole file)" : "")
+                       + ": copy the text to find from there and call 
camel_edit_file again, rather than writing"
+                       + " the whole file";
+            result.put("message", message);
+            return result;
+        }
+        if (content.indexOf(wanted, first + wanted.length()) >= 0) {
+            result.put("status", "ambiguous");
+            result.put("occurrences", count(content, wanted));
+            result.put("message", "The text to find occurs more than once: 
include the lines around it so it names one"
+                                  + " place, or write the whole file with 
camel_write_file");
+            return result;
+        }
+        if (trimmedMatch) {
+            // the snippet was written at another indentation than the file 
has: put the replacement in at the
+            // file's indentation, or the result is valid text at the wrong 
depth (CAMEL-24909)
+            put = reindent(put, indentOf(wanted), 
indentOf(content.substring(first)));
+            // the window of a trimmed match ends after the newline of its 
last line, so that removing a block
+            // removes its lines whole; a replacement that does not end in a 
newline must bring that one back, or
+            // the line after the window is glued onto it
+            if (!put.isEmpty() && !put.endsWith("\n") && content.charAt(first 
+ length - 1) == '\n') {
+                put = put + "\n";
+            }
+        }
+        int line = (int) content.substring(0, first).lines().count()
+                   + (first > 0 && content.charAt(first - 1) == '\n' ? 1 : 0);
+        result.put("content", content.substring(0, first) + put + 
content.substring(first + length));
+        result.put("editedAtLine", Math.max(1, line));
+        // the lines actually replaced: with the trimmed match that is the 
window in the file, which can be shorter
+        // than find when it ends in blank lines (CAMEL-24909)
+        result.put("replacedLines", (int) content.substring(first, first + 
length).lines().count());
+        return result;
+    }
+
+    /** The leading whitespace of the first line of the text that has 
something on it. */
+    private static String indentOf(String text) {
+        for (String line : text.split("\n", -1)) {
+            if (!line.isBlank()) {
+                int i = 0;
+                while (i < line.length() && 
Character.isWhitespace(line.charAt(i))) {
+                    i++;
+                }
+                return line.substring(0, i);
+            }
+        }
+        return "";
+    }
+
+    /** Moves the text from the indentation it was written at to the one the 
file has at that place. */
+    private static String reindent(String text, String from, String to) {
+        int delta = to.length() - from.length();
+        if (delta == 0 || text.isEmpty()) {
+            return text;
+        }
+        StringBuilder sb = new StringBuilder(text.length() + Math.abs(delta) * 
8);
+        String[] lines = text.split("\n", -1);
+        for (int i = 0; i < lines.length; i++) {
+            String line = lines[i];
+            if (!line.isBlank()) {
+                if (delta > 0) {
+                    line = " ".repeat(delta) + line;
+                } else {
+                    int strip = 0;
+                    while (strip < -delta && strip < line.length() && 
line.charAt(strip) == ' ') {
+                        strip++;
+                    }
+                    line = line.substring(strip);
+                }
+            }
+            sb.append(line);
+            if (i < lines.length - 1) {
+                sb.append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    /** Whether the text carries JSON escapes that were never turned back into 
the characters they stand for. */
+    private static boolean hasLiteralEscapes(String text) {
+        return text != null && (text.contains("\\n") || 
text.contains("\\r\\n") || text.contains("\\t"));
+    }
+
+    /** Reads {@code \n}, {@code \r\n} and {@code \t} as the characters they 
stand for. */
+    private static String unescapeLiterals(String text) {
+        return text == null ? null : text.replace("\\r\\n", 
"\n").replace("\\n", "\n").replace("\\t", "\t");
+    }
+
+    /**
+     * The one place where the file's lines match the wanted lines once their 
leading and trailing whitespace is
+     * removed, as start and end offset in the content, or null when there is 
no such place or more than one.
+     */
+    private static int[] uniqueTrimmedWindow(String content, String find) {
+        List<String> wanted = find.lines().map(String::strip).toList();
+        while (!wanted.isEmpty() && wanted.get(wanted.size() - 1).isEmpty()) {
+            wanted = wanted.subList(0, wanted.size() - 1);
+        }
+        if (wanted.isEmpty()) {
+            return null;
+        }
+        String[] lines = content.split("\n", -1);
+        int[] offsets = lineOffsets(content, lines);
+        int[] found = null;
+        for (int i = 0; i + wanted.size() <= lines.length; i++) {
+            boolean match = true;
+            for (int j = 0; j < wanted.size(); j++) {
+                if (!lines[i + j].strip().equals(wanted.get(j))) {
+                    match = false;
+                    break;
+                }
+            }
+            if (match) {
+                if (found != null) {
+                    return null; // more than one place: the caller must name 
one
+                }
+                int end = offsets[i + wanted.size() - 1] + lines[i + 
wanted.size() - 1].length();
+                found = new int[] { offsets[i], Math.min(end + 1, 
content.length()) };
+            }
+        }
+        return found;
+    }
+
+    /**
+     * 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.
+     */
+    private static String nearestBlock(String content, String find) {
+        List<String> wanted = find.lines().map(String::strip).filter(l -> 
!l.isEmpty()).toList();
+        if (wanted.isEmpty()) {
+            return null;
+        }
+        String[] lines = content.split("\n", -1);
+        int bestAt = nearestAt(lines, wanted);
+        if (bestAt < 0) {
+            return null; // hardly anything matches: naming a place would 
mislead
+        }
+        return join(lines, bestAt, bestAt + Math.min(wanted.size(), 
lines.length));
+    }
+
+    /** Where the file comes closest to the wanted lines, or -1 when hardly 
anything of them matches. */
+    private static int nearestAt(String[] lines, List<String> wanted) {
+        if (wanted.isEmpty()) {
+            return -1;
+        }
+        int size = Math.min(wanted.size(), lines.length);
+        int bestAt = -1;
+        int bestScore = 0;
+        for (int i = 0; i + size <= lines.length; i++) {
+            int score = 0;
+            for (int j = 0; j < size; j++) {
+                if (lines[i + j].strip().equals(wanted.get(j))) {
+                    score++;
+                }
+            }
+            if (score > bestScore) {
+                bestScore = score;
+                bestAt = i;
+            }
+        }
+        return bestAt >= 0 && bestScore * 4 >= size ? bestAt : -1;
+    }
+
+    /**
+     * The part of the file the edit was aiming at, as the first and last line 
index (the last exclusive): the block
+     * that comes closest to the wanted lines with {@link #EDIT_WINDOW_LINES} 
lines of room on either side, or the
+     * beginning of the file when nothing comes close.
+     */
+    private static int[] aroundNearest(String content, String find) {
+        String[] lines = content.split("\n", -1);
+        // the newline that ends the last line is not a line of its own
+        int count = content.endsWith("\n") ? lines.length - 1 : lines.length;
+        List<String> wanted = find.lines().map(String::strip).filter(l -> 
!l.isEmpty()).toList();
+        int at = nearestAt(lines, wanted);
+        int size = Math.max(1, Math.min(wanted.size(), count));
+        int from = at < 0 ? 0 : Math.max(0, at - EDIT_WINDOW_LINES);
+        int to = at < 0
+                ? Math.min(count, size + 2 * EDIT_WINDOW_LINES)
+                : Math.min(count, at + size + EDIT_WINDOW_LINES);
+        return new int[] { from, to };
+    }
+
+    /** The lines from {@code from} (inclusive) to {@code to} (exclusive), as 
they stand. */
+    private static String join(String[] lines, int from, int to) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = from; i < to; i++) {
+            sb.append(lines[i]).append('\n');
+        }
+        return sb.toString();
+    }
+
+    /** The offset of each line in the content. */
+    private static int[] lineOffsets(String content, String[] lines) {
+        int[] offsets = new int[lines.length];
+        int at = 0;
+        for (int i = 0; i < lines.length; i++) {
+            offsets[i] = at;
+            at += lines[i].length() + 1;
+        }
+        return offsets;
+    }
+
+    private static int count(String content, String find) {
+        int n = 0;
+        for (int i = content.indexOf(find); i >= 0; i = content.indexOf(find, 
i + find.length())) {
+            n++;
+        }
+        return n;
+    }
+
     /** Writes a file after validating it, as {@code camel_write_file} does; 
no confirmation is asked here. */
     public static JsonObject writeFile(ToolContext ctx, Path dir, String file, 
String content, boolean validate) {
         Path path = resolveFile(dir, file);
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
new file mode 100644
index 000000000000..dd0b4a3209f7
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsEditTest.java
@@ -0,0 +1,232 @@
+/*
+ * 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 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;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * CAMEL-24909: camel_edit_file replaces one snippet, so a change to a file 
does not rewrite every line of it.
+ */
+class AuthoringToolsEditTest {
+
+    private static final String ROUTE = """
+            - route:
+                id: stock-service
+                from:
+                  uri: direct:one-sku
+                  steps:
+                    - setBody:
+                        expression:
+                          jsonpath:
+                            expression: "$[?(@.sku == '${header.sku}')]"
+                    - log:
+                        message: "one"
+
+            - route:
+                id: check-stock
+                from:
+                  uri: direct:check
+                  steps:
+                    - log:
+                        message: "two"
+            """;
+
+    private static JsonObject edit(Path dir, String find, String replace) {
+        return AuthoringTools.editFile(new ToolContext(), dir, 
"demo.camel.yaml", find, replace);
+    }
+
+    @Test
+    void aSnippetIsReplacedAndTheRestOfTheFileIsUntouched(@TempDir Path dir) 
throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject result = edit(dir, "message: \"two\"", "message: \"two and 
a half\"");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        assertThat(result.getInteger("editedAtLine")).isEqualTo(19);
+        String after = Files.readString(dir.resolve("demo.camel.yaml"));
+        // the line the model would have corrupted by rewriting the whole file 
is exactly as it was
+        assertThat(after).contains("expression: \"$[?(@.sku == 
'${header.sku}')]\"")
+                .contains("message: \"two and a half\"")
+                .doesNotContain("message: \"two\"\n");
+        assertThat(after.lines().count()).isEqualTo(ROUTE.lines().count());
+    }
+
+    @Test
+    void escapesLeftInTheSnippetAreReadAsTheNewlinesTheyStandFor(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        // the model built the snippet as a JSON string and left its escapes 
in it
+        JsonObject result = edit(dir,
+                "- log:\\n                        message: \"two\"",
+                "- log:\\n                        message: \"two and a 
half\"");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        String after = Files.readString(dir.resolve("demo.camel.yaml"));
+        assertThat(after).contains("message: \"two and a 
half\"").doesNotContain("\\n");
+        assertThat(after.lines().count()).isEqualTo(ROUTE.lines().count());
+    }
+
+    @Test
+    void aSnippetWrittenAtAnotherIndentationIsPutInAtTheFileOwn(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        // the model wrote both the snippet and its replacement flat against 
the left margin
+        JsonObject result = edit(dir, "- log:\n    message: \"two\"", "- 
log:\n    message: \"two\"\n- to:\n    uri: mock:end");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        String after = Files.readString(dir.resolve("demo.camel.yaml"));
+        assertThat(after).contains("        - log:\n            message: 
\"two\"\n"
+                                   + "        - to:\n            uri: 
mock:end");
+    }
+
+    @Test
+    void 
aTrimmedMatchInTheMiddleOfTheFileKeepsTheLineAfterItOnItsOwnLine(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        // the first route, so there are lines after the window; the snippet 
is written flat (a trimmed match)
+        JsonObject result = edit(dir, "- log:\n    message: \"one\"", "- 
log:\n    message: \"ONE\"");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        String after = Files.readString(dir.resolve("demo.camel.yaml"));
+        assertThat(after).contains("            message: \"ONE\"\n\n- route:");
+        assertThat(after.lines().count()).isEqualTo(ROUTE.lines().count());
+    }
+
+    @Test
+    void removingABlockWithATrimmedMatchLeavesNoEmptyLine(@TempDir Path dir) 
throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject result = edit(dir, "- log:\n    message: \"one\"", "");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        String after = Files.readString(dir.resolve("demo.camel.yaml"));
+        assertThat(after.lines().count()).isEqualTo(ROUTE.lines().count() - 2);
+    }
+
+    @Test
+    void aMissHandsBackThePartOfTheFileItWasAimingAt(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject missing = edit(dir, "uri: direct:nowhere", "x");
+
+        assertThat(missing.getString("status")).isEqualTo("not-found");
+        // this file is shorter than the window, so the window is the whole of 
it
+        assertThat(missing.getString("fileWindow")).isEqualTo(ROUTE);
+        assertThat(missing.getInteger("windowFromLine")).isEqualTo(1);
+        assertThat(missing.getString("message"))
+                .contains("fileWindow")
+                .contains("rather than writing the whole file");
+    }
+
+    @Test
+    void aMissInALongFileHandsBackOnlyTheLinesAroundThePlace(@TempDir Path 
dir) throws IOException {
+        StringBuilder sb = new StringBuilder("- route:\n    from:\n      uri: 
direct:long\n      steps:\n");
+        for (int i = 0; i < 200; i++) {
+            sb.append("        - log:\n            message: \"step 
").append(i).append("\"\n");
+        }
+        Files.writeString(dir.resolve("demo.camel.yaml"), sb.toString());
+
+        // aimed at a line that is there, with a neighbour that is not
+        JsonObject missing = edit(dir, "message: \"step 150\"\n            
message: \"nowhere\"", "x");
+
+        assertThat(missing.getString("status")).isEqualTo("not-found");
+        String window = missing.getString("fileWindow");
+        assertThat(window).contains("step 150").doesNotContain("step 
100").doesNotContain("step 199");
+        assertThat(window.lines().count()).isLessThanOrEqualTo(2L * 20 + 4);
+        assertThat(missing.getInteger("windowFromLine")).isGreaterThan(1);
+    }
+
+    @Test
+    void textThatIsNotThereOrOccursTwiceIsRefusedWithWhatToDo(@TempDir Path 
dir) throws IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject missing = edit(dir, "uri: direct:nowhere", "x");
+        assertThat(missing.getString("status")).isEqualTo("not-found");
+        assertThat(missing.getString("message")).contains("copy the lines from 
the file");
+
+        JsonObject twice = edit(dir, "- log:", "- log2:");
+        assertThat(twice.getString("status")).isEqualTo("ambiguous");
+        assertThat(twice.getInteger("occurrences")).isEqualTo(2);
+        assertThat(twice.getString("message")).contains("occurs more than 
once");
+        
assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).isEqualTo(ROUTE);
+    }
+
+    /** CAMEL-24909: the same lines with other indentation still name the 
place, when they name only one. */
+    @Test
+    void theIndentationOfTheSnippetMayDiffer(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        // the snippet as a model composes it: the right lines, its own 
indentation
+        JsonObject result = edit(dir, "- log:\n    message: \"two\"", "        
            - log:\n"
+                                                                      + "      
                  message: \"two and a half\"");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        
assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).contains("message: 
\"two and a half\"")
+                .contains("expression: \"$[?(@.sku == '${header.sku}')]\"");
+    }
+
+    /** CAMEL-24909: replacedLines counts the lines of the file that went, not 
the lines of a find with blanks. */
+    @Test
+    void replacedLinesCountsTheLinesOfTheFile(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject result = edit(dir, "- log:\n    message: \"two\"\n\n", "    
                - log:\n"
+                                                                          + "  
                      message: \"two!\"\n");
+
+        assertThat(result.getString("status")).isEqualTo("edited");
+        assertThat(result.getInteger("replacedLines")).isEqualTo(2);
+    }
+
+    /** CAMEL-24909: a miss shows the lines the file has there, so the next 
attempt copies them. */
+    @Test
+    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");
+
+        assertThat(result.getString("status")).isEqualTo("not-found");
+        assertThat(result.getString("nearest")).contains("message: \"one\"");
+        assertThat(result.getString("message")).contains("which has there");
+    }
+
+    @Test
+    void aResultThatDoesNotValidateIsNotWritten(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), ROUTE);
+
+        JsonObject result = edit(dir, "message: \"two\"", "mesage: \"two\"");
+
+        assertThat(result.getString("status")).isEqualTo("invalid");
+        assertThat(result.getString("message")).contains("The file was not 
changed");
+        
assertThat(Files.readString(dir.resolve("demo.camel.yaml"))).isEqualTo(ROUTE);
+    }
+
+    @Test
+    void aFileThatDoesNotExistSaysToWriteIt(@TempDir Path dir) {
+        assertThatThrownBy(() -> edit(dir, "x", "y"))
+                .isInstanceOf(ToolExecutionException.class)
+                .hasMessageContaining("camel_write_file");
+    }
+}
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 8d95081714ce..d4ed18b57354 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
@@ -75,7 +75,7 @@ class AuthoringToolsTest {
         List<String> names = 
shared.stream().map(ToolDescriptor::name).toList();
         assertEquals(List.of("camel_catalog_doc", "camel_catalog_find", 
"camel_catalog_sample", "camel_validate_source",
                 "camel_get_files",
-                "camel_write_file", "camel_run", "camel_control", 
"camel_get_log", "camel_get_errors",
+                "camel_write_file", "camel_edit_file", "camel_run", 
"camel_control", "camel_get_log", "camel_get_errors",
                 "camel_eval_expression", "camel_dependency_for_class", 
"camel_error_diagnose"), names);
         for (ToolDescriptor td : shared) {
             assertTrue(td.name().startsWith("camel_"), td.name());
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
index bb26bf072701..d666d660d8a0 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java
@@ -153,6 +153,22 @@ public class AuthoringTools {
                 "camelVersion", camelVersion));
     }
 
+    @Tool(annotations = @Tool.Annotations(readOnlyHint = false, 
destructiveHint = false, openWorldHint = false),
+          description = "Changes a file by replacing one snippet: the exact 
text to find (it must occur once) and "
+                        + "what to put there. Validated and reloaded as a 
write is. Use it to change an existing file, "
+                        + "camel_write_file for a new one.")
+    public JsonObject camel_edit_file(
+            @ToolArg(description = DIRECTORY_DESC, required = false) String 
directory,
+            @ToolArg(description = "File path relative to the directory", 
required = true) String file,
+            @ToolArg(description = "The lines to replace as they stand in the 
file; other indentation is fine when "
+                                   + "the lines name one place",
+                     required = true) String find,
+            @ToolArg(description = "The text to put there; empty removes it", 
required = true) String replace,
+            @ToolArg(description = VERSION_DESC, required = false) String 
camelVersion) {
+        return call("camel_edit_file", args("directory", directory, "file", 
file, "find", find, "replace", replace,
+                "camelVersion", camelVersion));
+    }
+
     @Tool(annotations = @Tool.Annotations(readOnlyHint = false, 
destructiveHint = false, openWorldHint = true),
           description = "Starts an integration with camel run in a separate 
process, in dev mode by default (files reload when written). Returns the pid 
and log file; camel_get_log, camel_get_errors and camel_control follow it.")
     public JsonObject camel_run(
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 9ddbd0acd55d..e24765fe6746 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -1190,6 +1190,35 @@ class McpFacade {
         return result;
     }
 
+    /**
+     * Replaces one snippet of a file of the integration and writes the result 
through
+     * {@link #writeFile(String, String, String, boolean)}, so an edit is 
confirmed and replayed in the editor like a
+     * write (CAMEL-24909). The reading and the matching are the shared 
tool's, only the writing is the TUI's.
+     */
+    JsonObject editFile(String name, String file, String find, String replace, 
boolean confirm) {
+        IntegrationInfo target = findIntegration(name);
+        if (target == null) {
+            return writeError(name != null && !name.isEmpty()
+                    ? "No integration named '" + name + "'" : "No integration 
selected");
+        }
+        Path dir = FilesBrowser.resolveSourceDirectory(target);
+        if (dir == null || !Files.isDirectory(dir)) {
+            return writeError("No source directory found for the integration");
+        }
+        JsonObject edit = AuthoringTools.editedContent(dir, file, find, 
replace);
+        String content = edit.getString("content");
+        if (content == null) {
+            return edit; // not-found, ambiguous or an error: the shared 
answer says what to do
+        }
+        JsonObject result = writeFile(name, file, content, confirm);
+        if (!"invalid".equals(result.getString("status")) && 
!"error".equals(result.getString("status"))) {
+            result.put("status", "edited");
+            result.put("editedAtLine", edit.getInteger("editedAtLine"));
+            result.put("replacedLines", edit.getInteger("replacedLines"));
+        }
+        return result;
+    }
+
     private JsonObject replayResult(
             IntegrationInfo target, Path dir, String file, String requested, 
ReplayOutcome outcome) {
         JsonObject result = new JsonObject();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 702f86313e45..2bc863468e25 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -108,6 +108,7 @@ class TuiToolRegistry {
             case LOG_TOOL -> facade != null ? callGetLog(args) : 
executeShared(name, args);
             case FILES_TOOL -> !hasDirectory && facadeSelection ? 
callGetFiles(args) : executeShared(name, args);
             case WRITE_TOOL -> !hasDirectory && facadeSelection ? 
callWriteFile(args) : executeShared(name, args);
+            case EDIT_TOOL -> !hasDirectory && facadeSelection ? 
callEditFile(args) : executeShared(name, args);
             case VALIDATE_TOOL -> !hasDirectory && facadeSelection && 
args.get("content") == null
                     ? callValidateSource(args) : executeShared(name, args);
             default -> executeShared(name, args);
@@ -164,6 +165,7 @@ class TuiToolRegistry {
     static final String LOG_TOOL = "camel_get_log";
     static final String FILES_TOOL = "camel_get_files";
     static final String WRITE_TOOL = "camel_write_file";
+    static final String EDIT_TOOL = "camel_edit_file";
     static final String VALIDATE_TOOL = "camel_validate_source";
 
     /** The TUI's own tools in the core subset; the shared tools add those 
flagged core in the registry. */
@@ -1396,6 +1398,16 @@ class TuiToolRegistry {
         return Jsoner.serialize(facade.writeFile(name, file, content, 
confirm));
     }
 
+    private String callEditFile(Map<String, Object> args) {
+        String name = args.get("name") instanceof String s ? s : null;
+        String file = args.get("file") instanceof String s ? s : null;
+        String find = args.get("find") instanceof String s ? s : null;
+        String replace = args.get("replace") instanceof String s ? s : "";
+        boolean confirm = !Boolean.FALSE.equals(args.get("confirm"));
+        // an edit is confirmed and replayed in the editor like a write 
(CAMEL-24909)
+        return Jsoner.serialize(facade.editFile(name, file, find, replace, 
confirm));
+    }
+
     private String callValidateSource(Map<String, Object> args) {
         String name = args.get("name") instanceof String s ? s : null;
         String file = args.get("file") instanceof String s ? s : null;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
index b3ceac02cc9e..1589a28087bb 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelPromptBudgetTest.java
@@ -50,7 +50,9 @@ class AiPanelPromptBudgetTest {
     // raised with the core budget (CAMEL-24760)
     // raised from 9200 when camel_control gained the reload action 
(CAMEL-24861): main was ~9180 already; the
     // camel_run and camel_control descriptions were shortened in the same 
change
-    static final int FULL_BUDGET_TOKENS = 9_300;
+    // raised from 9300 for camel_edit_file (CAMEL-24909), the tool that 
changes a file without rewriting it: it
+    // saves far more tokens per edit than its schema costs once
+    static final int FULL_BUDGET_TOKENS = 9_450;
 
     record Prefix(String mode, int tools, long promptChars, long toolChars) {
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
index e9bec0b2ee83..48574d85ee6d 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacadeWriteFileTest.java
@@ -185,6 +185,24 @@ class McpFacadeWriteFileTest {
         assertFalse(result.getString("editing").contains("reloaded"));
     }
 
+    /** CAMEL-24909: an edit goes through the same confirmation and validation 
as a write. */
+    @Test
+    void anEditIsConfirmedAndValidatedLikeAWrite(@TempDir Path dir) throws 
IOException {
+        Files.writeString(dir.resolve("demo.camel.yaml"), "- route:\n    id: 
x\n    from:\n      uri: direct:x\n");
+        ConfirmingBridge bridge = new ConfirmingBridge(true);
+        McpFacade facade = facade(dir, true, bridge);
+
+        JsonObject edited = facade.editFile("demo", "demo.camel.yaml", "id: 
x", "id: y", true);
+        assertEquals("edited", edited.getString("status"));
+        assertEquals(1, bridge.asked, "the user confirms an edit as a write");
+        assertEquals("- route:\n    id: y\n    from:\n      uri: direct:x\n",
+                Files.readString(dir.resolve("demo.camel.yaml"), 
StandardCharsets.UTF_8));
+
+        JsonObject missing = facade.editFile("demo", "demo.camel.yaml", "id: 
nowhere", "id: z", true);
+        assertEquals("not-found", missing.getString("status"));
+        assertEquals(1, bridge.asked, "nothing to confirm when the snippet is 
not there");
+    }
+
     @Test
     void invalidContentIsNeverWritten(@TempDir Path dir) throws IOException {
         Files.writeString(dir.resolve("demo.camel.yaml"), "- route: {}\n");

Reply via email to