This is an automated email from the ASF dual-hosted git repository. gnodet pushed a commit to branch calm-hisser in repository https://gitbox.apache.org/repos/asf/camel.git
commit e8d2a616dd378687d99e25ea96a53177eaaf4374 Author: Guillaume Nodet <[email protected]> AuthorDate: Mon Mar 23 22:11:37 2026 +0100 CAMEL-22544: jbang dependency update supports multi-file and --scan-routes - Accept multiple target files (pom.xml or Java source files with //DEPS) - Add --scan-routes flag to sync dependencies from route definitions: - Manages only org.apache.camel dependencies - Preserves non-Camel dependencies - Removes unused Camel dependencies - Idempotent on re-execution - Route files (YAML, XML) passed as arguments are used as source files for the export pipeline dependency resolution - In scan-routes mode, Camel //DEPS in Java target files are stripped before export to prevent stale deps from being resolved Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../dsl/jbang/core/commands/DependencyUpdate.java | 506 ++++++++++++++++++--- .../core/commands/DependencyUpdateJBangTest.java | 276 +++++++++++ 2 files changed, 715 insertions(+), 67 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdate.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdate.java index 5c96cc3fbc5d..59efc5b1da80 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdate.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdate.java @@ -20,7 +20,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; import java.util.StringJoiner; import org.w3c.dom.Document; @@ -38,21 +40,30 @@ import org.apache.logging.log4j.util.Strings; import picocli.CommandLine; @CommandLine.Command(name = "update", - description = "Updates dependencies in Maven pom.xml or Java source file (JBang style)", + description = "Updates dependencies in Maven pom.xml or Java source files (JBang style)", sortOptions = false, showDefaultValues = true) public class DependencyUpdate extends DependencyList { - @CommandLine.Parameters(description = "Maven pom.xml or Java source files (JBang Style with //DEPS) to have dependencies updated", - arity = "1") - public Path file; + @CommandLine.Parameters(description = "Maven pom.xml or Java source files (JBang Style with //DEPS) to have dependencies updated." + + " Route definition files (YAML, XML) can also be included and will be used as source files" + + " for dependency resolution.", + arity = "1..*") + public List<Path> targetFiles; @CommandLine.Option(names = { "--clean" }, description = "Regenerate list of dependencies (do not keep existing dependencies). Not supported for pom.xml") protected boolean clean; + @CommandLine.Option(names = { "--scan-routes" }, + description = "Sync dependencies from route definitions. Only manages org.apache.camel dependencies," + + " preserving non-Camel dependencies. Removes unused Camel dependencies.") + protected boolean scanRoutes; + private final List<String> deps = new ArrayList<>(); private final List<MavenGav> gavs = new ArrayList<>(); + // actual target files to update (pom.xml or Java files with //DEPS) + private final List<Path> updateTargets = new ArrayList<>(); public DependencyUpdate(CamelJBangMain main) { super(main); @@ -60,22 +71,53 @@ public class DependencyUpdate extends DependencyList { @Override public Integer doCall() throws Exception { - // source file must exist - if (!Files.exists(file)) { - printer().printErr("Source file does not exist: " + file); + // classify target files: pom.xml and Java files are update targets, + // route definition files (yaml, xml, etc.) are source files for the export pipeline + updateTargets.clear(); + for (Path file : targetFiles) { + if (!Files.exists(file)) { + printer().printErr("Source file does not exist: " + file); + return -1; + } + String name = file.getFileName().toString(); + String ext = FileUtil.onlyExt(name, true); + if ("pom.xml".equals(name) || "java".equals(ext)) { + updateTargets.add(file); + } + // route source files are already added to this.files by the parent's parameter consumer + } + + if (scanRoutes) { + // in scan-routes mode, strip existing //DEPS from Java target files before the export pipeline + // runs, so only actual route-based dependencies are resolved (not stale //DEPS) + for (Path file : updateTargets) { + String ext = FileUtil.onlyExt(file.getFileName().toString(), true); + if ("java".equals(ext)) { + stripJBangDeps(file); + } + } + } + + if (updateTargets.isEmpty()) { + printer().printErr("No target files (pom.xml or Java source files) specified"); return -1; } - boolean maven = "pom.xml".equals(file.getFileName().toString()); + Path firstTarget = updateTargets.get(0); + boolean maven = "pom.xml".equals(firstTarget.getFileName().toString()); - if (clean && !maven) { - // remove DEPS in source file first - updateJBangSource(); + if ((clean || scanRoutes) && !maven) { + // in clean mode: remove all DEPS first + for (Path file : updateTargets) { + if (clean) { + updateJBangSource(file); + } + } } if (maven && this.runtime == null) { // Basic heuristic to determine if the project is a Quarkus or Spring Boot one. - String pomContent = new String(Files.readAllBytes(file)); + String pomContent = new String(Files.readAllBytes(firstTarget)); if (pomContent.contains("quarkus")) { runtime = RuntimeType.quarkus; } else if (pomContent.contains("spring-boot")) { @@ -93,7 +135,8 @@ public class DependencyUpdate extends DependencyList { @Override protected void outputGav(MavenGav gav, int index, int total) { try { - boolean maven = "pom.xml".equals(file.getFileName().toString()); + Path firstTarget = updateTargets.get(0); + boolean maven = "pom.xml".equals(firstTarget.getFileName().toString()); if (maven) { outputGavMaven(gav, index, total); } else { @@ -109,7 +152,9 @@ public class DependencyUpdate extends DependencyList { boolean last = total - index <= 1; if (last) { - updateMavenSource(); + for (Path file : updateTargets) { + updateMavenSource(file); + } } } @@ -127,11 +172,17 @@ public class DependencyUpdate extends DependencyList { } boolean last = total - index <= 1; if (last) { - updateJBangSource(); + for (Path file : updateTargets) { + if (scanRoutes) { + syncJBangSource(file); + } else { + updateJBangSource(file); + } + } } } - private void updateJBangSource() { + private void updateJBangSource(Path file) { try { List<String> lines = Files.readAllLines(file); List<String> answer = new ArrayList<>(); @@ -155,7 +206,7 @@ public class DependencyUpdate extends DependencyList { } // add after shebang in top if (pos == -1) { - if (answer.get(0).trim().startsWith("///usr/bin/env jbang")) { + if (!answer.isEmpty() && answer.get(0).trim().startsWith("///usr/bin/env jbang")) { pos = 1; } } @@ -164,8 +215,9 @@ public class DependencyUpdate extends DependencyList { } // reverse collection as we insert pos based - Collections.reverse(deps); - for (String dep : deps) { + List<String> depsToInsert = new ArrayList<>(deps); + Collections.reverse(depsToInsert); + for (String dep : depsToInsert) { // is this XML or YAML String ext = FileUtil.onlyExt(file.getFileName().toString(), true); if ("yaml".equals(ext)) { @@ -181,7 +233,124 @@ public class DependencyUpdate extends DependencyList { } } - private void updateMavenSource() throws Exception { + /** + * Syncs JBang source file dependencies: preserves non-Camel //DEPS, replaces Camel //DEPS with the resolved set. + */ + private void syncJBangSource(Path file) { + try { + List<String> lines = Files.readAllLines(file); + List<String> answer = new ArrayList<>(); + + // collect non-Camel DEPS and find insertion position + List<String> nonCamelDeps = new ArrayList<>(); + int pos = -1; + for (int i = 0; i < lines.size(); i++) { + String o = lines.get(i); + // remove leading comment chars to inspect + String l = o.trim(); + while (l.startsWith("#")) { + l = l.substring(1); + } + if (l.startsWith("//DEPS ")) { + if (pos == -1) { + pos = i; + } + // check if this is a Camel dependency + String depPart = l.substring("//DEPS ".length()).trim(); + if (!isCamelDependency(depPart)) { + nonCamelDeps.add(o); + } + } else { + answer.add(o); + } + } + // add after shebang in top + if (pos == -1) { + if (!answer.isEmpty() && answer.get(0).trim().startsWith("///usr/bin/env jbang")) { + pos = 1; + } + } + if (pos == -1) { + pos = 0; + } + + // build combined deps: Camel deps (from resolved) + non-Camel deps (preserved) + Set<String> seen = new LinkedHashSet<>(); + List<String> allDeps = new ArrayList<>(); + + // add resolved Camel deps first + for (String dep : deps) { + if (seen.add(dep)) { + allDeps.add(dep); + } + } + // add preserved non-Camel deps + for (String dep : nonCamelDeps) { + if (seen.add(dep)) { + allDeps.add(dep); + } + } + + // reverse collection as we insert pos based + Collections.reverse(allDeps); + for (String dep : allDeps) { + // is this XML or YAML + String ext = FileUtil.onlyExt(file.getFileName().toString(), true); + if ("yaml".equals(ext) && !dep.startsWith("#")) { + dep = "#" + dep; + } + answer.add(pos, dep); + } + + String text = String.join(System.lineSeparator(), answer); + Files.writeString(file, text); + } catch (Exception e) { + printer().printErr("Error updating source file: " + file + " due to: " + e.getMessage()); + } + } + + /** + * Strips Camel //DEPS lines from a JBang-style source file (in-place), preserving non-Camel //DEPS. This is used in + * scan-routes mode to prevent stale Camel //DEPS from being picked up by the export pipeline. + */ + private void stripJBangDeps(Path file) { + try { + List<String> lines = Files.readAllLines(file); + List<String> answer = new ArrayList<>(); + for (String line : lines) { + String l = line.trim(); + while (l.startsWith("#")) { + l = l.substring(1); + } + if (l.startsWith("//DEPS ")) { + String depPart = l.substring("//DEPS ".length()).trim(); + if (isCamelDependency(depPart)) { + // skip Camel deps — they will be re-added from route resolution + continue; + } + } + answer.add(line); + } + String text = String.join(System.lineSeparator(), answer); + Files.writeString(file, text); + } catch (Exception e) { + printer().printErr("Error stripping //DEPS from: " + file + " due to: " + e.getMessage()); + } + } + + /** + * Check if a dependency GAV string is a Camel dependency (org.apache.camel group). + */ + private static boolean isCamelDependency(String dep) { + // handle @pom suffix + String d = dep; + if (d.endsWith("@pom")) { + d = d.substring(0, d.length() - 4); + } + return d.startsWith("org.apache.camel:"); + } + + private void updateMavenSource(Path file) throws Exception { List<MavenGav> existingGavs = new ArrayList<>(); Node camelClone = null; @@ -270,62 +439,265 @@ public class DependencyUpdate extends DependencyList { } // sort the new JARs being added updates.sort(mavenGavComparator()); - List<MavenGav> toBeUpdated = new ArrayList<>(); - int changes = 0; - for (MavenGav update : updates) { - if (!existingGavs.contains(update)) { - toBeUpdated.add(update); - changes++; + + if (scanRoutes) { + // in scan-routes mode, sync Camel deps: add missing, remove unused + syncMavenSource(file, dom, existingGavs, updates); + } else { + // default mode: only add new deps + addMavenDeps(file, existingGavs, updates, targetLineNumber); + } + } else { + outPrinter().println("pom.xml not found " + pom.toAbsolutePath()); + } + } + + private void addMavenDeps(Path file, List<MavenGav> existingGavs, List<MavenGav> updates, int targetLineNumber) + throws Exception { + List<MavenGav> toBeUpdated = new ArrayList<>(); + int changes = 0; + for (MavenGav update : updates) { + if (!existingGavs.contains(update)) { + toBeUpdated.add(update); + changes++; + } + } + + if (changes > 0) { + writeMavenDeps(file, toBeUpdated, targetLineNumber); + if (changes > 1) { + outPrinter().println("Updating pom.xml with " + changes + " dependencies added"); + } else { + outPrinter().println("Updating pom.xml with 1 dependency added"); + } + } else { + outPrinter().println("No updates to pom.xml"); + } + } + + private void syncMavenSource( + Path file, Document dom, List<MavenGav> existingGavs, List<MavenGav> resolvedGavs) + throws Exception { + + // determine which existing Camel deps are no longer needed + Set<String> resolvedGAs = new LinkedHashSet<>(); + for (MavenGav gav : resolvedGavs) { + resolvedGAs.add(gav.getGroupId() + ":" + gav.getArtifactId()); + } + Set<String> existingGAs = new LinkedHashSet<>(); + for (MavenGav gav : existingGavs) { + existingGAs.add(gav.getGroupId() + ":" + gav.getArtifactId()); + } + + // find Camel deps to remove (exist in pom but not in resolved set) + List<String> toRemove = new ArrayList<>(); + for (MavenGav gav : existingGavs) { + String ga = gav.getGroupId() + ":" + gav.getArtifactId(); + boolean isCamel = gav.getGroupId().startsWith("org.apache.camel"); + if (isCamel && !resolvedGAs.contains(ga)) { + // skip BOM entries + if (!"camel-bom".equals(gav.getArtifactId()) + && !"camel-spring-boot-bom".equals(gav.getArtifactId())) { + toRemove.add(ga); } } + } - if (changes > 0) { - // respect indent from existing GAVs - String line = IOHelper.loadTextLine(Files.newInputStream(file), targetLineNumber); - line = StringHelper.before(line, "<"); - int indent = StringHelper.countChar(line, ' '); - String pad = Strings.repeat(" ", indent); - line = IOHelper.loadTextLine(Files.newInputStream(file), targetLineNumber - 1); - line = StringHelper.before(line, "<"); - int indent2 = StringHelper.countChar(line, ' '); - String pad2 = Strings.repeat(" ", indent2); - - // build GAVs to be added to pom.xml - StringJoiner sj = new StringJoiner(""); - for (MavenGav gav : toBeUpdated) { - sj.add("\n").add(pad).add("<dependency>\n"); - sj.add(pad2).add("<groupId>" + gav.getGroupId() + "</groupId>\n"); - sj.add(pad2).add("<artifactId>" + gav.getArtifactId() + "</artifactId>\n"); - if (gav.getVersion() != null) { - sj.add(pad2).add("<version>" + gav.getVersion() + "</version>\n"); - } - if (gav.getScope() != null) { - sj.add(pad2).add("<scope>" + gav.getScope() + "</scope>\n"); - } - sj.add(pad).add("</dependency>"); + // find Camel deps to add (in resolved but not in pom) + List<MavenGav> toAdd = new ArrayList<>(); + for (MavenGav gav : resolvedGavs) { + String ga = gav.getGroupId() + ":" + gav.getArtifactId(); + if (!existingGAs.contains(ga)) { + toAdd.add(gav); + } + } + + int added = toAdd.size(); + int removed = toRemove.size(); + + if (added == 0 && removed == 0) { + outPrinter().println("No updates to pom.xml"); + return; + } + + // process file: remove unused Camel deps and add new ones + String content = IOHelper.loadText(Files.newInputStream(file)); + String[] lines = content.split("\n"); + + if (removed > 0) { + content = removeMavenDeps(lines, dom, toRemove); + } + + if (added > 0) { + // re-parse to get updated line numbers after removals + if (removed > 0) { + lines = content.split("\n"); + Document updatedDom = XmlLineNumberParser.parseXml( + new java.io.ByteArrayInputStream(content.getBytes(java.nio.charset.StandardCharsets.UTF_8))); + int insertLine = findLastCamelDependencyLine(updatedDom); + if (insertLine > 0) { + content = insertMavenDeps(lines, toAdd, insertLine); + } + } else { + int insertLine = findLastCamelDependencyLine(dom); + if (insertLine > 0) { + content = insertMavenDeps(lines, toAdd, insertLine); + } + } + } + + Files.writeString(file, content); + + StringBuilder msg = new StringBuilder("Updating pom.xml with "); + if (added > 0) { + msg.append(added).append(added == 1 ? " dependency added" : " dependencies added"); + } + if (removed > 0) { + if (added > 0) { + msg.append(" and "); + } + msg.append(removed).append(removed == 1 ? " dependency removed" : " dependencies removed"); + } + outPrinter().println(msg.toString()); + } + + private int findLastCamelDependencyLine(Document dom) { + int targetLineNumber = -1; + NodeList nl = dom.getElementsByTagName("dependency"); + for (int i = 0; i < nl.getLength(); i++) { + Element node = (Element) nl.item(i); + String p = node.getParentNode().getNodeName(); + String p2 = node.getParentNode().getParentNode().getNodeName(); + boolean accept = ("dependencyManagement".equals(p2) || "project".equals(p2)) && (p.equals("dependencies")); + if (!accept) { + continue; + } + String g = node.getElementsByTagName("groupId").item(0).getTextContent(); + if (g.startsWith("org.apache.camel")) { + String num = (String) node.getUserData(XmlLineNumberParser.LINE_NUMBER_END); + if (num != null) { + targetLineNumber = Integer.parseInt(num); } + } + } + return targetLineNumber; + } - StringJoiner out = new StringJoiner("\n"); - String[] lines = IOHelper.loadText(Files.newInputStream(file)).split("\n"); - for (int i = 0; i < lines.length; i++) { - String txt = lines[i]; - out.add(txt); - if (i == targetLineNumber - 1) { - out.add(sj.toString()); - } + private String removeMavenDeps(String[] lines, Document dom, List<String> toRemove) { + // find line ranges of dependencies to remove + List<int[]> rangesToRemove = new ArrayList<>(); + NodeList nl = dom.getElementsByTagName("dependency"); + for (int i = 0; i < nl.getLength(); i++) { + Element node = (Element) nl.item(i); + String p = node.getParentNode().getNodeName(); + String p2 = node.getParentNode().getParentNode().getNodeName(); + boolean accept = ("dependencyManagement".equals(p2) || "project".equals(p2)) && (p.equals("dependencies")); + if (!accept) { + continue; + } + String g = node.getElementsByTagName("groupId").item(0).getTextContent(); + String a = node.getElementsByTagName("artifactId").item(0).getTextContent(); + String ga = g + ":" + a; + if (toRemove.contains(ga)) { + String startNum = (String) node.getUserData(XmlLineNumberParser.LINE_NUMBER); + String endNum = (String) node.getUserData(XmlLineNumberParser.LINE_NUMBER_END); + if (startNum != null && endNum != null) { + rangesToRemove.add(new int[] { Integer.parseInt(startNum), Integer.parseInt(endNum) }); } - if (changes > 1) { - outPrinter().println("Updating pom.xml with " + changes + " dependencies added"); - } else { - outPrinter().println("Updating pom.xml with 1 dependency added"); + } + } + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.length; i++) { + int lineNum = i + 1; // 1-based + boolean skip = false; + for (int[] range : rangesToRemove) { + if (lineNum >= range[0] && lineNum <= range[1]) { + skip = true; + break; } - Files.writeString(file, out.toString()); - } else { - outPrinter().println("No updates to pom.xml"); } - } else { - outPrinter().println("pom.xml not found " + pom.toAbsolutePath()); + if (!skip) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(lines[i]); + } + } + return sb.toString(); + } + + private String insertMavenDeps(String[] lines, List<MavenGav> toAdd, int targetLineNumber) { + // respect indent from existing lines + String line = lines[targetLineNumber - 1]; + String beforeTag = StringHelper.before(line, "<"); + int indent = beforeTag != null ? StringHelper.countChar(beforeTag, ' ') : 8; + String pad = Strings.repeat(" ", indent); + String line2 = targetLineNumber >= 2 ? lines[targetLineNumber - 2] : line; + String beforeTag2 = StringHelper.before(line2, "<"); + int indent2 = beforeTag2 != null ? StringHelper.countChar(beforeTag2, ' ') : indent + 4; + String pad2 = Strings.repeat(" ", indent2); + + StringJoiner sj = new StringJoiner(""); + for (MavenGav gav : toAdd) { + sj.add("\n").add(pad).add("<dependency>\n"); + sj.add(pad2).add("<groupId>" + gav.getGroupId() + "</groupId>\n"); + sj.add(pad2).add("<artifactId>" + gav.getArtifactId() + "</artifactId>\n"); + if (gav.getVersion() != null) { + sj.add(pad2).add("<version>" + gav.getVersion() + "</version>\n"); + } + if (gav.getScope() != null) { + sj.add(pad2).add("<scope>" + gav.getScope() + "</scope>\n"); + } + sj.add(pad).add("</dependency>"); + } + + StringJoiner out = new StringJoiner("\n"); + for (int i = 0; i < lines.length; i++) { + out.add(lines[i]); + if (i == targetLineNumber - 1) { + out.add(sj.toString()); + } + } + return out.toString(); + } + + private void writeMavenDeps(Path file, List<MavenGav> toBeUpdated, int targetLineNumber) throws Exception { + // respect indent from existing GAVs + String line = IOHelper.loadTextLine(Files.newInputStream(file), targetLineNumber); + line = StringHelper.before(line, "<"); + int indent = StringHelper.countChar(line, ' '); + String pad = Strings.repeat(" ", indent); + line = IOHelper.loadTextLine(Files.newInputStream(file), targetLineNumber - 1); + line = StringHelper.before(line, "<"); + int indent2 = StringHelper.countChar(line, ' '); + String pad2 = Strings.repeat(" ", indent2); + + // build GAVs to be added to pom.xml + StringJoiner sj = new StringJoiner(""); + for (MavenGav gav : toBeUpdated) { + sj.add("\n").add(pad).add("<dependency>\n"); + sj.add(pad2).add("<groupId>" + gav.getGroupId() + "</groupId>\n"); + sj.add(pad2).add("<artifactId>" + gav.getArtifactId() + "</artifactId>\n"); + if (gav.getVersion() != null) { + sj.add(pad2).add("<version>" + gav.getVersion() + "</version>\n"); + } + if (gav.getScope() != null) { + sj.add(pad2).add("<scope>" + gav.getScope() + "</scope>\n"); + } + sj.add(pad).add("</dependency>"); + } + + StringJoiner out = new StringJoiner("\n"); + String[] lines = IOHelper.loadText(Files.newInputStream(file)).split("\n"); + for (int i = 0; i < lines.length; i++) { + String txt = lines[i]; + out.add(txt); + if (i == targetLineNumber - 1) { + out.add(sj.toString()); + } } + Files.writeString(file, out.toString()); } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdateJBangTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdateJBangTest.java new file mode 100644 index 000000000000..18305836f429 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/DependencyUpdateJBangTest.java @@ -0,0 +1,276 @@ +/* + * 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; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +import org.apache.camel.dsl.jbang.core.common.RuntimeType; +import org.apache.camel.dsl.jbang.core.common.StringPrinter; +import org.apache.camel.util.FileUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; + +class DependencyUpdateJBangTest extends CamelCommandBaseTestSupport { + + private File workingDir; + + @BeforeEach + @Override + public void setup() throws Exception { + super.setup(); + Path base = Paths.get("target"); + workingDir = Files.createTempDirectory(base, "camel-dependency-update-jbang-tests").toFile(); + } + + @AfterEach + void end() { + FileUtil.removeDir(workingDir); + } + + @ParameterizedTest + @MethodSource("runtimeProvider") + void shouldScanRoutesAddDependency(RuntimeType rt) throws Exception { + prepareMavenProject(rt); + + // add arangodb to the route + addArangodbToCamelFile(); + + // run with --scan-routes + StringPrinter updatePrinter = new StringPrinter(); + DependencyUpdate command = new DependencyUpdate(new CamelJBangMain().withPrinter(updatePrinter)); + CommandLine.populateCommand(command, + "--scan-routes", + "--dir=" + workingDir, + new File(workingDir, "pom.xml").getAbsolutePath()); + int exit = command.doCall(); + Assertions.assertEquals(0, exit, updatePrinter.getLines().toString()); + + String pomContent = Files.readString(new File(workingDir, "pom.xml").toPath()); + switch (rt) { + case quarkus: + assertThat(pomContent).contains("camel-quarkus-arangodb"); + break; + case springBoot: + assertThat(pomContent).contains("camel-arangodb-starter"); + break; + case main: + assertThat(pomContent).contains("camel-arangodb<"); + break; + } + } + + @Test + void shouldScanRoutesRemoveUnusedMavenDependency() throws Exception { + prepareMavenProject(RuntimeType.main); + + // manually add camel-kafka to the pom.xml (which is not used by the route) + File pomFile = new File(workingDir, "pom.xml"); + String pomContent = Files.readString(pomFile.toPath()); + // insert before the closing </dependencies> tag in the main dependencies section + String kafkaDep = " <dependency>\n" + + " <groupId>org.apache.camel</groupId>\n" + + " <artifactId>camel-kafka</artifactId>\n" + + " </dependency>\n "; + pomContent = pomContent.replaceFirst("(</dependencies>)", kafkaDep + "$1"); + Files.writeString(pomFile.toPath(), pomContent); + + // verify kafka is there + assertThat(Files.readString(pomFile.toPath())).contains("camel-kafka"); + + // run with --scan-routes to sync + StringPrinter updatePrinter = new StringPrinter(); + DependencyUpdate command = new DependencyUpdate(new CamelJBangMain().withPrinter(updatePrinter)); + CommandLine.populateCommand(command, + "--scan-routes", + "--dir=" + workingDir, + pomFile.getAbsolutePath()); + int exit = command.doCall(); + Assertions.assertEquals(0, exit, updatePrinter.getLines().toString()); + + // camel-kafka should be removed (not used in routes) + String updatedPom = Files.readString(pomFile.toPath()); + assertThat(updatedPom).doesNotContain("camel-kafka"); + // should still have camel-yaml-dsl (used for the route file) + assertThat(updatedPom).contains("camel-yaml-dsl"); + } + + @Test + void shouldPreserveNonCamelDepsInJBangFile() throws Exception { + // create a Java file with mixed Camel and non-Camel //DEPS + Path javaFile = createFile("MyRoute.java", """ + ///usr/bin/env jbang + //DEPS org.apache.camel:camel-bom:4.13.0@pom + //DEPS org.apache.camel:camel-kafka + //DEPS com.google.guava:guava:33.0.0-jre + //DEPS io.netty:netty-all:4.1.100.Final + import org.apache.camel.builder.RouteBuilder; + public class MyRoute extends RouteBuilder { + public void configure() { + from("timer:tick").to("log:info"); + } + } + """); + + // run scan-routes with just the Java file (no separate route files) + StringPrinter p = new StringPrinter(); + DependencyUpdate command = new DependencyUpdate(new CamelJBangMain().withPrinter(p)); + CommandLine.populateCommand(command, + "--scan-routes", + "--dir=" + workingDir, + javaFile.toAbsolutePath().toString()); + int exit = command.doCall(); + Assertions.assertEquals(0, exit, p.getLines().toString()); + + String content = Files.readString(javaFile); + // non-Camel deps should be preserved + assertThat(content).contains("//DEPS com.google.guava:guava:33.0.0-jre"); + assertThat(content).contains("//DEPS io.netty:netty-all:4.1.100.Final"); + // should have camel-bom (resolved fresh) + assertThat(content).contains("//DEPS org.apache.camel:camel-bom:"); + // kafka should be removed (not used in the route definition) + assertThat(content).doesNotContain("camel-kafka"); + } + + @Test + void shouldScanRoutesIdempotentJBang() throws Exception { + Path javaFile = createFile("MyRoute.java", """ + ///usr/bin/env jbang + //DEPS com.google.guava:guava:33.0.0-jre + import org.apache.camel.builder.RouteBuilder; + public class MyRoute extends RouteBuilder { + public void configure() { + from("timer:tick").to("log:info"); + } + } + """); + + // run scan-routes twice + for (int run = 0; run < 2; run++) { + StringPrinter p = new StringPrinter(); + DependencyUpdate command = new DependencyUpdate(new CamelJBangMain().withPrinter(p)); + CommandLine.populateCommand(command, + "--scan-routes", + "--dir=" + workingDir, + javaFile.toAbsolutePath().toString()); + int exit = command.doCall(); + Assertions.assertEquals(0, exit, p.getLines().toString()); + } + + String content = Files.readString(javaFile); + // should have exactly one camel-bom line + long bomCount = content.lines().filter(l -> l.contains("camel-bom")).count(); + assertThat(bomCount).isEqualTo(1); + // non-Camel deps should still be there + assertThat(content).contains("//DEPS com.google.guava:guava:33.0.0-jre"); + } + + @Test + void shouldUpdateMultipleJBangFiles() throws Exception { + Path java1 = createFile("MyRoute1.java", """ + ///usr/bin/env jbang + import org.apache.camel.builder.RouteBuilder; + public class MyRoute1 extends RouteBuilder { + public void configure() { + from("timer:tick").to("log:info"); + } + } + """); + Path java2 = createFile("MyRoute2.java", """ + ///usr/bin/env jbang + import org.apache.camel.builder.RouteBuilder; + public class MyRoute2 extends RouteBuilder { + public void configure() { + from("timer:tick").to("log:info"); + } + } + """); + + DependencyUpdate command = new DependencyUpdate(new CamelJBangMain().withPrinter(printer)); + CommandLine.populateCommand(command, + "--dir=" + workingDir, + java1.toAbsolutePath().toString(), + java2.toAbsolutePath().toString()); + + int exit = command.doCall(); + Assertions.assertEquals(0, exit, printer.getLines().toString()); + + // both files should have //DEPS + String content1 = Files.readString(java1); + String content2 = Files.readString(java2); + assertThat(content1).contains("//DEPS org.apache.camel:camel-bom:"); + assertThat(content2).contains("//DEPS org.apache.camel:camel-bom:"); + } + + private void prepareMavenProject(RuntimeType rt) throws Exception { + StringPrinter initPrinter = new StringPrinter(); + Init initCommand = new Init(new CamelJBangMain().withPrinter(initPrinter)); + String camelFilePath = new File(workingDir, "my.camel.yaml").getAbsolutePath(); + CommandLine.populateCommand(initCommand, camelFilePath); + Assertions.assertEquals(0, initCommand.doCall(), initPrinter.getLines().toString()); + + StringPrinter exportPrinter = new StringPrinter(); + Export exportCommand = new Export(new CamelJBangMain().withPrinter(exportPrinter)); + CommandLine.populateCommand(exportCommand, + "--gav=examples:route:1.0.0", + "--dir=" + workingDir, + "--camel-version=4.13.0", + "--runtime=" + rt.runtime(), + camelFilePath); + Assertions.assertEquals(0, exportCommand.doCall(), exportPrinter.getLines().toString()); + } + + private void addArangodbToCamelFile() throws Exception { + File camelFile = new File(workingDir, "src/main/resources/camel/my.camel.yaml"); + String content = Files.readString(camelFile.toPath()); + content = content.replace("- log: ${body}", """ + - to: + uri: arangodb + parameters: + database: demo + """); + Files.writeString(camelFile.toPath(), content); + } + + private Path createFile(String name, String content) throws Exception { + Path file = workingDir.toPath().resolve(name); + Files.writeString(file, content); + return file; + } + + private static Stream<Arguments> runtimeProvider() { + Stream.Builder<Arguments> builder = Stream.builder(); + builder.add(Arguments.of(RuntimeType.quarkus)); + if (Runtime.version().feature() >= 21) { + builder.add(Arguments.of(RuntimeType.springBoot)); + } + builder.add(Arguments.of(RuntimeType.main)); + return builder.build(); + } + +}
