This is an automated email from the ASF dual-hosted git repository. christophd pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel.git
commit 2e8ab758626a484fe79a126998494896b8b819a7 Author: Christoph Deppisch <[email protected]> AuthorDate: Wed Nov 11 21:45:27 2026 +0100 CAMEL-26764: Let plugins declare which commands they contribute to Add an 'appliesTo' mechanism so each plugin declares the commands it participates in beyond its own (e.g. export, run). CliExportContext now passes the target command to getActivePlugins, so plugins unrelated to the current command are skipped entirely — avoiding expensive Maven resolution for plugins that don't contribute exporters or run customizers. Old plugin configurations for known PluginType entries are auto-migrated with a warning when the appliesTo field is missing. Co-Authored-By: Claude Opus 4.6 <[email protected]> --- .../dsl/jbang/core/commands/CliExportContext.java | 8 +- .../apache/camel/dsl/jbang/core/commands/Run.java | 2 +- .../dsl/jbang/core/commands/plugin/PluginAdd.java | 9 + .../dsl/jbang/core/common/CamelJBangPlugin.java | 7 + .../camel/dsl/jbang/core/common/PluginHelper.java | 31 +- .../camel/dsl/jbang/core/common/PluginType.java | 11 +- .../dsl/jbang/core/common/PluginFilteringTest.java | 435 +++++++++++++++++++++ .../dsl/jbang/core/commands/test/TestPlugin.java | 2 +- 8 files changed, 496 insertions(+), 9 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliExportContext.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliExportContext.java index a633f298ca0f..4d4767155fb5 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliExportContext.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CliExportContext.java @@ -42,7 +42,7 @@ public final class CliExportContext { * @param yes whether to answer yes to the confirmations */ public static ExportContext create(CamelJBangMain main, Printer printer, boolean yes) { - return configure(new ExportContext(), main, printer, yes); + return configure(new ExportContext(), main, printer, yes, "export"); } /** @@ -52,12 +52,14 @@ public final class CliExportContext { * @param main the CLI * @param printer where the export prints * @param yes whether to answer yes to the confirmations + * @param target the command name used to filter plugins (e.g. "run" or "export") * @return the context */ - public static <T extends ExportContext> T configure(T context, CamelJBangMain main, Printer printer, boolean yes) { + public static <T extends ExportContext> T configure( + T context, CamelJBangMain main, Printer printer, boolean yes, String target) { context.withPrinter(printer) .withPlugins(repos -> { - Collection<Plugin> plugins = PluginHelper.getActivePlugins(main, repos).values(); + Collection<Plugin> plugins = PluginHelper.getActivePlugins(main, repos, target).values(); return new ExportContext.ActivePlugins( plugins.stream().map(Plugin::getRunCustomizer).flatMap(Optional::stream).toList(), plugins.stream().map(Plugin::getExporter).flatMap(Optional::stream).toList()); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java index 93c4caf652dd..77ad57b08a54 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Run.java @@ -937,7 +937,7 @@ public class Run extends CamelCommand implements CommandLifecycle { * what a run with Maven does set a process starter, so they run neither Maven nor the application. */ protected RunContext runContext() { - return CliExportContext.configure(new RunContext(), getMain(), printer(), false) + return CliExportContext.configure(new RunContext(), getMain(), printer(), false, "run") .withFailureExitCode(new CliExecutionExceptionHandler(getMain())::getExitCode) // a YAML route file that did not load in dev mode: the validator's report (CAMEL-24851) .withReloadFailureListener(YamlLoadFailureReport::logIfYamlLoadFailure) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/plugin/PluginAdd.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/plugin/PluginAdd.java index 501b33facf5a..7e46197ac116 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/plugin/PluginAdd.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/plugin/PluginAdd.java @@ -16,6 +16,7 @@ */ package org.apache.camel.dsl.jbang.core.commands.plugin; +import java.util.Arrays; import java.util.Optional; import org.aesh.command.CommandDefinition; @@ -39,6 +40,7 @@ import org.apache.camel.dsl.jbang.core.common.PluginVersionResolver.Unavailable; import org.apache.camel.main.download.DependencyDownloader; import org.apache.camel.main.download.MavenDependencyDownloader; import org.apache.camel.tooling.maven.MavenGav; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; @CommandDefinition(name = "add", description = "Add new plugin", generateHelp = true, @@ -84,6 +86,7 @@ public class PluginAdd extends PluginBaseCommand { JsonObject pluginConfig = loadConfig(); JsonObject plugins = pluginConfig.getMap("plugins"); + String[] appliesTo = null; Optional<PluginType> camelPlugin = PluginType.findByName(name); if (camelPlugin.isPresent()) { if (command == null) { @@ -96,6 +99,7 @@ public class PluginAdd extends PluginBaseCommand { if (firstVersion == null) { firstVersion = camelPlugin.get().getFirstVersion(); } + appliesTo = camelPlugin.get().getAppliesTo(); } else { Optional<JsonObject> known = PluginHelper.findKnownPlugin(name); if (known.isPresent()) { @@ -139,6 +143,11 @@ public class PluginAdd extends PluginBaseCommand { } plugin.put("description", description != null ? description : "Plugin %s called with command %s".formatted(name, command)); + if (appliesTo != null && appliesTo.length > 0) { + JsonArray appliesToArray = new JsonArray(); + appliesToArray.addAll(Arrays.asList(appliesTo)); + plugin.put("appliesTo", appliesToArray); + } boolean explicit = gav != null || isVersionGiven(); if (gav == null && (groupId != null && artifactId != null)) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java index acfd1beae4c9..51f625877d57 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelJBangPlugin.java @@ -40,4 +40,11 @@ public @interface CamelJBangPlugin { */ String firstVersion(); + /** + * The commands this plugin contributes to beyond its own command. For example, a plugin that provides a + * {@link org.apache.camel.dsl.jbang.run.common.PluginExporter} should declare {@code appliesTo = {"export"}} so it + * is loaded during {@code camel export}. Empty means the plugin is only loaded for its own command. + */ + String[] appliesTo() default {}; + } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java index c615dd1a921d..4282cfabdba1 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginHelper.java @@ -361,9 +361,27 @@ public final class PluginHelper { final String firstVersion = properties.getOrDefault("firstVersion", "").toString(); final String gav = properties.getOrDefault("dependency", "").toString(); + // auto-migrate old config entries for known plugins that now declare appliesTo + if (properties.get("appliesTo") == null) { + Optional<PluginType> knownType = PluginType.findByName(name); + if (knownType.isPresent() && knownType.get().getAppliesTo().length > 0) { + JsonArray appliesToArray = new JsonArray(); + appliesToArray.addAll(Arrays.asList(knownType.get().getAppliesTo())); + properties.put("appliesTo", appliesToArray); + configDirty = true; + main.getOut().printWarn( + ("Plugin '%s' configuration updated with 'appliesTo' filter. " + + "Run 'camel plugin add %s' to refresh the full configuration.") + .formatted(name, name)); + } + } + // skip plugins that don't match the target command to avoid unnecessary downloads if (target != null && !"shell".equals(target) && !target.equals(command)) { - continue; + Object appliesToObj = properties.get("appliesTo"); + if (!(appliesToObj instanceof Collection<?> appliesToList) || !appliesToList.contains(target)) { + continue; + } } // check if plugin version can be loaded (cannot if we use an older camel version than the plugin) @@ -817,6 +835,12 @@ public final class PluginHelper { newPlugin.put("command", pluginType.getCommand()); newPlugin.put("description", pluginType.getDescription()); newPlugin.put("firstVersion", pluginType.getFirstVersion()); + String[] appliesTo = pluginType.getAppliesTo(); + if (appliesTo.length > 0) { + JsonArray appliesToArray = new JsonArray(); + appliesToArray.addAll(Arrays.asList(appliesTo)); + newPlugin.put("appliesTo", appliesToArray); + } plugins.putIfAbsent(pluginType.getName(), newPlugin); PluginHelper.savePluginConfig(pluginConfig); @@ -939,7 +963,10 @@ public final class PluginHelper { // Only load the plugin if the command-line is calling this plugin or if target is null (shell mode) if (target != null && !"shell".equals(target) && !target.equals(command)) { - return false; + CamelJBangPlugin ann = pluginClass.getAnnotation(CamelJBangPlugin.class); + if (ann == null || !Arrays.asList(ann.appliesTo()).contains(target)) { + return false; + } } // Check version compatibility if needed diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginType.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginType.java index 79e7d7bbf59f..06ad7c5b13cc 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginType.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/PluginType.java @@ -27,7 +27,7 @@ public enum PluginType { KUBERNETES("kubernetes", "kubernetes", "Run Camel applications on Kubernetes", "4.8.0", null, "ASF"), GENERATE("generate", "generate", "Generate code such as DTOs", "4.8.0", null, "ASF"), EDIT("edit", "edit", "Edit Camel files with suggestions", "4.12.0", null, "ASF"), - TEST("test", "test", "Manage tests for Camel applications", "4.14.0", null, "ASF"), + TEST("test", "test", "Manage tests for Camel applications", "4.14.0", null, "ASF", "export"), VALIDATE("validate", "validate", "Validate Camel routes", "4.18.0", null, "ASF"), MCP("mcp", "mcp", "MCP server for AI coding assistants", "4.21.0", null, "ASF"), TUI("tui", "tui", "Terminal UI for developing and understanding Camel integrations", "4.20.0", null, "ASF"); @@ -38,14 +38,17 @@ public enum PluginType { private final String firstVersion; private final String repos; private final String vendor; + private final String[] appliesTo; - PluginType(String name, String command, String description, String firstVersion, String repos, String vendor) { + PluginType(String name, String command, String description, String firstVersion, String repos, String vendor, + String... appliesTo) { this.name = name; this.command = command; this.description = description; this.firstVersion = firstVersion; this.repos = repos; this.vendor = vendor; + this.appliesTo = appliesTo; } public static Optional<PluginType> findByName(String name) { @@ -77,4 +80,8 @@ public enum PluginType { public String getVendor() { return vendor; } + + public String[] getAppliesTo() { + return appliesTo; + } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java new file mode 100644 index 000000000000..63e7d5412d95 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginFilteringTest.java @@ -0,0 +1,435 @@ +/* + * 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.common; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.commands.CliExportContext; +import org.apache.camel.dsl.jbang.run.common.StringPrinter; +import org.apache.camel.dsl.jbang.run.export.ExportContext; +import org.apache.camel.util.json.JsonArray; +import org.apache.camel.util.json.JsonObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests that plugins not relevant to the current command are skipped entirely — not downloaded, not customized. + * <p> + * Each test configures cached fake plugins (so no Maven download is needed). To detect whether a plugin was loaded or + * skipped, some tests use a "broken" plugin entry whose cache points to a non-existent jar: if the resolution code + * attempts to load the broken plugin, it will fail with a {@link CliException}. If the plugin is properly filtered out, + * the resolution succeeds. + */ +class PluginFilteringTest { + + @TempDir + Path tempDir; + + private Path previousHome; + private String camelVersion; + + @BeforeEach + void setup() { + previousHome = CommandLineHelper.getHomeDir(); + CommandLineHelper.useHomeDir(tempDir.toString()); + camelVersion = new org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion(); + } + + @AfterEach + void restoreHome() { + CommandLineHelper.useHomeDir(previousHome.toString()); + } + + // ---- Target-based filtering in getActivePlugins ---- + + @Test + void pluginNotMatchingTargetIsNotLoaded() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "alpha"); + + assertThat(plugins).containsKey("alpha") + .doesNotContainKey("beta"); + } + + @Test + void nullTargetLoadsAllPlugins() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, null); + + assertThat(plugins).containsKey("alpha") + .containsKey("beta"); + } + + @Test + void shellTargetLoadsAllPlugins() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "shell"); + + assertThat(plugins).containsKey("alpha") + .containsKey("beta"); + } + + // ---- The "run" command should not load unrelated plugins ---- + + @Test + void runCommandDoesNotLoadUnrelatedPlugin() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "run"); + + assertThat(plugins).doesNotContainKey("alpha") + .doesNotContainKey("beta"); + } + + // ---- The "export" command should not load unrelated plugins ---- + + @Test + void exportCommandDoesNotLoadUnrelatedPlugin() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); + + assertThat(plugins).doesNotContainKey("alpha") + .doesNotContainKey("beta"); + } + + // ---- A broken unrelated plugin should not break the "run" target ---- + + @Test + void brokenUnrelatedPluginDoesNotBreakTargetedResolution() throws Exception { + Path goodJar = tempDir.resolve("fake-good.jar"); + FakePluginJar.write(goodJar, "good"); + + writeConfig( + buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), + buildBrokenEntry("broken")); + + CamelJBangMain main = new CamelJBangMain(); + + // targeting "good" should skip the broken plugin entirely + assertThatCode(() -> PluginHelper.getActivePlugins(main, null, "good")) + .doesNotThrowAnyException(); + + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "good"); + assertThat(plugins).containsKey("good") + .doesNotContainKey("broken"); + } + + @Test + void brokenUnrelatedPluginBreaksNullTarget() throws Exception { + Path goodJar = tempDir.resolve("fake-good.jar"); + FakePluginJar.write(goodJar, "good"); + + writeConfig( + buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), + buildBrokenEntry("broken")); + + CamelJBangMain main = new CamelJBangMain(); + + // null target loads ALL plugins, so the broken one causes a failure + assertThatThrownBy(() -> PluginHelper.getActivePlugins(main, null, null)) + .isInstanceOf(CliException.class) + .hasMessageContaining("camel-jbang-plugin-broken not found"); + } + + // ---- appliesTo filtering ---- + + @Test + void pluginWithAppliesToIsLoadedForMatchingTarget() throws Exception { + Path jar = tempDir.resolve("fake-exporter.jar"); + FakePluginJar.write(jar, "exporter"); + + JsonObject entry = buildEntry("exporter", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + entry.put("appliesTo", jsonArray("export")); + writeConfig(entry); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); + + assertThat(plugins).containsKey("exporter"); + } + + @Test + void pluginWithAppliesToIsNotLoadedForNonMatchingTarget() throws Exception { + Path jar = tempDir.resolve("fake-exporter.jar"); + FakePluginJar.write(jar, "exporter"); + + JsonObject entry = buildEntry("exporter", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + entry.put("appliesTo", jsonArray("export")); + writeConfig(entry); + + CamelJBangMain main = new CamelJBangMain(); + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "run"); + + assertThat(plugins).doesNotContainKey("exporter"); + } + + @Test + void pluginWithAppliesToExportIsLoadedByCliExportContext() throws Exception { + Path jar = tempDir.resolve("fake-exporter.jar"); + FakePluginJar.write(jar, "exporter"); + + JsonObject entry = buildEntry("exporter", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + entry.put("appliesTo", jsonArray("export")); + writeConfig(entry); + + StringPrinter printer = new StringPrinter(); + CamelJBangMain main = new CamelJBangMain().withPrinter(printer); + + ExportContext context = CliExportContext.create(main, printer, true); + + assertThatCode(() -> context.getPlugins(null)) + .doesNotThrowAnyException(); + } + + // ---- auto-migration of old configs for known plugins ---- + + @Test + void oldConfigForKnownPluginIsAutoMigrated() throws Exception { + Path jar = tempDir.resolve("fake-test.jar"); + FakePluginJar.write(jar, "test"); + + // simulate old config: known plugin "test" without appliesTo + JsonObject entry = buildEntry("test", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + writeConfig(entry); + + StringPrinter printer = new StringPrinter(); + CamelJBangMain main = new CamelJBangMain().withPrinter(printer); + + // target "export" — without migration, "test" plugin would be skipped + Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, null, "export"); + assertThat(plugins).containsKey("test"); + + // verify the config was updated on disk + JsonObject savedConfig = PluginHelper.getPluginConfig(); + JsonObject savedPlugins = savedConfig.getMap("plugins"); + JsonObject savedPlugin = savedPlugins.getMap("test"); + assertThat(savedPlugin.get("appliesTo")) + .isInstanceOf(List.class) + .asList().contains("export"); + } + + @Test + void oldConfigForKnownPluginEmitsWarning() throws Exception { + Path jar = tempDir.resolve("fake-test.jar"); + FakePluginJar.write(jar, "test"); + + JsonObject entry = buildEntry("test", camelVersion, jar, Files.getLastModifiedTime(jar).toMillis()); + writeConfig(entry); + + StringPrinter printer = new StringPrinter(); + CamelJBangMain main = new CamelJBangMain().withPrinter(printer); + + PluginHelper.getActivePlugins(main, null, "export"); + + assertThat(printer.getOutput()) + .contains("appliesTo"); + } + + // ---- CliExportContext should not resolve unrelated plugins ---- + + @Test + void cliExportContextDoesNotResolveUnrelatedPlugins() throws Exception { + Path goodJar = tempDir.resolve("fake-good.jar"); + FakePluginJar.write(goodJar, "good"); + + writeConfig( + buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), + buildBrokenEntry("broken")); + + StringPrinter printer = new StringPrinter(); + CamelJBangMain main = new CamelJBangMain().withPrinter(printer); + + ExportContext context = CliExportContext.create(main, printer, true); + + // CliExportContext.create() calls getActivePlugins(main, repos) WITHOUT a target filter. + // This means it will attempt to resolve the broken plugin, causing a CliException. + // If the filtering were correct, the broken plugin would be skipped and this would succeed. + assertThatCode(() -> context.getPlugins(null)) + .as("CliExportContext should not resolve plugins unrelated to run/export, " + + "but it currently calls getActivePlugins without a target filter") + .doesNotThrowAnyException(); + } + + // ---- addPlugins should not customize unrelated plugins for "run" ---- + + @Test + void addPluginsForRunDoesNotCustomizeUnrelatedPlugin() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); + CommandLine commandLine = new CommandLine(main); + commandLine.addSubcommand("run", new CommandLine(new NoOpCommand())); + + PluginHelper.addPlugins(commandLine, main, "run", "foo.yaml"); + + // "run" is a plugin-consuming builtin, but neither plugin's command is "run", + // so they should be filtered out + assertThat(commandLine.getSubcommands()) + .doesNotContainKey("alpha") + .doesNotContainKey("beta"); + } + + @Test + void addPluginsForExportDoesNotCustomizeUnrelatedPlugin() throws Exception { + writeTwoPluginConfig("alpha", "beta"); + + CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); + CommandLine commandLine = new CommandLine(main); + commandLine.addSubcommand("export", new CommandLine(new NoOpCommand())); + + PluginHelper.addPlugins(commandLine, main, "export"); + + assertThat(commandLine.getSubcommands()) + .doesNotContainKey("alpha") + .doesNotContainKey("beta"); + } + + // ---- A broken unrelated plugin should not break addPlugins for "run" ---- + + @Test + void brokenUnrelatedPluginDoesNotBreakAddPluginsForRun() throws Exception { + Path goodJar = tempDir.resolve("fake-good.jar"); + FakePluginJar.write(goodJar, "good"); + + writeConfig( + buildEntry("good", camelVersion, goodJar, Files.getLastModifiedTime(goodJar).toMillis()), + buildBrokenEntry("broken")); + + CamelJBangMain main = new CamelJBangMain().withPrinter(new StringPrinter()); + CommandLine commandLine = new CommandLine(main); + commandLine.addSubcommand("run", new CommandLine(new NoOpCommand())); + + // addPlugins for "run" should skip the broken plugin since it doesn't match + assertThatCode(() -> PluginHelper.addPlugins(commandLine, main, "run", "foo.yaml")) + .doesNotThrowAnyException(); + } + + // ---- A built-in command like "version" or "ps" should not trigger plugin loading ---- + + @Test + void builtInCommandDoesNotTriggerPluginDiscovery() { + CamelJBangMain main = new CamelJBangMain(); + CommandLine cl = new CommandLine(main); + cl.addSubcommand("version", new CommandLine(new NoOpCommand())); + cl.addSubcommand("ps", new CommandLine(new NoOpCommand())); + cl.addSubcommand("get", new CommandLine(new NoOpCommand())); + cl.addSubcommand("config", new CommandLine(new NoOpCommand())); + cl.addSubcommand("catalog", new CommandLine(new NoOpCommand())); + + assertThat(PluginHelper.shouldDiscoverPlugins(cl, "version")).isFalse(); + assertThat(PluginHelper.shouldDiscoverPlugins(cl, "ps")).isFalse(); + assertThat(PluginHelper.shouldDiscoverPlugins(cl, "get", "bean")).isFalse(); + assertThat(PluginHelper.shouldDiscoverPlugins(cl, "config", "list")).isFalse(); + assertThat(PluginHelper.shouldDiscoverPlugins(cl, "catalog", "component")).isFalse(); + } + + // ---- Helpers ---- + + private void writeTwoPluginConfig(String pluginA, String pluginB) throws Exception { + Path jarA = tempDir.resolve("fake-" + pluginA + ".jar"); + FakePluginJar.write(jarA, pluginA); + Path jarB = tempDir.resolve("fake-" + pluginB + ".jar"); + FakePluginJar.write(jarB, pluginB); + + writeConfig( + buildEntry(pluginA, camelVersion, jarA, Files.getLastModifiedTime(jarA).toMillis()), + buildEntry(pluginB, camelVersion, jarB, Files.getLastModifiedTime(jarB).toMillis())); + } + + private void writeConfig(JsonObject... entries) throws Exception { + JsonObject plugins = new JsonObject(); + for (JsonObject entry : entries) { + plugins.put(entry.getString("name"), entry); + } + JsonObject config = new JsonObject(); + config.put("plugins", plugins); + Path userConfig = CommandLineHelper.getHomeDir().resolve(PluginHelper.PLUGIN_CONFIG); + Files.writeString(userConfig, config.toJson(), StandardOpenOption.CREATE); + } + + private JsonObject buildEntry(String name, String camelVersion, Path jar, long jarMtime) throws Exception { + JsonObject entry = new JsonObject(); + entry.put("name", name); + entry.put("command", name); + entry.put("description", "Fake plugin " + name); + entry.put("firstVersion", camelVersion); + + JsonObject jarEntry = new JsonObject(); + jarEntry.put("path", jar.toAbsolutePath().toString()); + jarEntry.put("size", Files.size(jar)); + jarEntry.put("mtime", jarMtime); + + JsonObject resolved = new JsonObject(); + resolved.put("camelVersion", camelVersion); + resolved.put("className", FakePluginJar.PLUGIN_CLASS); + List<JsonObject> cp = new ArrayList<>(); + cp.add(jarEntry); + resolved.put("classpath", cp); + entry.put("resolved", resolved); + return entry; + } + + /** + * Builds a plugin entry with no valid resolution path: no cache, no factory finder, no Maven GAV. If the resolution + * code attempts to load this plugin, it will fail with "not found". Used to verify that filtering properly skips + * unrelated plugins. + */ + private JsonObject buildBrokenEntry(String name) { + JsonObject entry = new JsonObject(); + entry.put("name", name); + entry.put("command", name); + entry.put("description", "Broken plugin that should not be loaded"); + entry.put("firstVersion", camelVersion); + return entry; + } + + private static JsonArray jsonArray(String... values) { + JsonArray array = new JsonArray(); + array.addAll(Arrays.asList(values)); + return array; + } + + @CommandLine.Command(name = "noop") + private static class NoOpCommand implements Runnable { + @Override + public void run() { + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-test/src/main/java/org/apache/camel/dsl/jbang/core/commands/test/TestPlugin.java b/dsl/camel-jbang/camel-jbang-plugin-test/src/main/java/org/apache/camel/dsl/jbang/core/commands/test/TestPlugin.java index a37f5046b861..64885e868cb0 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-test/src/main/java/org/apache/camel/dsl/jbang/core/commands/test/TestPlugin.java +++ b/dsl/camel-jbang/camel-jbang-plugin-test/src/main/java/org/apache/camel/dsl/jbang/core/commands/test/TestPlugin.java @@ -38,7 +38,7 @@ import org.citrusframework.jbang.cli.commands.ListTests; import org.citrusframework.jbang.cli.commands.Run; import picocli.CommandLine; -@CamelJBangPlugin(name = "camel-jbang-plugin-test", firstVersion = "4.14.0") +@CamelJBangPlugin(name = "camel-jbang-plugin-test", firstVersion = "4.14.0", appliesTo = { "export" }) public class TestPlugin implements Plugin { public static final String TEST_DIR = "test";
