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 43393d516da2 CAMEL-25673: camel-jbang - camel_write_file refuses a
second copy of a Java class in another folder of the project (#27862)
43393d516da2 is described below
commit 43393d516da281fc406b4cc7690ccaca6b0cae1c
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Oct 15 09:41:07 2026 +0200
CAMEL-25673: camel-jbang - camel_write_file refuses a second copy of a Java
class in another folder of the project (#27862)
* CAMEL-25673: camel-jbang - camel_write_file refuses a second copy of a
Java class in another folder of the project
Asked to change a class of a camel run project, whose sources sit next to
the routes, a model wrote a second copy of
it under src/main/java instead. In a local-model benchmark that happened in
7 of 11 writes of OrderNumber.java. The
project then has two classes of the same name, of which the runtime uses
one, while the change is in the other.
- A new Java source whose class (package and name) another file of the
project already defines is not written. The
answer is status invalid, with existingFile and a message to change the
class where it is. Dot folders, build output
and test sources are not searched.
- A new beans.yaml or application.properties is written, with a note when
the project already has a file of that name
in another folder.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
* CAMEL-25673: an unreadable same-named file is skipped, not a reason to
refuse the write
Review follow-up.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
.../dsl/jbang/core/commands/ai/AuthoringTools.java | 117 +++++++++++++++++
.../commands/ai/AuthoringToolsSameClassTest.java | 142 +++++++++++++++++++++
2 files changed, 259 insertions(+)
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 4d2be4bff8a0..7abf08b7e432 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
@@ -1070,6 +1070,97 @@ public final class AuthoringTools {
}
}
+ private static final Pattern JAVA_PACKAGE =
Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE);
+
+ /** The package a Java source declares, or the empty string for none. */
+ static String javaPackage(String content) {
+ Matcher m = content != null ? JAVA_PACKAGE.matcher(content) : null;
+ return m != null && m.find() ? m.group(1) : "";
+ }
+
+ /**
+ * The other files of the project with the name of the file. Folders that
hold no sources of the app are left out:
+ * dot folders, build output and test sources.
+ */
+ private static List<Path> sameNamedFiles(Path dir, Path path) {
+ Path root = dir.toAbsolutePath().normalize();
+ Path self = path.toAbsolutePath().normalize();
+ String name = self.getFileName().toString();
+ List<Path> found = new ArrayList<>();
+ try {
+ Files.walkFileTree(root, EnumSet.noneOf(FileVisitOption.class),
12, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path d,
BasicFileAttributes attrs) {
+ String n = d.getFileName() != null ?
d.getFileName().toString() : "";
+ String relative =
root.relativize(d).toString().replace('\\', '/');
+ if (!d.equals(root) && (n.startsWith(".") ||
"target".equals(n) || "build".equals(n)
+ || "node_modules".equals(n) ||
"src/test".equals(relative) || relative.endsWith("/src/test"))) {
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path f, BasicFileAttributes
attrs) {
+ if (attrs.isRegularFile() &&
name.equals(f.getFileName().toString())
+ && !self.equals(f.toAbsolutePath().normalize())) {
+ found.add(f);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ } catch (IOException e) {
+ // best effort: without the list the write goes ahead as before
+ }
+ return found;
+ }
+
+ /**
+ * The file of the project that already defines the class a new Java
source would define (the same package and
+ * name), relative to the project directory, or null. Asked to change a
class of a camel run project, whose sources
+ * sit next to the routes, a model wrote a second copy under src/main/java
instead, 7 times out of 11 in a benchmark
+ * (CAMEL-25673): two classes of the same name, of which the runtime uses
one, while the change is in the other.
+ */
+ static String fileOfSameClass(Path dir, Path path, String content) {
+ String pkg = javaPackage(content);
+ Path root = dir.toAbsolutePath().normalize();
+ for (Path other : sameNamedFiles(dir, path)) {
+ String otherContent;
+ try {
+ otherContent = read(other);
+ } catch (ToolExecutionException e) {
+ // best effort, as the search: a file that cannot be read is
no reason to refuse the write
+ continue;
+ }
+ if (pkg.equals(javaPackage(otherContent))) {
+ return
root.relativize(other.toAbsolutePath().normalize()).toString().replace('\\',
'/');
+ }
+ }
+ return null;
+ }
+
+ /**
+ * A note when a new beans.yaml or application.properties has the name of
a file the project already has in another
+ * folder: two files of that name are easy to mix up (CAMEL-25673). Null
for any other file, or when there is none.
+ */
+ static String sameNamedConfigNote(Path dir, Path path) {
+ String name = path.getFileName().toString();
+ boolean config = "application.properties".equals(name)
+ || (name.startsWith("application-") &&
name.endsWith(".properties"))
+ || "beans.yaml".equals(name) || "beans.yml".equals(name);
+ if (!config) {
+ return null;
+ }
+ List<Path> others = sameNamedFiles(dir, path);
+ if (others.isEmpty()) {
+ return null;
+ }
+ String other =
dir.toAbsolutePath().normalize().relativize(others.get(0).toAbsolutePath().normalize()).toString()
+ .replace('\\', '/');
+ return "the project already has " + name + " at " + other + ": two
files of that name are easy to mix up, so"
+ + " change that one unless you mean to have both";
+ }
+
/** 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);
@@ -1077,6 +1168,26 @@ public final class AuthoringTools {
if (exists && !Files.isRegularFile(path)) {
throw new ToolExecutionException(file + " is not a regular file");
}
+ if (!exists && file.endsWith(".java")) {
+ String other = fileOfSameClass(dir, path, content);
+ if (other != null) {
+ String simple = path.getFileName().toString();
+ simple = simple.substring(0, simple.length() -
".java".length());
+ String pkg = javaPackage(content);
+ String fqn = pkg.isEmpty() ? simple : pkg + "." + simple;
+ JsonObject result = new JsonObject();
+ result.put("status", "invalid");
+ result.put("file", file);
+ result.put("existingFile", other);
+ result.put("errors", new JsonArray(
+ List.of("the class " + fqn + " is already in " + other
+ + ": a second copy is a second class of the
same name, and"
+ + " the runtime uses only one of them")));
+ result.put("message", "The file was not written: change the
class where it is, in " + other
+ + " (camel_edit_file, or
camel_write_file with file " + other + ").");
+ return result;
+ }
+ }
List<String> problemsBefore = 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
@@ -1162,6 +1273,12 @@ public final class AuthoringTools {
notes.add(waiting.note());
result.put("notes", notes);
}
+ String sameNamed = exists ? null : sameNamedConfigNote(dir, path);
+ if (sameNamed != null) {
+ JsonArray notes = result.get("notes") instanceof JsonArray
existing ? existing : new JsonArray();
+ notes.add(sameNamed);
+ result.put("notes", notes);
+ }
}
if (watch) {
JsonObject reload = ReloadOutcome.await(watchPid, processName,
sinceKey, RELOAD_WAIT_MILLIS);
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsSameClassTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsSameClassTest.java
new file mode 100644
index 000000000000..3ce21a84ed51
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringToolsSameClassTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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.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-25673: asked to change a class of a camel run project, whose sources
sit next to the routes, a model wrote a
+ * second copy of it under src/main/java instead, which is a second class of
the same name. The write is refused and
+ * names the file to change; a second beans.yaml or application.properties is
written with a note.
+ */
+class AuthoringToolsSameClassTest {
+
+ private static final String ORDER_NUMBER = """
+ package camel.example;
+
+ public class OrderNumber {
+ private int counter = 1001;
+
+ public String next() {
+ return "ORD-" + counter++;
+ }
+ }
+ """;
+
+ private static final String ORDER_NUMBER_WITH_START = """
+ package camel.example;
+
+ public class OrderNumber {
+ private int counter = 1001;
+
+ public void setStart(int start) {
+ this.counter = start;
+ }
+
+ public String next() {
+ return "ORD-" + counter++;
+ }
+ }
+ """;
+
+ @TempDir
+ Path dir;
+
+ private JsonObject write(String file, String content) {
+ return AuthoringTools.writeFile(new ToolContext(), dir, file, content,
true);
+ }
+
+ private void project(String file, String content) throws IOException {
+ Path path = dir.resolve(file);
+ Files.createDirectories(path.getParent());
+ Files.writeString(path, content);
+ }
+
+ @Test
+ void aSecondCopyOfAClassInAnotherFolderIsRefused() throws IOException {
+ project("OrderNumber.java", ORDER_NUMBER);
+
+ JsonObject result =
write("src/main/java/camel/example/OrderNumber.java", ORDER_NUMBER_WITH_START);
+
+ assertThat(result.getString("status")).isEqualTo("invalid");
+
assertThat(result.getString("existingFile")).isEqualTo("OrderNumber.java");
+ assertThat(((JsonArray) result.get("errors")).get(0).toString())
+ .startsWith("the class camel.example.OrderNumber is already in
OrderNumber.java");
+ assertThat(result.getString("message")).contains("change the class
where it is, in OrderNumber.java");
+
assertThat(dir.resolve("src/main/java/camel/example/OrderNumber.java")).doesNotExist();
+ }
+
+ @Test
+ void theClassItselfIsWrittenAsBefore() throws IOException {
+ project("OrderNumber.java", ORDER_NUMBER);
+
+ JsonObject result = write("OrderNumber.java", ORDER_NUMBER_WITH_START);
+
+ assertThat(result.getString("status")).isEqualTo("overwritten");
+
assertThat(dir.resolve("OrderNumber.java")).content().contains("setStart");
+ }
+
+ @Test
+ void aClassOfTheSameNameInAnotherPackageIsWritten() throws IOException {
+ project("OrderNumber.java", ORDER_NUMBER);
+
+ JsonObject result = write("src/main/java/shop/OrderNumber.java",
+ ORDER_NUMBER_WITH_START.replace("package camel.example;",
"package shop;"));
+
+ assertThat(result.getString("status")).isEqualTo("created");
+ }
+
+ @Test
+ void aTestSourceOfTheSameNameIsNotACopy() throws IOException {
+ project("src/test/java/camel/example/OrderNumber.java", ORDER_NUMBER);
+
+ JsonObject result =
write("src/main/java/camel/example/OrderNumber.java", ORDER_NUMBER_WITH_START);
+
+ assertThat(result.getString("status")).isEqualTo("created");
+ }
+
+ @Test
+ void aSecondApplicationPropertiesIsWrittenWithANote() throws IOException {
+ project("application.properties", "order.period=5000\n");
+
+ JsonObject result = write("src/main/resources/application.properties",
"order.first-number=1001\n");
+
+ assertThat(result.getString("status")).isEqualTo("created");
+ assertThat(result.get("notes").toString())
+ .contains("the project already has application.properties at
application.properties");
+ }
+
+ @Test
+ void aSecondBeansYamlGetsTheNoteToo() throws IOException {
+ project("beans.yaml", "- beans: []\n");
+
+ assertThat(AuthoringTools.sameNamedConfigNote(dir,
dir.resolve("src/main/resources/beans.yaml")))
+ .startsWith("the project already has beans.yaml at
beans.yaml");
+ // a route file of the same name in another folder is no configuration
file: no note
+ project("orders.camel.yaml", "- route: {}\n");
+ assertThat(AuthoringTools.sameNamedConfigNote(dir,
dir.resolve("sub/orders.camel.yaml"))).isNull();
+ }
+}