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