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();
+    }
+}

Reply via email to