diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc index 9fed81526309c..48faae5c99b4e 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc @@ -104,3 +104,21 @@ the message, consistent with the inbound header filtering already performed by t Ordinary application headers are unaffected. If a route relied on `Camel*` headers being propagated from the MIME content, set them explicitly after unmarshalling. + +=== camel-jbang catalog tables fill the terminal width + +The `camel catalog` commands (`camel catalog component`, `camel catalog dataformat`, +`camel catalog language`, `camel catalog transformer`, `camel catalog kamelet`, ...) now size the +`DESCRIPTION` column to the detected terminal width instead of the previous fixed 80-character cap, +so wide terminals show more of the description before it is truncated with an ellipsis. The `NAME` +column width is also standardized across these commands (it previously differed per command, for +example 60 for `transformer` and 30 elsewhere). + +Terminal width is now detected on Windows (`cmd` / PowerShell) via `mode con`, in addition to the +existing `COLUMNS` / `stty size` detection. When no terminal can be detected (for example when the +output is piped or redirected), the width falls back to 120 columns. For full, untruncated output +suitable for scripting, use the `--json` option. + +The `camel infra list` table now sizes its `DESCRIPTION` column to the terminal width, and truncates +the `IMPLEMENTATION` and `SERVICE_DATA` columns with an ellipsis instead of letting the raw service +data overflow the terminal. The complete, structured service data remains available via `--json`. diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java index a83f99aa11136..b8ac84ee7605e 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogBaseCommand.java @@ -20,18 +20,19 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.function.Function; import java.util.stream.Collectors; import com.github.freva.asciitable.AsciiTable; import com.github.freva.asciitable.Column; import com.github.freva.asciitable.HorizontalAlign; -import com.github.freva.asciitable.OverflowBehaviour; import org.apache.camel.catalog.CamelCatalog; import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; import org.apache.camel.dsl.jbang.core.commands.MavenResolverMixin; import org.apache.camel.dsl.jbang.core.commands.QuarkusPlatformMixin; +import org.apache.camel.dsl.jbang.core.common.CamelTableColumns; import org.apache.camel.dsl.jbang.core.common.CatalogLoader; import org.apache.camel.dsl.jbang.core.common.RuntimeCompletionCandidates; import org.apache.camel.dsl.jbang.core.common.RuntimeType; @@ -159,27 +160,28 @@ public Integer doCall() throws Exception { .map(CatalogBaseDTO::toMap) .collect(Collectors.toList()))); } else { - // Compute description width: terminal minus fixed columns and border overhead - int fixedWidth = nameWidth() + 12 + 8; // LEVEL ~12 chars, SINCE ~8 chars - if (RuntimeType.quarkus == runtime) { - fixedWidth += 8; // NATIVE column - } - int descWidth = TerminalWidthHelper.flexWidth( - terminalWidth(), fixedWidth, TerminalWidthHelper.noBorderOverhead( - RuntimeType.quarkus == runtime ? 5 : 4), - 20, 80); + // Size the DESCRIPTION column to fill the terminal: measure the actual width of the other + // visible columns and give the remainder to DESCRIPTION (floored on narrow terminals). + boolean quarkus = RuntimeType.quarkus == runtime; + Function nameGetter = displayGav ? this::shortGav : r -> r.name; + int nameW = CamelTableColumns.measure(displayGav ? "ARTIFACT-ID" : "NAME", + displayGav ? Integer.MAX_VALUE : CamelTableColumns.NAME_MAX, rows, nameGetter); + int levelW = CamelTableColumns.measure("LEVEL", Integer.MAX_VALUE, rows, this::level); + int sinceW = CamelTableColumns.measure("SINCE", Integer.MAX_VALUE, rows, r -> r.since); + int overhead = TerminalWidthHelper.noBorderOverhead(quarkus ? 5 : 4); + int descWidth = quarkus + ? CamelTableColumns.lastColumnWidth(terminalWidth(), overhead, nameW, levelW, sinceW, + CamelTableColumns.measure("NATIVE", Integer.MAX_VALUE, rows, this::nativeSupported)) + : CamelTableColumns.lastColumnWidth(terminalWidth(), overhead, nameW, levelW, sinceW); printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( - new Column().header("NAME").visible(!displayGav).dataAlign(HorizontalAlign.LEFT).maxWidth(nameWidth()) - .with(r -> r.name), + CamelTableColumns.name().visible(!displayGav).with(r -> r.name), new Column().header("ARTIFACT-ID").visible(displayGav).dataAlign(HorizontalAlign.LEFT) .with(this::shortGav), new Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).with(this::level), new Column().header("NATIVE").dataAlign(HorizontalAlign.CENTER) - .visible(RuntimeType.quarkus == runtime).with(this::nativeSupported), - new Column().header("SINCE").dataAlign(HorizontalAlign.RIGHT).with(r -> r.since), - new Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT) - .maxWidth(descWidth, OverflowBehaviour.ELLIPSIS_RIGHT) - .with(this::shortDescription)))); + .visible(quarkus).with(this::nativeSupported), + CamelTableColumns.since().with(r -> r.since), + CamelTableColumns.lastText("DESCRIPTION", descWidth).with(this::shortDescription)))); } } else if (filterName != null) { // suggest similar names when filter returns no results @@ -196,10 +198,6 @@ public Integer doCall() throws Exception { return 0; } - int nameWidth() { - return 30; - } - int sortRow(Row o1, Row o2) { String s = sort; int negate = 1; diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java index bc00499e416ed..18cae77dd45cd 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogKamelet.java @@ -29,6 +29,7 @@ import com.github.freva.asciitable.HorizontalAlign; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CamelTableColumns; import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper; import org.apache.camel.dsl.jbang.core.common.VersionHelper; import org.apache.camel.main.download.DependencyDownloaderClassLoader; @@ -113,18 +114,17 @@ public Integer doCall() throws Exception { rows.sort(this::sortRow); if (!rows.isEmpty()) { - int tw = terminalWidth(); - // Fixed columns: NAME (~30), TYPE (10), LEVEL (12) - int fixedWidth = 30 + 10 + 12; - int descWidth = TerminalWidthHelper.flexWidth( - tw, fixedWidth, TerminalWidthHelper.noBorderOverhead(4), - 20, 80); + // Size the DESCRIPTION column to fill the terminal from the measured width of the other columns. + int nameW = CamelTableColumns.measure("NAME", CamelTableColumns.NAME_MAX, rows, r -> r.name); + int typeW = Math.max(10, CamelTableColumns.measure("TYPE", Integer.MAX_VALUE, rows, r -> r.type)); + int levelW = Math.max(12, CamelTableColumns.measure("LEVEL", Integer.MAX_VALUE, rows, r -> r.supportLevel)); + int descWidth = CamelTableColumns.lastColumnWidth( + terminalWidth(), TerminalWidthHelper.noBorderOverhead(4), nameW, typeW, levelW); printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( - new Column().header("NAME").dataAlign(HorizontalAlign.LEFT).with(r -> r.name), + CamelTableColumns.name().with(r -> r.name), new Column().header("TYPE").dataAlign(HorizontalAlign.LEFT).minWidth(10).with(r -> r.type), new Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).minWidth(12).with(r -> r.supportLevel), - new Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT).maxWidth(descWidth) - .with(this::getDescription)))); + CamelTableColumns.lastText("DESCRIPTION", descWidth).with(this::getDescription)))); } return 0; diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java index 0d44412ff8165..317538e1c127b 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/catalog/CatalogTransformer.java @@ -35,11 +35,6 @@ public CatalogTransformer(CamelJBangMain main) { super(main); } - @Override - int nameWidth() { - return 60; - } - @Override List collectRows() { List rows = new ArrayList<>(); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java index 474cdb266f6aa..e59bfd51653ad 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraBaseCommand.java @@ -48,6 +48,7 @@ import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CamelTableColumns; import org.apache.camel.dsl.jbang.core.common.CommandLineHelper; import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper; import org.apache.camel.dsl.jbang.core.model.InfraBaseDTO; @@ -192,40 +193,54 @@ public int listServices(Consumer> serviceConsumer) throws IOException if (jsonOutput) { printer().println( Jsoner.serialize( - rows.stream().map(row -> { - Object serviceDataObj = null; - try { - serviceDataObj = Jsoner.deserialize(row.serviceData()); - } catch (DeserializationException e) { - // ignore - } - return new InfraBaseDTO(row.alias, row.aliasImplementation, row.description, serviceDataObj); - }) + rows.stream().map(row -> new InfraBaseDTO( + row.alias, row.aliasImplementation, row.description, + parseServiceData(row.serviceData()))) .map(InfraBaseDTO::toMap) .collect(Collectors.toList()))); } else { int tw = terminalWidth(); - // Fixed columns: PID (~8), ALIAS (width+2), SERVICE_DATA (~30), DESCRIPTION (~30) - int fixedWidth = (width + 2) + 30 + 30; - if (showPidColumn()) { - fixedWidth += 8; - } - int implWidth = TerminalWidthHelper.flexWidth( - tw, fixedWidth, TerminalWidthHelper.noBorderOverhead(showPidColumn() ? 5 : 4), - 20, 35); + // Size DESCRIPTION to fill the terminal: measure the other columns so it gets the exact remainder. + // IMPLEMENTATION is capped and SERVICE_DATA keeps a compact fixed width; both truncate with an ellipsis + // instead of overflowing the terminal (the full, structured service data is available via --json). + int serviceDataWidth = 30; + int aliasWidth = width + 2; + int pidWidth = showPidColumn() + ? CamelTableColumns.measure("PID", Integer.MAX_VALUE, rows, r -> r.pid) : 0; + int implWidth = CamelTableColumns.measure("IMPLEMENTATION", 35, rows, Row::aliasImplementation); + int overhead = TerminalWidthHelper.noBorderOverhead(showPidColumn() ? 5 : 4); + int descWidth = CamelTableColumns.lastColumnWidth( + tw, overhead, pidWidth, aliasWidth, implWidth, serviceDataWidth); printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( new Column().header("PID").visible(showPidColumn()).headerAlign(HorizontalAlign.CENTER).with(r -> r.pid), - new Column().header("ALIAS").minWidth(width + 2).dataAlign(HorizontalAlign.LEFT) + new Column().header("ALIAS").minWidth(aliasWidth).dataAlign(HorizontalAlign.LEFT) .with(Row::alias), - new Column().header("IMPLEMENTATION").maxWidth(implWidth, OverflowBehaviour.NEWLINE) + new Column().header("IMPLEMENTATION").maxWidth(implWidth, OverflowBehaviour.ELLIPSIS_RIGHT) .dataAlign(HorizontalAlign.LEFT).with(Row::aliasImplementation), - new Column().header("DESCRIPTION").dataAlign(HorizontalAlign.LEFT).with(Row::description), - new Column().header("SERVICE_DATA").dataAlign(HorizontalAlign.LEFT).with(Row::serviceData)))); + CamelTableColumns.lastText("DESCRIPTION", descWidth).with(Row::description), + new Column().header("SERVICE_DATA").maxWidth(serviceDataWidth, OverflowBehaviour.ELLIPSIS_RIGHT) + .dataAlign(HorizontalAlign.LEFT).with(Row::serviceData)))); } return 0; } + /** + * Parses the raw service-data JSON string (read from the infra {@code .json} file) into a structured object so it + * is emitted as nested JSON by {@code --json}, rather than as an escaped string. Returns {@code null} (so the + * {@code serviceData} field is omitted) when there is no data or the stored content is not valid JSON. + */ + static Object parseServiceData(String serviceData) { + if (serviceData == null) { + return null; + } + try { + return Jsoner.deserialize(serviceData); + } catch (DeserializationException e) { + return null; + } + } + private String getServiceData(String key, String pid) { Path jsonFilePath = CommandLineHelper.getCamelDir().resolve(getJsonFileName(key, pid)); if (jsonFilePath.toFile().exists()) { diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java index 57a9182715ec0..739aafdce5ca7 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/update/UpdateList.java @@ -37,6 +37,7 @@ import com.github.freva.asciitable.HorizontalAlign; import org.apache.camel.dsl.jbang.core.commands.CamelCommand; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.common.CamelTableColumns; import org.apache.camel.dsl.jbang.core.common.RuntimeType; import org.apache.camel.dsl.jbang.core.common.TerminalWidthHelper; import org.apache.camel.dsl.jbang.core.common.VersionHelper; @@ -183,21 +184,23 @@ public Integer doCall() throws Exception { .map(UpdateListDTO::toMap) .collect(Collectors.toList()))); } else { - int tw = terminalWidth(); - // Fixed columns: VERSION (10), RUNTIME (~18), RUNTIME VERSION (~17) - int fixedWidth = 10 + 18 + 17; - int descWidth = TerminalWidthHelper.flexWidth( - tw, fixedWidth, TerminalWidthHelper.noBorderOverhead(4), - 20, 80); + // Size the DESCRIPTION column to fill the terminal: measure the actual rendered width of the + // other columns so the remainder handed to the last column is exact (see CamelTableColumns). + int versionW = CamelTableColumns.measure("VERSION", Integer.MAX_VALUE, rows, r -> r.version().toString()); + int runtimeW = CamelTableColumns.measure("RUNTIME", Integer.MAX_VALUE, rows, r -> r.runtime()); + int runtimeVersionW + = CamelTableColumns.measure("RUNTIME VERSION", Integer.MAX_VALUE, rows, r -> r.runtimeVersion()); + int overhead = TerminalWidthHelper.noBorderOverhead(4); + int descWidth + = CamelTableColumns.lastColumnWidth(terminalWidth(), overhead, versionW, runtimeW, runtimeVersionW); printer().println(AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( - new Column().header("VERSION").minWidth(10).dataAlign(HorizontalAlign.LEFT) + new Column().header("VERSION").dataAlign(HorizontalAlign.LEFT) .with(r -> r.version().toString()), new Column().header("RUNTIME") .dataAlign(HorizontalAlign.LEFT).with(r -> r.runtime()), new Column().header("RUNTIME VERSION") .dataAlign(HorizontalAlign.LEFT).with(r -> r.runtimeVersion()), - new Column().header("DESCRIPTION").maxWidth(descWidth) - .dataAlign(HorizontalAlign.LEFT).with(r -> r.description())))); + CamelTableColumns.lastText("DESCRIPTION", descWidth).with(r -> r.description())))); } return 0; diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java new file mode 100644 index 0000000000000..838ff1b78ab81 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumns.java @@ -0,0 +1,107 @@ +/* + * 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.util.Collection; +import java.util.function.Function; +import java.util.stream.IntStream; + +import com.github.freva.asciitable.Column; +import com.github.freva.asciitable.HorizontalAlign; +import com.github.freva.asciitable.OverflowBehaviour; + +/** + * Standardized {@link Column} definitions for camel-jbang table output. + * + *

+ * The same logical column (NAME, DESCRIPTION, ...) was previously hand-rolled in every command with differing widths + * and overflow behaviour. These factories give a single source of truth so tables render consistently, and so the last + * (rightmost) free-text column can be sized to fill the terminal width. + * + *

+ * Each factory returns a pre-configured {@link Column}; the caller chains {@code .with(getter)} (which returns the + * opaque {@code ColumnData}), so all configuration must happen before {@code with}. + */ +public final class CamelTableColumns { + + /** + * Maximum width for a NAME column. A name longer than this is the rare case; short names render at their content + * width regardless, so this only acts as a ceiling. + */ + public static final int NAME_MAX = 60; + + /** Minimum width for the last free-text column on narrow terminals. */ + public static final int LAST_MIN = 20; + + private CamelTableColumns() { + } + + /** Standard NAME column: left aligned, capped at {@link #NAME_MAX}. */ + public static Column name() { + return new Column().header("NAME").dataAlign(HorizontalAlign.LEFT).maxWidth(NAME_MAX); + } + + /** Standard SINCE column: right aligned. */ + public static Column since() { + return new Column().header("SINCE").dataAlign(HorizontalAlign.RIGHT); + } + + /** + * The rightmost free-text column. Grows up to {@code width} and truncates with an ellipsis so each row stays on a + * single line (use this for flat list/status tables; detail views may keep {@code NEWLINE} wrapping). + * + * @param header the column header + * @param width the maximum width, typically from {@link #lastColumnWidth(int, int, int...)} + */ + public static Column lastText(String header, int width) { + return new Column().header(header).dataAlign(HorizontalAlign.LEFT) + .maxWidth(width, OverflowBehaviour.ELLIPSIS_RIGHT); + } + + /** + * Actual rendered width of a structured column: the longest of the header and any cell value, capped at + * {@code maxWidth}. Pass {@link Integer#MAX_VALUE} for an unbounded column. + * + * @param header the column header (may be {@code null}) + * @param maxWidth the column's maximum width (ceiling) + * @param rows the rows being rendered + * @param getter accessor returning the cell value for a row + */ + public static int measure(String header, int maxWidth, Collection rows, Function getter) { + int width = header != null ? header.length() : 0; + for (T row : rows) { + String value = getter.apply(row); + if (value != null) { + width = Math.max(width, value.length()); + } + } + return Math.min(width, maxWidth); + } + + /** + * Width for the last column so the table reaches the terminal edge: the terminal width minus the (actual) widths of + * the other columns and the border overhead, floored at {@link #LAST_MIN}. + * + * @param terminalWidth total terminal width in columns + * @param borderOverhead overhead from borders/padding (see {@link TerminalWidthHelper#noBorderOverhead(int)}) + * @param otherWidths the measured widths of every other (visible) column + */ + public static int lastColumnWidth(int terminalWidth, int borderOverhead, int... otherWidths) { + int others = IntStream.of(otherWidths).sum(); + return TerminalWidthHelper.fillWidth(terminalWidth, others, borderOverhead, LAST_MIN); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java index e096f8f961502..24e1e7e3ff045 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelper.java @@ -16,12 +16,17 @@ */ package org.apache.camel.dsl.jbang.core.common; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * Helper for detecting the terminal width to adapt table and command output. * *

- * Uses the {@code COLUMNS} environment variable or {@code stty size} to detect the terminal width. Falls back to a - * default width when the terminal size cannot be determined (e.g., when output is piped or redirected). + * Uses the {@code COLUMNS} environment variable, {@code stty size} (POSIX) or {@code mode con} (Windows) to detect the + * terminal width. Falls back to a default width when the terminal size cannot be determined (e.g., when output is piped + * or redirected). * *

* Avoids using JLine's {@code TerminalBuilder} for width detection because it sends escape sequence queries (DA1, CPR) @@ -32,6 +37,8 @@ public final class TerminalWidthHelper { private static final int DEFAULT_WIDTH = 120; private static final int MIN_WIDTH = 40; + private static final Pattern INTEGER = Pattern.compile("\\d+"); + private TerminalWidthHelper() { } @@ -39,8 +46,8 @@ private TerminalWidthHelper() { * Returns the current terminal width in columns. * *

- * Tries {@code COLUMNS} environment variable first, then {@code stty size}. Returns {@value #DEFAULT_WIDTH} if - * detection fails or if the output is not connected to a terminal. + * Tries the {@code COLUMNS} environment variable first, then {@code stty size} on POSIX or {@code mode con} on + * Windows. Returns {@value #DEFAULT_WIDTH} if detection fails or if the output is not connected to a terminal. */ public static int getTerminalWidth() { // Try COLUMNS env var first (set by most shells) @@ -55,26 +62,58 @@ public static int getTerminalWidth() { // ignore } } - // Fall back to stty which reads the terminal size without escape sequences + // Fall back to an OS native command that reads the terminal size without escape sequences + int w = isWindows() + ? readWidthFromCommand("cmd", "/c", "mode", "con") + : readWidthFromCommand("stty", "size"); + if (w > 0) { + return Math.max(w, MIN_WIDTH); + } + return DEFAULT_WIDTH; + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("windows"); + } + + private static int readWidthFromCommand(String... command) { try { - Process p = new ProcessBuilder("stty", "size") + Process p = new ProcessBuilder(command) .redirectInput(ProcessBuilder.Redirect.INHERIT) .start(); - String output = new String(p.getInputStream().readAllBytes()).trim(); + String output = new String(p.getInputStream().readAllBytes()); p.waitFor(); - if (!output.isEmpty()) { - String[] parts = output.split("\\s+"); - if (parts.length >= 2) { - int w = Integer.parseInt(parts[1]); - if (w > 0) { - return Math.max(w, MIN_WIDTH); - } - } - } + return parseColumns(output); } catch (Exception e) { - // ignore — stty not available (e.g. Windows) + // ignore — command not available (e.g. stty/mode missing) + return -1; } - return DEFAULT_WIDTH; + } + + /** + * Parses the column count from the output of {@code stty size} ("rows cols") or Windows {@code mode con} (a + * multi-line "Lines: N / Columns: N" block). Both place the column count as the second integer in the + * output, so it is parsed positionally rather than by label to survive localized Windows output where the + * {@code Columns:} label is translated. + * + * @param output the raw command output + * @return the column count, or {@code -1} if it cannot be determined + */ + static int parseColumns(String output) { + if (output == null) { + return -1; + } + Matcher m = INTEGER.matcher(output); + Integer first = null; + while (m.find()) { + int value = Integer.parseInt(m.group()); + if (first == null) { + first = value; + } else { + return value; + } + } + return -1; } /** @@ -96,6 +135,23 @@ public static int flexWidth( return Math.max(minFlexWidth, Math.min(maxFlexWidth, available)); } + /** + * Computes the width for the last column so the table fills the terminal width. Unlike + * {@link #flexWidth(int, int, int, int, int)} there is no upper cap: the column grows all the way to the terminal + * edge. + * + * @param terminalWidth total terminal width in columns + * @param fixedColumnsWidth sum of the (actual) widths of all other columns + * @param borderOverhead overhead from table borders and padding (use {@link #noBorderOverhead(int)} or + * {@link #fancyBorderOverhead(int)}) + * @param minWidth minimum width for the last column (used when terminal is narrow) + * @return the computed width for the last column + */ + public static int fillWidth( + int terminalWidth, int fixedColumnsWidth, int borderOverhead, int minWidth) { + return Math.max(minWidth, terminalWidth - fixedColumnsWidth - borderOverhead); + } + /** * Scales a column width proportionally based on available terminal width. All columns with the given preferred * widths are scaled proportionally to fit within the terminal. @@ -119,10 +175,11 @@ public static int scaleWidth( } /** - * Border overhead for NO_BORDERS tables: 2 spaces between each column pair. + * Border overhead for NO_BORDERS tables. AsciiTable pads every column with one leading and one trailing space (no + * separator characters), so the overhead is exactly {@code 2 * columnCount} regardless of the number of columns. */ public static int noBorderOverhead(int columnCount) { - return (columnCount - 1) * 2; + return columnCount * 2; } /** diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java index f82c23ac25f3d..e8983b74be705 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/infra/InfraTest.java @@ -21,6 +21,8 @@ import org.apache.camel.dsl.jbang.core.commands.CamelCommandBaseTestSupport; import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain; +import org.apache.camel.dsl.jbang.core.model.InfraBaseDTO; +import org.apache.camel.util.json.Jsoner; import org.assertj.core.api.Assertions; import org.awaitility.Awaitility; import org.junit.jupiter.api.Test; @@ -69,4 +71,32 @@ public void listServices() throws Exception { Assertions.assertThat(output).contains("amqp"); Assertions.assertThat(output).contains("minio"); } + + @Test + public void serviceDataInJsonIsNestedNotEscaped() { + String rawServiceData = "{\"host\":\"localhost\",\"port\":61616}"; + Object serviceData = InfraBaseCommand.parseServiceData(rawServiceData); + + InfraBaseDTO dto = new InfraBaseDTO("artemis", "amqp", "AMQP broker", serviceData); + String json = Jsoner.serialize(List.of(dto.toMap())); + + // serviceData is embedded as a nested JSON object (so --json is machine-parseable), not a string-escaped blob + Assertions.assertThat(json).contains("\"serviceData\":{"); + Assertions.assertThat(json).contains("\"host\":\"localhost\""); + Assertions.assertThat(json).doesNotContain("\\\"host\\\""); + } + + @Test + public void malformedServiceDataIsOmittedFromJson() { + Object serviceData = InfraBaseCommand.parseServiceData("{not valid json"); + + // unparseable content is dropped rather than emitted as an escaped string + Assertions.assertThat(serviceData).isNull(); + + InfraBaseDTO dto = new InfraBaseDTO("artemis", "amqp", "AMQP broker", serviceData); + String json = Jsoner.serialize(List.of(dto.toMap())); + + Assertions.assertThat(json).contains("\"alias\":\"artemis\""); + Assertions.assertThat(json).doesNotContain("serviceData"); + } } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java new file mode 100644 index 0000000000000..7c15ae7687c11 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/CamelTableColumnsTest.java @@ -0,0 +1,110 @@ +/* + * 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.util.Arrays; +import java.util.List; +import java.util.function.Function; + +import com.github.freva.asciitable.AsciiTable; +import com.github.freva.asciitable.Column; +import com.github.freva.asciitable.HorizontalAlign; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CamelTableColumnsTest { + + private static final Function IDENTITY = s -> s; + + // --- measure --- + + @Test + void measureUsesLongestCellWhenWiderThanHeader() { + List rows = List.of("ftp", "salesforce", "kafka"); + // longest value "salesforce" = 10, header "NAME" = 4 + assertEquals(10, CamelTableColumns.measure("NAME", CamelTableColumns.NAME_MAX, rows, IDENTITY)); + } + + @Test + void measureUsesHeaderWhenWiderThanCells() { + List rows = List.of("x", "y"); + // header "DESCRIPTION" = 11 is wider than any 1-char value + assertEquals(11, CamelTableColumns.measure("DESCRIPTION", Integer.MAX_VALUE, rows, IDENTITY)); + } + + @Test + void measureCapsAtMaxWidth() { + List rows = List.of("a-very-long-component-name-that-exceeds-the-cap-by-a-lot-indeed"); + // value length is > NAME_MAX, so it is capped at NAME_MAX + assertEquals(CamelTableColumns.NAME_MAX, + CamelTableColumns.measure("NAME", CamelTableColumns.NAME_MAX, rows, IDENTITY)); + } + + @Test + void measureIgnoresNullCells() { + List rows = Arrays.asList("ok", null, "fine"); + assertEquals(4, CamelTableColumns.measure("N", Integer.MAX_VALUE, rows, IDENTITY)); + } + + // --- lastColumnWidth --- + + @Test + void lastColumnWidthConsumesExactRemainder() { + // 200 terminal - (20 + 10 + 8) others - 6 borders = 156 for the last column + assertEquals(156, CamelTableColumns.lastColumnWidth(200, 6, 20, 10, 8)); + } + + @Test + void lastColumnWidthFloorsAtLastMinOnNarrowTerminal() { + // remainder would be negative, so it is floored at LAST_MIN + assertEquals(CamelTableColumns.LAST_MIN, CamelTableColumns.lastColumnWidth(40, 6, 30, 12, 10)); + } + + // --- rendering: a table sized with lastColumnWidth must fit the terminal --- + + @Test + void lastColumnWidthKeepsRenderedTableWithinTerminal() { + // The whole point of filling the terminal is that the rendered line still fits inside it. A single-line + // description longer than the last column exercises the exact-fit case where an off-by-one in the border + // overhead would overflow the terminal by a couple of columns. + int terminalWidth = 100; + List rows = List.of( + new String[] { + "ftp", "Stable", "4.0", + "A very long component description that definitely exceeds the remaining width and must be truncated" }, + new String[] { "salesforce-streaming", "Preview", "3.1", "short" }); + + int nameW = CamelTableColumns.measure("NAME", CamelTableColumns.NAME_MAX, rows, r -> r[0]); + int levelW = CamelTableColumns.measure("LEVEL", Integer.MAX_VALUE, rows, r -> r[1]); + int sinceW = CamelTableColumns.measure("SINCE", Integer.MAX_VALUE, rows, r -> r[2]); + int descWidth = CamelTableColumns.lastColumnWidth( + terminalWidth, TerminalWidthHelper.noBorderOverhead(4), nameW, levelW, sinceW); + + String table = AsciiTable.getTable(AsciiTable.NO_BORDERS, rows, Arrays.asList( + CamelTableColumns.name().with(r -> r[0]), + new Column().header("LEVEL").dataAlign(HorizontalAlign.LEFT).with(r -> r[1]), + CamelTableColumns.since().with(r -> r[2]), + CamelTableColumns.lastText("DESCRIPTION", descWidth).with(r -> r[3]))); + + for (String line : table.split("\n")) { + assertTrue(line.length() <= terminalWidth, + "Rendered line width %d exceeds terminal %d: [%s]".formatted(line.length(), terminalWidth, line)); + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java index af8e5da22c3eb..3b331cf0dcfb9 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/common/TerminalWidthHelperTest.java @@ -31,6 +31,63 @@ void getTerminalWidthReturnsPositiveValue() { assertTrue(width >= 40, "Terminal width should be at least 40, got: " + width); } + // --- fillWidth --- + + @Test + void fillWidthFillsRemainingSpaceWithoutCap() { + // 200 cols - 80 others - 6 borders = 114 available; no upper cap, so the last column keeps all 114 + assertEquals(114, TerminalWidthHelper.fillWidth(200, 80, 6, 20)); + } + + @Test + void fillWidthFloorsAtMinOnNarrowTerminal() { + // 60 cols - 86 others - 6 borders = -32 available; floored at min 20 + assertEquals(20, TerminalWidthHelper.fillWidth(60, 86, 6, 20)); + } + + // --- parseColumns (shared by stty size and Windows mode con) --- + + @Test + void parseColumnsFromStty() { + // stty size prints "rows cols"; the column count is the second integer + assertEquals(80, TerminalWidthHelper.parseColumns("24 80")); + assertEquals(211, TerminalWidthHelper.parseColumns("51 211\n")); + } + + @Test + void parseColumnsFromWindowsModeCon() { + String output = """ + Status for device CON: + ---------------------- + Lines: 30 + Columns: 120 + Keyboard rate: 31 + Keyboard delay: 1 + Code page: 850 + """; + assertEquals(120, TerminalWidthHelper.parseColumns(output)); + } + + @Test + void parseColumnsFromLocalizedModeCon() { + // Non-English Windows translates the labels; parsing the second integer positionally still works + String output = """ + État du périphérique CON : + -------------------------- + Lignes : 30 + Colonnes : 120 + """; + assertEquals(120, TerminalWidthHelper.parseColumns(output)); + } + + @Test + void parseColumnsReturnsNegativeWhenUndetermined() { + assertEquals(-1, TerminalWidthHelper.parseColumns(null)); + assertEquals(-1, TerminalWidthHelper.parseColumns("")); + assertEquals(-1, TerminalWidthHelper.parseColumns("no numbers here")); + assertEquals(-1, TerminalWidthHelper.parseColumns("42")); // only one integer, no column value + } + // --- flexWidth --- @Test @@ -101,14 +158,16 @@ void scaleWidthNeverExceedsPreferred() { @Test void noBorderOverheadSingleColumn() { - assertEquals(0, TerminalWidthHelper.noBorderOverhead(1)); + // one leading + one trailing padding space around the single column + assertEquals(2, TerminalWidthHelper.noBorderOverhead(1)); } @Test void noBorderOverheadMultipleColumns() { - assertEquals(2, TerminalWidthHelper.noBorderOverhead(2)); - assertEquals(6, TerminalWidthHelper.noBorderOverhead(4)); - assertEquals(16, TerminalWidthHelper.noBorderOverhead(9)); + // each column contributes 2 padding spaces (one on each side) + assertEquals(4, TerminalWidthHelper.noBorderOverhead(2)); + assertEquals(8, TerminalWidthHelper.noBorderOverhead(4)); + assertEquals(18, TerminalWidthHelper.noBorderOverhead(9)); } // --- fancyBorderOverhead --- @@ -168,19 +227,19 @@ void scaledColumnsPreserveWidthOnWideTerminal() { void flexWidthForNoBordersProcessCommand() { // Simulate ListProcess: 9 columns, fixed ~56 chars, NAME flex (max 40), error flex (max 70) int tw = 80; - int borders = TerminalWidthHelper.noBorderOverhead(9); // 16 + int borders = TerminalWidthHelper.noBorderOverhead(9); // 18 int nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40); - // 80 - 56 - 16 = 8, but min is 15 + // 80 - 56 - 18 = 6, but min is 15 assertEquals(15, nameW); tw = 120; nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40); - // 120 - 56 - 16 = 48, capped at max 40 + // 120 - 56 - 18 = 46, capped at max 40 assertEquals(40, nameW); tw = 100; nameW = TerminalWidthHelper.flexWidth(tw, 56, borders, 15, 40); - // 100 - 56 - 16 = 28 - assertEquals(28, nameW); + // 100 - 56 - 18 = 26 + assertEquals(26, nameW); } }