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 9712c5e70300 CAMEL-23688: camel-jbang - Tests for explain and harden 
(#27971)
9712c5e70300 is described below

commit 9712c5e703008111c6b17d104b7c7f89eace0dc8
Author: Claus Ibsen <[email protected]>
AuthorDate: Fri Oct 16 22:29:04 2026 +0200

    CAMEL-23688: camel-jbang - Tests for explain and harden (#27971)
    
    Tests for camel explain (9% covered) and camel harden (14%) against a local
    server that plays Ollama and an OpenAI-compatible service (FakeLlmServer),
    which keeps the question it answered, so a test can check the prompt.
    They found these bugs, fixed here:
    
    * camel harden --api-type=openai printed only the header: with --stream
      (the default) it took the answer as printed, but the OpenAI-compatible
      service answers in one go.
    * The LLM configuration, the progress and the prompts of --show-prompt
      were printed on stdout with the answer; they go to stderr, so stdout
      has only the answer.
    * An unknown --format was ignored; now a usage error.
    * An API key of up to four characters was shown whole in the
      configuration; a key of eight characters or fewer is not shown.
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |   9 ++
 .../camel/dsl/jbang/core/commands/Explain.java     |  34 +++--
 .../camel/dsl/jbang/core/commands/Harden.java      |  47 ++++---
 .../camel/dsl/jbang/core/commands/ExplainTest.java | 153 +++++++++++++++++++++
 .../dsl/jbang/core/commands/FakeLlmServer.java     | 105 ++++++++++++++
 .../camel/dsl/jbang/core/commands/HardenTest.java  | 148 ++++++++++++++++++++
 6 files changed, 461 insertions(+), 35 deletions(-)

diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 3668b9465e35..b332441dfd88 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -462,6 +462,15 @@ or follows, like Enter does.
   printed. Files together with `--source-dir`, and `--background` with a 
runtime that does not support it, exit with
   `2`.
 
+=== camel-jbang - camel explain and camel harden
+
+* The LLM configuration, the progress and the prompts of `--show-prompt` are 
printed on stderr, so stdout has only the
+  answer of the LLM (with a header per file), such as for `camel explain 
route.yaml > explanation.md`.
+* An unknown `--format` exits with `2` (it was ignored).
+* `camel harden --api-type=openai` prints the suggestions (it printed only the 
header): the OpenAI-compatible service
+  answers in one go, also with `--stream`.
+* An API key of eight characters or fewer is not shown in the configuration 
(the last four characters of a key were).
+
 === camel-jbang - the AI tools report failures as errors
 
 * `camel ask` runs a `camel` command for the model with a timeout of 60 
seconds, and gives it the exit code, stdout and
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
index 9fc0e51ea173..10d8cdbdf369 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Explain.java
@@ -27,6 +27,7 @@ import java.util.Optional;
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.dsl.jbang.core.commands.ai.RouteComponents;
+import org.apache.camel.dsl.jbang.core.common.CliException;
 import org.apache.camel.dsl.jbang.core.common.CliExitCode;
 import org.apache.camel.tooling.model.ComponentModel;
 import org.apache.camel.tooling.model.EipModel;
@@ -150,6 +151,9 @@ public class Explain extends CamelCommand {
 
     @Override
     public Integer doCall() throws Exception {
+        if (!"text".equals(format) && !"markdown".equals(format)) {
+            throw CliException.usage("--format must be text or markdown, was: 
" + format);
+        }
         // check the input before reaching out to an LLM
         for (String file : files) {
             if (!Files.exists(Path.of(file))) {
@@ -185,20 +189,21 @@ public class Explain extends CamelCommand {
     }
 
     private void printConfiguration(LlmClient client) {
-        printer().println("LLM Configuration:");
-        printer().println("  URL: " + (client.url != null ? client.url : 
"(Vertex AI)"));
-        printer().println("  API Type: " + client.apiType);
-        printer().println("  Model: " + client.model);
+        printer().printDiagnostic("LLM Configuration:");
+        printer().printDiagnostic("  URL: " + (client.url != null ? client.url 
: "(Vertex AI)"));
+        printer().printDiagnostic("  API Type: " + client.apiType);
+        printer().printDiagnostic("  Model: " + client.model);
         printMaskedApiKey(client.apiKey);
-        printer().println();
+        printer().printDiagnostic("");
     }
 
     private void printMaskedApiKey(String key) {
         if (key == null || key.isBlank()) {
             return;
         }
-        String masked = "****" + key.substring(Math.max(0, key.length() - 4));
-        printer().println("  API Key: " + masked);
+        // the last characters of a long key only: a short one would be shown 
whole
+        String masked = key.length() > 8 ? "****" + key.substring(key.length() 
- 4) : "****";
+        printer().printDiagnostic("  API Key: " + masked);
     }
 
     private void printUsageHelp() {
@@ -227,8 +232,7 @@ public class Explain extends CamelCommand {
 
         printPromptsIfRequested(sysPrompt, userPrompt);
 
-        printer().println("Analyzing route with " + client.model + " (" + 
client.apiType + ")...");
-        printer().println();
+        printer().printDiagnostic("Analyzing route with " + client.model + " 
(" + client.apiType + ")...");
 
         String explanation = client.generate(sysPrompt, userPrompt);
 
@@ -246,12 +250,12 @@ public class Explain extends CamelCommand {
         if (!showPrompt) {
             return;
         }
-        printer().println("--- SYSTEM PROMPT ---");
-        printer().println(sysPrompt);
-        printer().println("--- USER PROMPT ---");
-        printer().println(userPrompt);
-        printer().println("--- END PROMPTS ---");
-        printer().println();
+        printer().printDiagnostic("--- SYSTEM PROMPT ---");
+        printer().printDiagnostic(sysPrompt);
+        printer().printDiagnostic("--- USER PROMPT ---");
+        printer().printDiagnostic(userPrompt);
+        printer().printDiagnostic("--- END PROMPTS ---");
+        printer().printDiagnostic("");
     }
 
     private int handleExplanationResult(String explanation) {
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
index 6cfef2eba744..43d38db64081 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Harden.java
@@ -37,6 +37,7 @@ import java.util.stream.Stream;
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.dsl.jbang.core.commands.ai.RouteComponents;
+import org.apache.camel.dsl.jbang.core.common.CliException;
 import org.apache.camel.dsl.jbang.core.common.CliExitCode;
 import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
 import org.apache.camel.tooling.model.ComponentModel;
@@ -193,6 +194,9 @@ public class Harden extends CamelCommand {
             defaultValue = "true", arity = "0..1", fallbackValue = "true")
     boolean stream = true;
 
+    // whether the answer for the current file was printed as it came (reset 
per file)
+    private boolean streamed;
+
     private final HttpClient httpClient = HttpClient.newBuilder()
             .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
             .build();
@@ -203,6 +207,9 @@ public class Harden extends CamelCommand {
 
     @Override
     public Integer doCall() throws Exception {
+        if (!"text".equals(format) && !"markdown".equals(format)) {
+            throw CliException.usage("--format must be text or markdown, was: 
" + format);
+        }
         // check the input before reaching out to an LLM
         for (String file : files) {
             if (!Files.exists(Path.of(file))) {
@@ -230,20 +237,21 @@ public class Harden extends CamelCommand {
     }
 
     private void printConfiguration(String endpoint, String resolvedApiKey) {
-        printer().println("LLM Configuration:");
-        printer().println("  URL: " + endpoint);
-        printer().println("  API Type: " + apiType);
-        printer().println("  Model: " + model);
+        printer().printDiagnostic("LLM Configuration:");
+        printer().printDiagnostic("  URL: " + endpoint);
+        printer().printDiagnostic("  API Type: " + apiType);
+        printer().printDiagnostic("  Model: " + model);
         printMaskedApiKey(resolvedApiKey);
-        printer().println();
+        printer().printDiagnostic("");
     }
 
     private void printMaskedApiKey(String key) {
         if (key == null || key.isBlank()) {
             return;
         }
-        String masked = "****" + key.substring(Math.max(0, key.length() - 4));
-        printer().println("  API Key: " + masked);
+        // the last characters of a long key only: a short one would be shown 
whole
+        String masked = key.length() > 8 ? "****" + key.substring(key.length() 
- 4) : "****";
+        printer().printDiagnostic("  API Key: " + masked);
     }
 
     private void printUsageHelp() {
@@ -370,6 +378,7 @@ public class Harden extends CamelCommand {
     }
 
     private int hardenRoute(String file, String endpoint, String 
resolvedApiKey) throws Exception {
+        streamed = false;
         Path path = Path.of(file);
         String routeContent = Files.readString(path);
         String ext = Optional.ofNullable(FileUtil.onlyExt(file, 
false)).orElse("yaml");
@@ -397,12 +406,12 @@ public class Harden extends CamelCommand {
         if (!showPrompt) {
             return;
         }
-        printer().println("--- SYSTEM PROMPT ---");
-        printer().println(sysPrompt);
-        printer().println("--- USER PROMPT ---");
-        printer().println(userPrompt);
-        printer().println("--- END PROMPTS ---");
-        printer().println();
+        printer().printDiagnostic("--- SYSTEM PROMPT ---");
+        printer().printDiagnostic(sysPrompt);
+        printer().printDiagnostic("--- USER PROMPT ---");
+        printer().printDiagnostic(userPrompt);
+        printer().printDiagnostic("--- END PROMPTS ---");
+        printer().printDiagnostic("");
     }
 
     private int handleHardeningResult(String suggestions) {
@@ -410,9 +419,8 @@ public class Harden extends CamelCommand {
             printer().printErr("Failed to get security hardening suggestions 
from LLM");
             return 1;
         }
-        // With streaming, response was already printed during generation
-        // Without streaming, we need to print it now
-        if (!stream) {
+        // a streamed answer is printed as it comes; the OpenAI-compatible 
service answers in one go, also with --stream
+        if (!streamed) {
             printer().println(suggestions);
         }
         printer().println();
@@ -555,8 +563,7 @@ public class Harden extends CamelCommand {
         options.put("temperature", temperature);
         request.put("options", options);
 
-        printer().println("Performing security analysis with " + model + " 
(Ollama)...");
-        printer().println();
+        printer().printDiagnostic("Performing security analysis with " + model 
+ " (Ollama)...");
 
         if (stream) {
             return sendStreamingRequest(endpoint + "/api/generate", request);
@@ -577,8 +584,7 @@ public class Harden extends CamelCommand {
 
         String apiUrl = normalizeOpenAiUrl(endpoint);
 
-        printer().println("Performing security analysis with " + model + " 
(OpenAI-compatible)...");
-        printer().println();
+        printer().printDiagnostic("Performing security analysis with " + model 
+ " (OpenAI-compatible)...");
 
         JsonObject response = sendRequest(apiUrl, request, resolvedApiKey);
         return extractOpenAiContent(response);
@@ -631,6 +637,7 @@ public class Harden extends CamelCommand {
             }
 
             StringBuilder fullResponse = new StringBuilder();
+            streamed = true;
             response.body().forEach(line -> {
                 if (line.isBlank()) {
                     return;
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
new file mode 100644
index 000000000000..cd4759be880a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ExplainTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+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.assertThatThrownBy;
+
+/**
+ * camel explain: a route explained by an LLM (a local server plays Ollama). 
Only the explanation is on stdout.
+ */
+class ExplainTest extends CamelCommandBaseTestSupport {
+
+    private static final String ROUTE = """
+            - route:
+                id: hello
+                from:
+                  uri: timer:tick
+                  steps:
+                    - filter:
+                        simple: "${body} != null"
+                        steps:
+                          - to: kafka:greetings
+            """;
+
+    private static final String EXPLANATION = "The route sends a greeting to 
Kafka every second.";
+
+    @TempDir
+    Path dir;
+
+    private Path route;
+    private FakeLlmServer llm;
+
+    @BeforeEach
+    void start() throws Exception {
+        route = Files.writeString(dir.resolve("hello.camel.yaml"), ROUTE);
+        llm = new FakeLlmServer(EXPLANATION);
+    }
+
+    @AfterEach
+    void stop() {
+        llm.close();
+    }
+
+    @Test
+    void explainsTheRoute() throws Exception {
+        assertThat(command(route.toString()).doCall()).isZero();
+
+        // streamed: the parts of the answer as they come
+        assertThat(printer.getStdout()).contains("Explaining: " + route, 
EXPLANATION).doesNotContain("LLM Configuration");
+        assertThat(printer.getStderr()).contains("LLM Configuration:", "  
Model: test", "Analyzing route with test");
+        assertThat(llm.lastRequest().getString("prompt")).contains("Route 
definition:", "kafka:greetings");
+    }
+
+    @Test
+    void explainsTheRouteWithoutStreaming() throws Exception {
+        assertThat(command(route.toString(), 
"--stream=false").doCall()).isZero();
+
+        assertThat(printer.getStdout()).contains(EXPLANATION);
+        assertThat(llm.lastRequest().getBoolean("stream")).isFalse();
+    }
+
+    @Test
+    void thePromptsAreOnStderr() throws Exception {
+        assertThat(command(route.toString(), "--show-prompt", 
"--format=markdown", "--verbose").doCall()).isZero();
+
+        assertThat(printer.getStderr()).contains("--- SYSTEM PROMPT ---", 
"Format output as Markdown",
+                "Include technical details", "--- USER PROMPT ---", 
"kafka:greetings");
+        assertThat(printer.getStdout()).doesNotContain("--- SYSTEM PROMPT 
---");
+    }
+
+    @Test
+    void catalogContextDescribesTheComponentsAndEips() throws Exception {
+        assertThat(command(route.toString(), 
"--catalog-context").doCall()).isZero();
+
+        String prompt = llm.lastRequest().getString("prompt");
+        assertThat(prompt).contains("Reference information:", "- kafka: ", "- 
timer: ", "(EIP)");
+    }
+
+    @Test
+    void customSystemPrompt() throws Exception {
+        assertThat(command(route.toString(), "--system-prompt=Answer in one 
word.").doCall()).isZero();
+
+        assertThat(llm.lastRequest().getString("system")).isEqualTo("Answer in 
one word.");
+    }
+
+    @Test
+    void aShortApiKeyIsNotShown() throws Exception {
+        assertThat(command(route.toString(), 
"--api-key=abc").doCall()).isZero();
+
+        assertThat(printer.getStderr()).contains("  API Key: 
****").doesNotContain("abc");
+    }
+
+    @Test
+    void aLongApiKeyShowsItsLastCharacters() throws Exception {
+        assertThat(command(route.toString(), 
"--api-key=sk-1234567890abcd").doCall()).isZero();
+
+        assertThat(printer.getStderr()).contains("  API Key: 
****abcd").doesNotContain("sk-1234567890");
+    }
+
+    @Test
+    void unknownFormatIsAUsageError() {
+        Explain command = command(route.toString(), "--format=json");
+
+        assertThatThrownBy(command::doCall)
+                .isInstanceOfSatisfying(CliException.class,
+                        e -> 
assertThat(e.getExitCode()).isEqualTo(CliExitCode.USAGE))
+                .hasMessage("--format must be text or markdown, was: json");
+        assertThat(llm.lastRequest()).isNull();
+    }
+
+    @Test
+    void aFileThatDoesNotExistIsNotFound() throws Exception {
+        
assertThat(command(dir.resolve("nope.yaml").toString()).doCall()).isEqualTo(CliExitCode.NOT_FOUND);
+
+        assertThat(printer.getStderr()).contains("File not found: ");
+        assertThat(llm.lastRequest()).isNull();
+    }
+
+    private Explain command(String... args) {
+        Explain command = new Explain(new 
CamelJBangMain().withPrinter(printer));
+        CommandLine.populateCommand(command,
+                Stream.concat(Arrays.stream(args), 
Stream.of("--api-type=ollama", "--url=" + llm.url(), "--model=test"))
+                        .toArray(String[]::new));
+        return command;
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
new file mode 100644
index 000000000000..e60017e02118
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/FakeLlmServer.java
@@ -0,0 +1,105 @@
+/*
+ * 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.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * A local server that plays an LLM for the tests of the AI commands: Ollama 
(its models, and the answer of the model,
+ * whole or streamed in two parts) and an OpenAI-compatible chat completion. 
It keeps the last question it answered.
+ */
+final class FakeLlmServer implements AutoCloseable {
+
+    private final HttpServer server;
+    private final String answer;
+    private volatile JsonObject lastRequest;
+
+    FakeLlmServer(String answer) throws IOException {
+        this.answer = answer;
+        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/api/tags", ex -> reply(ex, 
"{\"models\":[{\"name\":\"test\",\"size\":1000}]}"));
+        server.createContext("/api/ps", ex -> reply(ex, "{\"models\":[]}"));
+        server.createContext("/api/show", ex -> reply(ex, 
"{\"model_info\":{}}"));
+        server.createContext("/api/generate", this::generate);
+        server.createContext("/v1/models", ex -> reply(ex, 
"{\"data\":[{\"id\":\"test\"}]}"));
+        server.createContext("/v1/chat/completions", this::chat);
+        server.start();
+    }
+
+    String url() {
+        return "http://127.0.0.1:"; + server.getAddress().getPort();
+    }
+
+    /**
+     * The last question the server answered, as the client sent it.
+     */
+    JsonObject lastRequest() {
+        return lastRequest;
+    }
+
+    @Override
+    public void close() {
+        server.stop(0);
+    }
+
+    private void generate(HttpExchange exchange) throws IOException {
+        JsonObject request = read(exchange);
+        if (request.getBooleanOrDefault("stream", false)) {
+            // the answer in two parts, a JSON document per line
+            int half = answer.length() / 2;
+            String first = new JsonObject(Map.of("response", 
answer.substring(0, half), "done", false)).toJson();
+            String last = new JsonObject(Map.of("response", 
answer.substring(half), "done", true)).toJson();
+            reply(exchange, first + "\n" + last + "\n");
+        } else {
+            reply(exchange, new JsonObject(Map.of("response", answer, "done", 
true)).toJson());
+        }
+    }
+
+    private void chat(HttpExchange exchange) throws IOException {
+        read(exchange);
+        JsonObject message = new JsonObject(Map.of("role", "assistant", 
"content", answer));
+        JsonObject choice = new JsonObject(Map.of("index", 0, "message", 
message));
+        reply(exchange, new JsonObject(Map.of("choices", new 
JsonArray(List.of(choice)))).toJson());
+    }
+
+    private JsonObject read(HttpExchange exchange) throws IOException {
+        String body = new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8);
+        JsonObject request = Jsoner.deserialize(body, new JsonObject());
+        lastRequest = request;
+        return request;
+    }
+
+    private static void reply(HttpExchange exchange, String body) throws 
IOException {
+        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().add("Content-Type", "application/json");
+        exchange.sendResponseHeaders(200, bytes.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(bytes);
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
new file mode 100644
index 000000000000..183710a20c60
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/HardenTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.camel.dsl.jbang.core.common.CliException;
+import org.apache.camel.dsl.jbang.core.common.CliExitCode;
+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.assertThatThrownBy;
+
+/**
+ * camel harden: security suggestions for a route by an LLM (a local server 
plays Ollama, or an OpenAI-compatible
+ * service). Only the suggestions are on stdout.
+ */
+class HardenTest extends CamelCommandBaseTestSupport {
+
+    private static final String ROUTE = """
+            - route:
+                id: upload
+                from:
+                  uri: platform-http:/upload
+                  steps:
+                    - to: ftp:files.example.com/in?username=bob&password=secret
+            """;
+
+    private static final String SUGGESTIONS = "Use SFTP instead of FTP, and 
keep the password in a vault.";
+
+    @TempDir
+    Path dir;
+
+    private Path route;
+    private FakeLlmServer llm;
+
+    @BeforeEach
+    void start() throws Exception {
+        route = Files.writeString(dir.resolve("upload.camel.yaml"), ROUTE);
+        llm = new FakeLlmServer(SUGGESTIONS);
+    }
+
+    @AfterEach
+    void stop() {
+        llm.close();
+    }
+
+    @Test
+    void suggestionsFromOllama() throws Exception {
+        assertThat(command(route.toString()).doCall()).isZero();
+
+        assertThat(printer.getStdout()).contains("Security Hardening Analysis: 
" + route, SUGGESTIONS)
+                .doesNotContain("LLM Configuration");
+        assertThat(printer.getStderr()).contains("LLM Configuration:", 
"Performing security analysis with test (Ollama)");
+        
assertThat(llm.lastRequest().getString("prompt")).contains("ftp:files.example.com");
+    }
+
+    @Test
+    void suggestionsFromAnOpenAiCompatibleService() throws Exception {
+        assertThat(command(route.toString(), "--api-type=openai", 
"--api-key=sk-1234567890abcd").doCall()).isZero();
+
+        assertThat(printer.getStdout()).contains(SUGGESTIONS);
+        assertThat(printer.getStderr()).contains("(OpenAI-compatible)", "  API 
Key: ****abcd");
+        JsonObject request = llm.lastRequest();
+        assertThat(request.getString("model")).isEqualTo("test");
+        assertThat(request.toJson()).contains("ftp:files.example.com");
+    }
+
+    @Test
+    void suggestionsForEachFile() throws Exception {
+        Path other = Files.writeString(dir.resolve("other.camel.yaml"), 
ROUTE.replace("upload", "other"));
+
+        assertThat(command(route.toString(), other.toString(), 
"--api-type=openai").doCall()).isZero();
+
+        String out = printer.getStdout();
+        assertThat(out).contains("Security Hardening Analysis: " + route, 
"Security Hardening Analysis: " + other);
+        // the answer of each file, also after a file before it
+        
assertThat(out.indexOf(SUGGESTIONS)).isNotEqualTo(out.lastIndexOf(SUGGESTIONS));
+    }
+
+    @Test
+    void streamedSuggestionsForEachFile() throws Exception {
+        Path other = Files.writeString(dir.resolve("other.camel.yaml"), 
ROUTE.replace("upload", "other"));
+
+        assertThat(command(route.toString(), 
other.toString()).doCall()).isZero();
+
+        String out = printer.getStdout();
+        
assertThat(out.indexOf(SUGGESTIONS)).isNotEqualTo(-1).isNotEqualTo(out.lastIndexOf(SUGGESTIONS));
+    }
+
+    @Test
+    void thePromptsAreOnStderr() throws Exception {
+        assertThat(command(route.toString(), "--show-prompt", 
"--stream=false").doCall()).isZero();
+
+        assertThat(printer.getStderr()).contains("--- SYSTEM PROMPT ---", "--- 
USER PROMPT ---");
+        
assertThat(printer.getStdout()).contains(SUGGESTIONS).doesNotContain("--- 
SYSTEM PROMPT ---");
+    }
+
+    @Test
+    void anUnreachableUrlIsNotReplacedByAnotherLlm() throws Exception {
+        Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+        CommandLine.populateCommand(command, route.toString(), 
"--url=http://127.0.0.1:1";, "--model=test");
+
+        // the route is not sent to the Ollama of the host
+        assertThat(command.doCall()).isEqualTo(CliExitCode.FAILURE);
+        assertThat(printer.getStderr()).contains("Cannot connect to LLM 
service at: http://127.0.0.1:1";);
+    }
+
+    @Test
+    void unknownFormatIsAUsageError() {
+        Harden command = command(route.toString(), "--format=json");
+
+        assertThatThrownBy(command::doCall)
+                .isInstanceOfSatisfying(CliException.class,
+                        e -> 
assertThat(e.getExitCode()).isEqualTo(CliExitCode.USAGE))
+                .hasMessage("--format must be text or markdown, was: json");
+    }
+
+    private Harden command(String... args) {
+        Harden command = new Harden(new CamelJBangMain().withPrinter(printer));
+        CommandLine.populateCommand(command,
+                Stream.concat(Arrays.stream(args), Stream.of("--url=" + 
llm.url(), "--model=test"))
+                        .toArray(String[]::new));
+        return command;
+    }
+}

Reply via email to