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 1def6015b768 CAMEL-23335: camel-jbang - Lazy plugin discovery and 
resolved-classpath cache (#23129)
1def6015b768 is described below

commit 1def6015b768272a3837fa30f0bc45c252660b72
Author: Adriano Machado <[email protected]>
AuthorDate: Wed May 13 06:12:14 2026 -0400

    CAMEL-23335: camel-jbang - Lazy plugin discovery and resolved-classpath 
cache (#23129)
    
    Two related changes to remove per-invocation plugin overhead from `camel`:
    
    * `CamelJBangMain.execute` now consults a new 
`PluginHelper.shouldDiscoverPlugins`
      gate before calling `addPlugins`. Built-in commands that do not consume 
plugins
      (e.g. `version`, `get`, `ps`, `stop`) short-circuit the plugin JSON read 
and
      FACTORY_FINDER classpath scan entirely. Plugin-consuming built-ins
      (`run`, `export`, `cmd`, `shell`), unknown subcommands (likely 
plugin-provided),
      and no-args/help still discover so plugin commands remain visible.
    
    * `PluginHelper.resolvePlugin` now reads a `resolved` block from the 
per-plugin
      entry in `~/.camel-jbang-plugins.json`. When present and valid (Camel 
version,
      gav, repos match; cached jars and the plugin POM unchanged by 
size+mtime), the
      plugin is loaded directly from a URLClassLoader over the cached jars, 
skipping
      FACTORY_FINDER and the Maven downloader. The resolved block is populated 
on
      the first successful Maven resolution; SNAPSHOT plugins rebuilt locally 
are
      picked up automatically via the mtime check.
    
    Tests cover the gate's classification, cache hit, mtime-based invalidation, 
the
    write path, and a paired before/after demonstration of the cache fast path
    (no resolved block -> resolver invoked and quits; resolved block present ->
    plugin loaded from the cached jar without invoking the resolver). The 
existing
    `testCacheInvalidatedOnMtimeChange` is cleaned up to use `assertThrows`
    instead of a try/empty-catch block.
    
    Upgrade guide updated.
    
    Co-Authored-By: Claude Opus 4.7 <[email protected]>
    
    rh-pre-commit.version: 2.3.2
    rh-pre-commit.check-secrets: ENABLED
    
    Co-authored-by: Claus Ibsen <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_21.adoc    |  17 ++
 .../dsl/jbang/core/commands/CamelJBangMain.java    |   2 +-
 .../camel/dsl/jbang/core/common/PluginHelper.java  | 281 ++++++++++++++++++++-
 .../dsl/jbang/core/common/CachedFakePlugin.java    |  32 +++
 .../camel/dsl/jbang/core/common/FakePluginJar.java |  59 +++++
 .../dsl/jbang/core/common/PluginHelperTest.java    | 190 ++++++++++++++
 6 files changed, 568 insertions(+), 13 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
index d3deb55028d1..d3b12a318ea0 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_21.adoc
@@ -58,6 +58,23 @@ auto-disables `contentCache` on resource-based components 
(such as `xslt`) whose
 the route. Set `camel.component.<name>.contentCache=true` (or pass 
`?contentCache=true` on the
 URI) to opt back in to caching during dev mode.
 
+==== camel-jbang plugins
+
+Plugins are now loaded lazily. Built-in commands that do not consume plugins
+(for example `camel get`, `camel version`, `camel ps`, `camel stop`) skip 
plugin
+discovery entirely, avoiding classpath scans and Maven resolution on every
+invocation. Plugin-consuming commands (`run`, `export`, `cmd`, `shell`) and
+plugin-provided commands (such as `kubernetes`, `generate`, `test`) continue
+to work unchanged. When an external plugin is resolved through Maven, its 
resolved classpath is
+cached in `~/.camel-jbang-plugins.json` under a new `resolved` block. 
Subsequent
+invocations load the plugin directly from the cached jars without going through
+the Maven downloader. The cache is validated by file size and modification time
+on both the cached jars and the plugin's POM, so SNAPSHOT plugins rebuilt
+locally are picked up automatically. The cache is also invalidated when the
+Camel version, the plugin GAV, or the effective `--repos`/`--repo` value
+changes. No user action is required; existing plugin entries are populated on
+first use after upgrade.
+
 === camel-yaml-dsl
 
 A new canonical JSON Schema variant (`camelYamlDsl-canonical.json`) has been 
added alongside the existing classic
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index d96725ab5749..f766d4ce91c2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -214,7 +214,7 @@ public class CamelJBangMain implements Callable<Integer> {
 
         postAddCommands(commandLine, args);
 
-        if (discoverPlugins) {
+        if (discoverPlugins && PluginHelper.shouldDiscoverPlugins(commandLine, 
args)) {
             PluginHelper.addPlugins(commandLine, this, args);
         }
 
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 0ce0100bb7e0..36b01f5e3eda 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
@@ -19,14 +19,20 @@ package org.apache.camel.dsl.jbang.core.common;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
+import java.net.URLClassLoader;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Enumeration;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Properties;
+import java.util.Set;
 import java.util.function.Supplier;
 import java.util.jar.JarEntry;
 import java.util.jar.JarFile;
@@ -57,6 +63,13 @@ public final class PluginHelper {
     public static final String PLUGIN_CONFIG = ".camel-jbang-plugins.json";
     public static final String PLUGIN_SERVICE_DIR = 
"META-INF/services/org/apache/camel/camel-jbang-plugin/";
 
+    /**
+     * Built-in top-level commands that consume plugins — either by accepting 
plugin-contributed sub-options (run,
+     * export) or by dispatching to plugin-provided commands (shell, cmd). 
Plugin discovery must still run for these
+     * even if the target name is registered as a built-in subcommand.
+     */
+    private static final Set<String> PLUGIN_CONSUMING_BUILTINS = 
Set.of("shell", "run", "export", "cmd");
+
     private static final FactoryFinder FACTORY_FINDER
             = new DefaultFactoryFinder(new DefaultClassResolver(), 
FactoryFinder.DEFAULT_PATH + "camel-jbang-plugin/");
 
@@ -64,6 +77,35 @@ public final class PluginHelper {
         // prevent instantiation of utility class
     }
 
+    /**
+     * Decides whether plugin discovery (classpath scan + JSON config + Maven 
resolution) is needed for the current
+     * invocation. Returns false when the target command is a built-in that 
does not consume plugins, skipping all
+     * plugin-related IO. Returns true for plugin-consuming built-ins 
(run/export/cmd/shell), for unknown commands
+     * (likely plugin-provided), and when no target is given (e.g. --help 
listing).
+     *
+     * @param  commandLine the command line with all built-in subcommands 
already registered
+     * @param  args        the raw CLI args; only args[0] is inspected
+     * @return             true if plugin discovery should run, false to 
short-circuit
+     */
+    public static boolean shouldDiscoverPlugins(CommandLine commandLine, 
String... args) {
+        if (args == null || args.length == 0) {
+            return true;
+        }
+        // Only args[0] is inspected. If the user puts global options before 
the subcommand
+        // (e.g. `camel --verbose version`), we conservatively load plugins. 
Picocli option grammar
+        // is non-trivial enough that a heuristic skip would risk false 
negatives; the missed
+        // optimization is acceptable since this prefix-options pattern is 
uncommon.
+        String target = args[0];
+        if (target == null || target.isBlank() || target.startsWith("-")) {
+            return true;
+        }
+        if (PLUGIN_CONSUMING_BUILTINS.contains(target)) {
+            return true;
+        }
+        // target is a built-in (and not a plugin-consuming one) → no plugin 
needed
+        return !commandLine.getSubcommands().containsKey(target);
+    }
+
     /**
      * Loads the plugin Json configuration from the user home and goes through 
all configured plugins adding the plugin
      * commands to the current command line. Tries to resolve each plugin from 
the classpath with the factory finder
@@ -161,6 +203,7 @@ public final class PluginHelper {
             String version = catalog.getCatalogVersion();
             JsonObject plugins = config.getMap("plugins");
 
+            boolean configDirty = false;
             for (String pluginKey : plugins.keySet()) {
                 JsonObject properties = plugins.getMap(pluginKey);
 
@@ -179,30 +222,243 @@ public final class PluginHelper {
                     versionCheck(main, version, firstVersion, command);
                 }
 
-                Optional<Plugin> plugin = getPlugin(command, version, gav, 
repos, main.getOut());
-                if (plugin.isPresent()) {
-                    activePlugins.put(command, plugin.get());
+                ResolveResult res = resolvePlugin(properties, command, 
version, gav, repos, main.getOut());
+                if (res.plugin().isPresent()) {
+                    activePlugins.put(command, res.plugin().get());
+                    if (res.cacheWritten()) {
+                        configDirty = true;
+                    }
                 } else {
                     main.getOut().println("camel-jbang-plugin-" + command + " 
not found. Exit");
                     main.quit(1);
                 }
             }
+            if (configDirty) {
+                savePluginConfig(config);
+            }
         }
 
         return activePlugins;
     }
 
     public static Optional<Plugin> getPlugin(String name, String 
defaultVersion, String gav, String repos, Printer printer) {
+        return resolvePlugin(null, name, defaultVersion, gav, repos, 
printer).plugin();
+    }
+
+    /**
+     * Resolves a plugin by trying, in order: the cached metadata in the 
plugin entry (fast path with no IO beyond
+     * size+mtime checks), the factory finder (embedded plugin on the JVM 
classpath), and finally the Maven downloader.
+     * When the downloader runs, the resolved classpath is captured into the 
plugin entry's {@code resolved} block so
+     * subsequent invocations take the fast path.
+     */
+    private static ResolveResult resolvePlugin(
+            JsonObject entry, String name, String defaultVersion, String gav, 
String repos, Printer printer) {
+        Optional<Plugin> cached = loadFromCache(entry, defaultVersion, gav, 
repos);
+        if (cached.isPresent()) {
+            return new ResolveResult(cached, false);
+        }
+
         Optional<Plugin> plugin = 
FACTORY_FINDER.newInstance("camel-jbang-plugin-" + name, Plugin.class);
-        if (plugin.isEmpty()) {
-            final MavenGav mavenGav = dependencyAsMavenGav(gav);
-            final String group = extractGroup(mavenGav, "org.apache.camel");
-            final String depVersion = extractVersion(mavenGav, defaultVersion);
+        if (plugin.isPresent()) {
+            return new ResolveResult(plugin, false);
+        }
+
+        final MavenGav mavenGav = dependencyAsMavenGav(gav);
+        final String group = extractGroup(mavenGav, "org.apache.camel");
+        final String depVersion = extractVersion(mavenGav, defaultVersion);
+
+        DownloadResult dr = downloadPlugin(name, defaultVersion, depVersion, 
group, repos, printer);
+        boolean cacheWritten = false;
+        if (dr.plugin().isPresent() && entry != null && dr.classLoader() != 
null && dr.className() != null) {
+            cacheWritten = writeCache(entry, defaultVersion, gav, repos, 
dr.className(), dr.classLoader(), name, depVersion);
+        }
+        return new ResolveResult(dr.plugin(), cacheWritten);
+    }
+
+    private static Optional<Plugin> loadFromCache(JsonObject entry, String 
camelVersion, String gav, String repos) {
+        if (entry == null) {
+            return Optional.empty();
+        }
+        JsonObject resolved = entry.getMap("resolved");
+        if (resolved == null) {
+            return Optional.empty();
+        }
+        if (!sameCamelVersion(asString(resolved.get("camelVersion")), 
camelVersion)) {
+            return Optional.empty();
+        }
+        if (!Objects.equals(normalize(asString(resolved.get("gav"))), 
normalize(gav))) {
+            return Optional.empty();
+        }
+        if (!Objects.equals(normalize(asString(resolved.get("repos"))), 
normalize(repos))) {
+            return Optional.empty();
+        }
+        String className = asString(resolved.get("className"));
+        if (className == null || className.isBlank()) {
+            return Optional.empty();
+        }
+        Object cpObj = resolved.get("classpath");
+        if (!(cpObj instanceof Collection)) {
+            return Optional.empty();
+        }
+        Collection<?> classpath = (Collection<?>) cpObj;
+        if (classpath.isEmpty()) {
+            return Optional.empty();
+        }
+
+        List<URL> urls = new ArrayList<>(classpath.size());
+        for (Object o : classpath) {
+            if (!(o instanceof Map)) {
+                return Optional.empty();
+            }
+            Map<?, ?> jar = (Map<?, ?>) o;
+            Path p = validateFileEntry(jar);
+            if (p == null) {
+                return Optional.empty();
+            }
+            try {
+                urls.add(p.toUri().toURL());
+            } catch (IOException e) {
+                return Optional.empty();
+            }
+        }
+
+        // If the cache tracks the plugin POM, validate it too. Detects 
POM-only changes (e.g. a SNAPSHOT
+        // plugin's transitive deps changed without a jar rebuild).
+        Object pomObj = resolved.get("pom");
+        if (pomObj instanceof Map<?, ?> pom) {
+            if (validateFileEntry(pom) == null) {
+                return Optional.empty();
+            }
+        }
+
+        try {
+            URLClassLoader cl = new URLClassLoader(urls.toArray(new URL[0]), 
PluginHelper.class.getClassLoader());
+            Class<?> pluginClass = cl.loadClass(className);
+            Plugin instance = (Plugin) ObjectHelper.newInstance(pluginClass);
+            instance.setClassLoader(cl);
+            return Optional.of(instance);
+        } catch (Exception e) {
+            return Optional.empty();
+        }
+    }
 
-            plugin = downloadPlugin(name, defaultVersion, depVersion, group, 
repos, printer);
+    /**
+     * Persists the resolved plugin classpath into the entry's {@code 
resolved} block. Package-private so unit tests can
+     * drive the happy path without invoking the Maven downloader. Also tracks 
the plugin's own POM file (size+mtime) so
+     * a POM-only change (e.g. a SNAPSHOT plugin gaining a new transitive 
dependency without a jar rebuild) invalidates
+     * the cache on the next invocation.
+     */
+    static boolean writeCache(
+            JsonObject entry, String camelVersion, String gav, String repos, 
String className, ClassLoader cl,
+            String pluginCommand, String pluginVersion) {
+        URL[] urls;
+        if (cl instanceof URLClassLoader ucl) {
+            urls = ucl.getURLs();
+        } else {
+            return false;
+        }
+        if (urls == null || urls.length == 0) {
+            return false;
+        }
+        Collection<JsonObject> classpath = new ArrayList<>(urls.length);
+        JsonObject pomEntry = null;
+        String pluginJarName = "camel-jbang-plugin-" + pluginCommand + "-" + 
pluginVersion + ".jar";
+        for (URL u : urls) {
+            try {
+                Path p = Path.of(u.toURI());
+                if (!Files.exists(p)) {
+                    return false;
+                }
+                JsonObject jar = new JsonObject();
+                jar.put("path", p.toAbsolutePath().toString());
+                jar.put("size", Files.size(p));
+                jar.put("mtime", Files.getLastModifiedTime(p).toMillis());
+                classpath.add(jar);
+
+                // Identify the plugin's own jar by filename and track the 
sibling POM, so a Maven re-install
+                // of the plugin (which always rewrites the POM) is detected 
even when the jar bytes happen
+                // to be unchanged.
+                if (pomEntry == null && 
pluginJarName.equals(p.getFileName().toString())) {
+                    Path pom = p.resolveSibling("camel-jbang-plugin-" + 
pluginCommand + "-" + pluginVersion + ".pom");
+                    if (Files.exists(pom)) {
+                        pomEntry = new JsonObject();
+                        pomEntry.put("path", pom.toAbsolutePath().toString());
+                        pomEntry.put("size", Files.size(pom));
+                        pomEntry.put("mtime", 
Files.getLastModifiedTime(pom).toMillis());
+                    }
+                }
+            } catch (Exception e) {
+                return false;
+            }
+        }
+        JsonObject resolved = new JsonObject();
+        resolved.put("camelVersion", camelVersion);
+        if (normalize(gav) != null) {
+            resolved.put("gav", normalize(gav));
+        }
+        if (normalize(repos) != null) {
+            resolved.put("repos", normalize(repos));
         }
+        resolved.put("className", className);
+        resolved.put("cachedAt", System.currentTimeMillis());
+        resolved.put("classpath", classpath);
+        if (pomEntry != null) {
+            resolved.put("pom", pomEntry);
+        }
+        entry.put("resolved", resolved);
+        return true;
+    }
+
+    /**
+     * Validates a {path, size, mtime} entry from the cache against the actual 
file on disk. Returns the resolved Path
+     * on match, or null if the file is missing, was modified, or the entry is 
malformed.
+     */
+    private static Path validateFileEntry(Map<?, ?> entry) {
+        String path = asString(entry.get("path"));
+        Object sizeObj = entry.get("size");
+        Object mtimeObj = entry.get("mtime");
+        if (path == null || !(sizeObj instanceof Number) || !(mtimeObj 
instanceof Number)) {
+            return null;
+        }
+        long size = ((Number) sizeObj).longValue();
+        long mtime = ((Number) mtimeObj).longValue();
+        Path p = Path.of(path);
+        try {
+            if (!Files.exists(p) || Files.size(p) != size || 
Files.getLastModifiedTime(p).toMillis() != mtime) {
+                return null;
+            }
+            return p;
+        } catch (IOException e) {
+            return null;
+        }
+    }
+
+    private static boolean sameCamelVersion(String a, String b) {
+        return stripSnapshot(a).equals(stripSnapshot(b));
+    }
+
+    private static String stripSnapshot(String v) {
+        if (v == null) {
+            return "";
+        }
+        return v.endsWith("-SNAPSHOT") ? v.substring(0, v.length() - 
"-SNAPSHOT".length()) : v;
+    }
+
+    private static String normalize(String s) {
+        if (s == null || s.isBlank()) {
+            return null;
+        }
+        return s.trim();
+    }
+
+    private static String asString(Object o) {
+        return o == null ? null : o.toString();
+    }
+
+    private record ResolveResult(Optional<Plugin> plugin, boolean 
cacheWritten) {
+    }
 
-        return plugin;
+    private record DownloadResult(Optional<Plugin> plugin, ClassLoader 
classLoader, String className) {
     }
 
     private static MavenGav dependencyAsMavenGav(String gav) {
@@ -227,7 +483,7 @@ public final class PluginHelper {
         }
     }
 
-    private static Optional<Plugin> downloadPlugin(
+    private static DownloadResult downloadPlugin(
             String command, String camelVersion, String version, String group, 
String repos, Printer printer) {
         DependencyDownloader downloader = new MavenDependencyDownloader();
         DependencyDownloaderClassLoader ddlcl = new 
DependencyDownloaderClassLoader(PluginHelper.class.getClassLoader());
@@ -242,6 +498,7 @@ public final class PluginHelper {
         
downloader.downloadDependencyWithParent("org.apache.camel:camel-jbang-parent:pom:"
 + camelVersion, group,
                 "camel-jbang-plugin-" + command, version);
         Optional<Plugin> instance = Optional.empty();
+        String pluginClassName = null;
         InputStream in = null;
         String path = FactoryFinder.DEFAULT_PATH + 
"camel-jbang-plugin/camel-jbang-plugin-" + command;
         try {
@@ -250,7 +507,7 @@ public final class PluginHelper {
             if (in != null) {
                 Properties prop = new Properties();
                 prop.load(in);
-                String pluginClassName = prop.getProperty("class");
+                pluginClassName = prop.getProperty("class");
                 DefaultClassResolver resolver = new DefaultClassResolver();
                 Class<?> pluginClass = resolver.resolveClass(pluginClassName, 
ddlcl);
                 instance = 
Optional.of(Plugin.class.cast(ObjectHelper.newInstance(pluginClass)));
@@ -270,7 +527,7 @@ public final class PluginHelper {
             }
             IOHelper.close(in);
         }
-        return instance;
+        return new DownloadResult(instance, ddlcl, pluginClassName);
     }
 
     public static JsonObject getOrCreatePluginConfig() {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java
new file mode 100644
index 000000000000..46dd6491ff09
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CachedFakePlugin.java
@@ -0,0 +1,32 @@
+/*
+ * 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 org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import picocli.CommandLine;
+
+/**
+ * Minimal Plugin implementation packaged into a fake jar by {@link 
FakePluginJar} so that the cache fast path can be
+ * exercised end-to-end without invoking the Maven downloader.
+ */
+public class CachedFakePlugin implements Plugin {
+
+    @Override
+    public void customize(CommandLine commandLine, CamelJBangMain main) {
+        // no-op
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java
new file mode 100644
index 000000000000..08e85baeb288
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/FakePluginJar.java
@@ -0,0 +1,59 @@
+/*
+ * 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.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+/**
+ * Builds a tiny jar containing the precompiled CachedFakePlugin class so 
cache-hit tests can verify the fast path loads
+ * a class from a URLClassLoader without going through the Maven downloader.
+ */
+final class FakePluginJar {
+
+    static final String PLUGIN_CLASS = CachedFakePlugin.class.getName();
+
+    private FakePluginJar() {
+    }
+
+    static void write(Path target, String pluginName) throws Exception {
+        // Copy the existing .class for CachedFakePlugin into a jar; that 
class implements Plugin already.
+        String classResource = PLUGIN_CLASS.replace('.', '/') + ".class";
+        byte[] classBytes;
+        try (var in = 
FakePluginJar.class.getClassLoader().getResourceAsStream(classResource)) {
+            if (in == null) {
+                throw new IllegalStateException("Missing test class resource: 
" + classResource);
+            }
+            classBytes = in.readAllBytes();
+        }
+        try (OutputStream fos = Files.newOutputStream(target);
+             JarOutputStream jos = new JarOutputStream(fos)) {
+            JarEntry classEntry = new JarEntry(classResource);
+            jos.putNextEntry(classEntry);
+            jos.write(classBytes);
+            jos.closeEntry();
+
+            JarEntry svc = new JarEntry(PluginHelper.PLUGIN_SERVICE_DIR + 
"camel-jbang-plugin-" + pluginName);
+            jos.putNextEntry(svc);
+            jos.write(("class=" + PLUGIN_CLASS + "\n").getBytes());
+            jos.closeEntry();
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
index 0a1b9db38ff9..c9a0255953d2 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/PluginHelperTest.java
@@ -26,10 +26,13 @@ import org.apache.camel.util.json.JsonObject;
 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.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class PluginHelperTest {
@@ -131,4 +134,191 @@ public class PluginHelperTest {
         JsonObject pluginsConfig = config.getMap("plugins");
         assertEquals(1, pluginsConfig.size());
     }
+
+    @Test
+    public void testShouldDiscoverPlugins() {
+        CamelJBangMain main = new CamelJBangMain();
+        CommandLine cl = new CommandLine(main);
+        cl.addSubcommand("version", new CommandLine(new NoOpCommand()));
+        cl.addSubcommand("get", new CommandLine(new NoOpCommand()));
+        cl.addSubcommand("run", new CommandLine(new NoOpCommand()));
+        cl.addSubcommand("export", new CommandLine(new NoOpCommand()));
+        cl.addSubcommand("shell", new CommandLine(new NoOpCommand()));
+        cl.addSubcommand("cmd", new CommandLine(new NoOpCommand()));
+
+        // built-in non-plugin-consuming commands → short-circuit
+        assertFalse(PluginHelper.shouldDiscoverPlugins(cl, "version"));
+        assertFalse(PluginHelper.shouldDiscoverPlugins(cl, "get", "bean"));
+
+        // plugin-consuming built-ins → must load
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "run", "foo.yaml"));
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "export"));
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "shell"));
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "cmd", "browse"));
+
+        // unknown command (likely plugin-provided) → must load
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "kubernetes", 
"run"));
+
+        // no args / help → must load so plugin commands appear in help listing
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl));
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, "--help"));
+        assertTrue(PluginHelper.shouldDiscoverPlugins(cl, ""));
+    }
+
+    @Test
+    public void testCacheHitSkipsDownload() throws Exception {
+        Path jar = tempDir.resolve("fake-plugin.jar");
+        FakePluginJar.write(jar, "fake");
+
+        String camelVersion = new 
org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion();
+        writeConfig(buildEntry("fake", camelVersion, jar, 
Files.getLastModifiedTime(jar).toMillis()));
+
+        CamelJBangMain main = new CamelJBangMain();
+        Map<String, Plugin> plugins = PluginHelper.getActivePlugins(main, 
null, "fake");
+        assertEquals(1, plugins.size());
+        assertNotNull(plugins.get("fake"));
+        // ensure the cache path returned an instance of the class loaded from 
the cached jar
+        assertEquals(FakePluginJar.PLUGIN_CLASS, 
plugins.get("fake").getClass().getName());
+    }
+
+    @Test
+    public void testCacheInvalidatedOnMtimeChange() throws Exception {
+        Path jar = tempDir.resolve("fake-plugin.jar");
+        FakePluginJar.write(jar, "fake");
+
+        String camelVersion = new 
org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion();
+        long staleMtime = Files.getLastModifiedTime(jar).toMillis() - 1000;
+        writeConfig(buildEntry("fake", camelVersion, jar, staleMtime));
+
+        // Stale mtime invalidates the cache. There is no factory-finder entry 
and no Maven dependency to
+        // download (gav is empty), so the resolver returns empty and quits.
+        QuitCapture main = new QuitCapture();
+        assertThrows(RuntimeException.class, () -> 
PluginHelper.getActivePlugins(main, null, "fake"));
+        assertTrue(main.quitCalled, "expected resolver to give up when cache 
is invalid and no download path is viable");
+    }
+
+    @Test
+    public void testWriteCachePersistsResolvedBlock() throws Exception {
+        Path jar = tempDir.resolve("camel-jbang-plugin-fake-9.9.9.jar");
+        FakePluginJar.write(jar, "fake");
+        Path pom = tempDir.resolve("camel-jbang-plugin-fake-9.9.9.pom");
+        Files.writeString(pom, "<project/>");
+
+        JsonObject entry = new JsonObject();
+        entry.put("name", "fake");
+        entry.put("command", "fake");
+
+        try (java.net.URLClassLoader cl = new java.net.URLClassLoader(new 
java.net.URL[] { jar.toUri().toURL() })) {
+            boolean written = PluginHelper.writeCache(entry, "9.9.9", null, 
null, FakePluginJar.PLUGIN_CLASS, cl, "fake",
+                    "9.9.9");
+            assertTrue(written);
+        }
+
+        JsonObject resolved = entry.getMap("resolved");
+        assertNotNull(resolved);
+        assertEquals("9.9.9", resolved.getString("camelVersion"));
+        assertEquals(FakePluginJar.PLUGIN_CLASS, 
resolved.getString("className"));
+        assertNotNull(resolved.get("cachedAt"));
+
+        Object cp = resolved.get("classpath");
+        assertTrue(cp instanceof java.util.Collection);
+        java.util.Collection<?> classpath = (java.util.Collection<?>) cp;
+        assertEquals(1, classpath.size());
+        Map<?, ?> jarEntry = (Map<?, ?>) classpath.iterator().next();
+        assertEquals(jar.toAbsolutePath().toString(), jarEntry.get("path"));
+        assertEquals(Files.size(jar), ((Number) 
jarEntry.get("size")).longValue());
+        assertEquals(Files.getLastModifiedTime(jar).toMillis(), ((Number) 
jarEntry.get("mtime")).longValue());
+
+        // POM sibling should be tracked since it lives next to the plugin jar
+        Map<?, ?> pomEntry = (Map<?, ?>) resolved.get("pom");
+        assertNotNull(pomEntry);
+        assertEquals(pom.toAbsolutePath().toString(), pomEntry.get("path"));
+        assertEquals(Files.size(pom), ((Number) 
pomEntry.get("size")).longValue());
+    }
+
+    @Test
+    public void testCacheFastPathAvoidsResolver() throws Exception {
+        // Before/after demonstration of CAMEL-23335 cache fast path. Same 
plugin name and on-disk jar in
+        // both halves — only the presence of the `resolved` block in the 
config differs.
+        Path jar = tempDir.resolve("fake-plugin.jar");
+        FakePluginJar.write(jar, "fake");
+        String camelVersion = new 
org.apache.camel.catalog.DefaultCamelCatalog().getCatalogVersion();
+
+        // BEFORE CAMEL-23335: entry has no `resolved` block. resolvePlugin 
falls through loadFromCache,
+        // misses FACTORY_FINDER (no service registered for "fake"), reaches 
downloadPlugin with no usable
+        // gav, gets nothing back, and quits.
+        writeConfig(buildEntryWithoutResolvedBlock("fake", camelVersion));
+        QuitCapture before = new QuitCapture();
+        assertThrows(RuntimeException.class, () -> 
PluginHelper.getActivePlugins(before, null, "fake"));
+        assertTrue(before.quitCalled,
+                "without the resolved-block cache, resolver attempted download 
and gave up");
+
+        // AFTER CAMEL-23335: entry has a valid `resolved` block. 
loadFromCache builds a URLClassLoader
+        // from the cached jar and returns the plugin directly — 
FACTORY_FINDER and Maven are never touched.
+        writeConfig(buildEntry("fake", camelVersion, jar, 
Files.getLastModifiedTime(jar).toMillis()));
+        QuitCapture after = new QuitCapture();
+        Map<String, Plugin> plugins
+                = assertDoesNotThrow(() -> 
PluginHelper.getActivePlugins(after, null, "fake"));
+        assertFalse(after.quitCalled, "cache fast path resolved the plugin 
without invoking the resolver");
+        assertEquals(FakePluginJar.PLUGIN_CLASS, 
plugins.get("fake").getClass().getName(),
+                "plugin loaded from the cached jar, not via FACTORY_FINDER");
+    }
+
+    private void writeConfig(JsonObject pluginEntry) throws Exception {
+        JsonObject plugins = new JsonObject();
+        plugins.put(pluginEntry.getString("name"), pluginEntry);
+        JsonObject config = new JsonObject();
+        config.put("plugins", plugins);
+        Path userConfig = 
CommandLineHelper.getHomeDir().resolve(PluginHelper.PLUGIN_CONFIG);
+        Files.writeString(userConfig, config.toJson(), 
StandardOpenOption.CREATE);
+    }
+
+    private static JsonObject buildEntryWithoutResolvedBlock(String name, 
String camelVersion) {
+        JsonObject entry = new JsonObject();
+        entry.put("name", name);
+        entry.put("command", name);
+        entry.put("description", "Fake plugin");
+        entry.put("firstVersion", camelVersion);
+        return entry;
+    }
+
+    private static 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");
+        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);
+        java.util.List<JsonObject> cp = new java.util.ArrayList<>();
+        cp.add(jarEntry);
+        resolved.put("classpath", cp);
+        entry.put("resolved", resolved);
+        return entry;
+    }
+
+    private static class QuitCapture extends CamelJBangMain {
+        boolean quitCalled;
+
+        @Override
+        public void quit(int exitCode) {
+            quitCalled = true;
+            throw new RuntimeException("quit");
+        }
+    }
+
+    @CommandLine.Command(name = "noop")
+    private static class NoOpCommand implements Runnable {
+        @Override
+        public void run() {
+            // no-op
+        }
+    }
 }


Reply via email to