From 6c3bbee4cc873e86662d4ed2408b7621a6a12e53 Mon Sep 17 00:00:00 2001 From: KarimALotfy Date: Sun, 26 Apr 2026 19:39:13 +0200 Subject: [PATCH 1/3] #1457: Improve CLI error messages with suggestions - Add clearer messages when a command requires IDEasy project context. - Detect unknown options and suggest the closest valid option (Did you mean ...). - Suggest closest commandlet name for unknown commands. - Add tests for the new suggestion flows --- .../devonfw/tools/ide/cli/CliSuggester.java | 244 ++++++++++++++++++ .../tools/ide/context/AbstractIdeContext.java | 67 ++++- .../tools/ide/validation/ValidationState.java | 22 ++ .../tools/ide/cli/CliSuggestionTest.java | 81 ++++++ 4 files changed, 411 insertions(+), 3 deletions(-) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java new file mode 100644 index 0000000000..b5605f5402 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java @@ -0,0 +1,244 @@ +package com.devonfw.tools.ide.cli; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.step.StepImpl; +import com.devonfw.tools.ide.tool.ToolCommandlet; +import com.devonfw.tools.ide.validation.ValidationResult; +import com.devonfw.tools.ide.validation.ValidationState; + +/** + * Helper class to suggest corrections for mistyped CLI options, commands, and tools using Levenshtein distance. + */ +public class CliSuggester { + + private static final Logger LOG = LoggerFactory.getLogger(CliSuggester.class); + + private final IdeContext context; + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public CliSuggester(IdeContext context) { + this.context = context; + } + + + /** + * Handles the case where a commandlet requires an IDEasy project context (IDE_ROOT/IDE_HOME) but the user is not inside one. + * + * @param commandlet the {@link Commandlet} that the user tried to run. + * @param step the current {@link StepImpl} for error reporting. + * @return {@code true} if handled (message printed), {@code false} otherwise. + */ + public boolean handleMissingProjectContext(Commandlet commandlet, StepImpl step) { + + if (commandlet == null) { + return false; + } + + boolean missingIdeRoot = commandlet.isIdeRootRequired() && (this.context.getIdeRoot() == null); + boolean missingIdeHome = commandlet.isIdeHomeRequired() && (this.context.getIdeHome() == null); + + if (!(missingIdeRoot || missingIdeHome)) { + return false; + } + + String name = commandlet.getName(); + + // Match your expected output wording (project, not project root) + step.error("The {} commandlet requires to be an IDEasy project to work.", name); + IdeLogLevel.INTERACTION.log(LOG, "Please run \"icd \" before calling \"ide {}\".", name); + IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help\" for additional details."); + + return true; + } + + + /** + * Handles invalid option errors and suggests corrections. + * + * @param result the {@link ValidationResult} from option parsing. + * @param commandlet the {@link Commandlet} that was being executed. + * @param step the current {@link StepImpl} for error reporting. + * @return {@code true} if handled (suggestion provided), {@code false} otherwise. + */ + public boolean handleInvalidOption(ValidationState result, Commandlet commandlet, StepImpl step) { + + if ((result == null) || (commandlet == null)) { + return false; + } + String message = result.getErrorMessage(); + if ((message == null) || !message.contains("Invalid option \"") || result.getInvalidOption() == null) { + return false; + } + String invalidOption = result.getInvalidOption(); + if (invalidOption == null) { + return false; + } + List options = getAllOptionNames(commandlet); + String suggestion = bestSuggestion(invalidOption, options); + + step.error("Invalid option \"{}\".", invalidOption); + if (suggestion != null) { + IdeLogLevel.INTERACTION.log(LOG, "Did you mean \"{}\"?", suggestion); + } + IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help {}\" for additional details.", commandlet.getName()); + return true; + } + + + /** + * Handles missing commandlet errors and suggests corrections. + * + * @param commandKey the command name that was not found. + * @param step the current {@link StepImpl} for error reporting. + * @return {@code true} if handled (suggestion provided), {@code false} otherwise. + */ + public boolean handleMissingCommandlet(String commandKey, StepImpl step) { + + // Try to find a suggestion among commandlets + List commandletNames = getAllCommandletNames(); + String commandletSuggestion = bestSuggestion(commandKey, commandletNames); + + // Try to find a suggestion among tools (if not found in commandlets) + if (commandletSuggestion == null) { + List toolNames = getAllToolNames(); + commandletSuggestion = bestSuggestion(commandKey, toolNames); + } + + if (commandletSuggestion != null) { + step.error("Unknown command \"{}\".", commandKey); + IdeLogLevel.INTERACTION.log(LOG, "Did you mean \"{}\"?", commandletSuggestion); + IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help\" for additional details."); + return true; + } + + return false; + } + + + /** + * Gets all option names for a commandlet. + * + * @param cmd the {@link Commandlet}. + * @return {@link List} of all option names and aliases. + */ + private List getAllOptionNames(Commandlet cmd) { + + List opts = new ArrayList<>(); + for (Property p : cmd.getProperties()) { + if (p.isOption()) { + if (!opts.contains(p.getName())) { + opts.add(p.getName()); + } + if (p.getAlias() != null) { + if (!opts.contains(p.getAlias())) { + opts.add(p.getAlias()); + } + } + } + } + return opts; + } + + /** + * Gets all available commandlet names. + * + * @return {@link List} of commandlet names. + */ + private List getAllCommandletNames() { + + List names = new ArrayList<>(); + for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + names.add(cmd.getName()); + } + return names; + } + + /** + * Gets all available tool names. + * + * @return {@link List} of tool names. + */ + private List getAllToolNames() { + + List names = new ArrayList<>(); + for (Commandlet cmd : this.context.getCommandletManager().getCommandlets()) { + if (cmd instanceof ToolCommandlet) { + names.add(cmd.getName()); + } + } + return names; + } + + /** + * Finds the best matching suggestion from candidates using Levenshtein distance. + * + * @param input the input string to match. + * @param candidates the list of candidate strings. + * @return the best matching candidate, or {@code null} if no suitable match is found. + */ + public String bestSuggestion(String input, List candidates) { + + String best = null; + int bestDist = Integer.MAX_VALUE; + for (String c : candidates) { + int d = levenshtein(input, c); + if (d < bestDist) { + bestDist = d; + best = c; + } + } + if (best != null) { + int threshold = Math.max(3, input.length() / 2); + if (bestDist <= threshold) { + return best; + } + } + return null; + } + + /** + * Calculates the Levenshtein distance between two strings. + * + * @param a the first string. + * @param b the second string. + * @return the edit distance between the strings. + */ + private int levenshtein(String a, String b) { + if (a == null) { + a = ""; + } + if (b == null) { + b = ""; + } + int[] costs = new int[b.length() + 1]; + for (int j = 0; j < costs.length; j++) { + costs[j] = j; + } + for (int i = 1; i <= a.length(); i++) { + costs[0] = i; + int nw = i - 1; + for (int j = 1; j <= b.length(); j++) { + int cj = Math.min(1 + Math.min(costs[j], costs[j - 1]), + a.charAt(i - 1) == b.charAt(j - 1) ? nw : nw + 1); + nw = costs[j]; + costs[j] = cj; + } + } + return costs[b.length()]; + } + +} + diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index f810c7d4d1..79a83aea9f 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -29,6 +29,7 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; import com.devonfw.tools.ide.cli.CliException; +import com.devonfw.tools.ide.cli.CliSuggester; import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.commandlet.CommandletManager; import com.devonfw.tools.ide.commandlet.CommandletManagerImpl; @@ -175,6 +176,8 @@ public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatt private Path logfile; + private CliSuggester cliSuggester; + /** * The constructor. * @@ -1128,6 +1131,10 @@ public void endStep(StepImpl step) { public int run(CliArguments arguments) { CliArgument current = arguments.current(); + if (current.isStart()) { + arguments.next(); + current = arguments.current(); + } assert (this.currentStep == null); boolean supressStepSuccess = false; StepImpl step = newStep(true, "ide", (Object[]) current.asArray()); @@ -1146,6 +1153,24 @@ public int run(CliArguments arguments) { } activateLogging(cmd); verifyIdeMinVersion(false); + String commandKey = current.getKey(); + if (commandKey != null && !commandKey.isBlank()) { + Commandlet commandletByName = findCommandletByName(commandKey); + if (commandletByName != null) { + if (getCliSuggester().handleMissingProjectContext(commandletByName, step)) { + return 1; + } + ValidationState applyResult = (ValidationState) apply(arguments.copy(), commandletByName); + + if (getCliSuggester().handleInvalidOption(applyResult, commandletByName, step)) { + return 1; + } + } else { + if (getCliSuggester().handleMissingCommandlet(commandKey, step)) { + return 1; + } + } + } if (result != null) { LOG.error(result.getErrorMessage()); } @@ -1166,6 +1191,34 @@ public int run(CliArguments arguments) { } } + /** + * Finds the {@link Commandlet} with the given name. + * + * @param name the name of the {@link Commandlet} to find. + * @return the {@link Commandlet} with the given name or {@code null} if no such {@link Commandlet} exists. + */ + private Commandlet findCommandletByName(String name) { + if (name == null) { + return null; + } + for (Commandlet c : this.commandletManager.getCommandlets()) { + if (name.equals(c.getName())) { + return c; + } + } + return null; + } + + /** + * @return the {@link CliSuggester} for CLI suggestions. + */ + private CliSuggester getCliSuggester() { + if (this.cliSuggester == null) { + this.cliSuggester = new CliSuggester(this); + } + return this.cliSuggester; + } + /** * Ensure the logging system is initialized. */ @@ -1326,9 +1379,9 @@ settingsRepository, getSettingsCommitIdPath()))) { /** - * When an update is available for the settings repository, we log a message to the console, reminding the user to run {@code ide update}. - * This method determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not. - * Should the user already be running the appropriate {@code ide update} command, the message is suppressed to avoid confusion. + * When an update is available for the settings repository, we log a message to the console, reminding the user to run {@code ide update}. This method + * determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not. Should the user already be running the + * appropriate {@code ide update} command, the message is suppressed to avoid confusion. * * @param cmd the {@link Commandlet}. * @return {@code msg} to log to the console. {@code null} if the message is suppressed. @@ -1537,6 +1590,14 @@ public ValidationResult apply(CliArguments arguments, Commandlet cmd) { Property option = cmd.getOption(currentArgument.getKey()); if (option != null) { currentProperty = option; + } else { + boolean allowDashedValue = (property != null && property.isValue() && property.isMultiValued()); + if (!allowDashedValue && currentArgument.isOption()) { + ValidationState state = new ValidationState(null); + state.addInvalidOption(currentArgument.getKey()); + state.addErrorMessage("Invalid option \"" + currentArgument.getKey() + "\""); + return state; + } } } if (currentProperty == null) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java b/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java index 5758143e60..27eae14ec1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java +++ b/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java @@ -9,6 +9,9 @@ public class ValidationState implements ValidationResult { private StringBuilder errorMessage; + private String invalidOption; + + /** * The default constructor for no property. */ @@ -36,6 +39,25 @@ public String getErrorMessage() { return this.errorMessage.toString(); } + + /** + * @return the invalid option that caused the error, if applicable, for enhanced error reporting and suggestions. May be {@code null}. + */ + public String getInvalidOption() { + if (this.invalidOption == null) { + return null; + } + return this.invalidOption; + } + + /** + * @param invalidOption the invalid option that caused the error, if applicable, for enhanced error reporting and suggestions. + */ + public void addInvalidOption(String invalidOption) { + this.invalidOption = invalidOption; + } + + /** * @param error the error message to add to this {@link ValidationState}. */ diff --git a/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java b/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java new file mode 100644 index 0000000000..deb4d8a10e --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java @@ -0,0 +1,81 @@ +package com.devonfw.tools.ide.cli; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.log.IdeLogEntry; +import com.devonfw.tools.ide.log.IdeLogLevel; + +/** + * Tests for CLI suggestion behavior: + *
    + *
  • Missing IDE project context
  • + *
  • Invalid option with suggestion
  • + *
  • Unknown commandlet with suggestion
  • + *
+ */ +class CliSuggestionTest extends AbstractIdeContextTest { + + private static final String PROJECT_BASIC = "basic"; + + + @Test + void testMissingProjectContextSuggestsProblem() { + + IdeTestContext context = new IdeTestContext(); + context.getTestStartContext().getEntries().clear(); + + CliArguments args = new CliArguments("update"); + args.next(); + int exit = context.run(args); + + assertThat(exit).isEqualTo(1); + + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.ERROR, "The update commandlet requires to be an IDEasy project to work.", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Please run \"icd \" before calling \"ide update\".", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help\" for additional details.", true) + ); + } + + @Test + void testInvalidOptionSuggestsClosestMatch() { + + IdeTestContext context = newContext(PROJECT_BASIC); + context.getTestStartContext().getEntries().clear(); + + CliArguments args = new CliArguments("upgrade", "--mdoe"); + args.next(); + + int exit = context.run(args); + + assertThat(exit).isEqualTo(1); + + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.ERROR, "Invalid option \"--mdoe\".", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Did you mean \"--mode\"?", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help upgrade\" for additional details.", true) + ); + + } + + @Test + void testUnknownCommandSuggestsClosestCommand() { + + IdeTestContext context = new IdeTestContext(); + context.getTestStartContext().getEntries().clear(); + + CliArguments args = new CliArguments("updtae"); + args.next(); + + int exit = context.run(args); + + assertThat(exit).isEqualTo(1); + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.ERROR, "Unknown command \"updtae\".", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Did you mean \"update\"?", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help\" for additional details.", true) + ); + } +} From 2813996cc5b15592414cf1d700dcdd35f636d95a Mon Sep 17 00:00:00 2001 From: KarimALotfy Date: Thu, 30 Apr 2026 14:30:13 +0200 Subject: [PATCH 2/3] #1788: -Added Error handling for Invalid Arguments -Added in ClI Error the installable Versions as a suggestion when an unfound version is entered --- CHANGELOG.adoc | 3 + .../devonfw/tools/ide/cli/CliSuggester.java | 77 ++++++++++++++++++- .../tools/ide/context/AbstractIdeContext.java | 20 ++++- .../devonfw/tools/ide/property/Property.java | 19 +++++ .../tools/ide/validation/ValidationState.java | 33 ++++++++ .../tools/ide/version/VersionIdentifier.java | 35 ++++++++- .../tools/ide/cli/CliSuggestionTest.java | 50 +++++++++++- .../ide/version/VersionIdentifierTest.java | 37 +++++++++ 8 files changed, 265 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index f80de86736..666b99e641 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -14,6 +14,9 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/451[#451]: Automatically remove macOS quarantine attribute after tool extraction * https://github.com/devonfw/IDEasy/issues/1823[#1823]: Fix IDEasy creates duplicate entries in .gitconfig * https://github.com/devonfw/IDEasy/issues/1853[#1853]: Add ARM releases for VSCode on Mac +* https://github.com/devonfw/IDEasy/issues/1457[#1457]: Improve CLI error messages on invalid args or commanlets + + The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/44?closed=1[milestone 2026.05.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java index b5605f5402..e059e1d0d2 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -9,10 +10,10 @@ import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.property.EnumProperty; import com.devonfw.tools.ide.property.Property; import com.devonfw.tools.ide.step.StepImpl; import com.devonfw.tools.ide.tool.ToolCommandlet; -import com.devonfw.tools.ide.validation.ValidationResult; import com.devonfw.tools.ide.validation.ValidationState; /** @@ -68,7 +69,7 @@ public boolean handleMissingProjectContext(Commandlet commandlet, StepImpl step) /** * Handles invalid option errors and suggests corrections. * - * @param result the {@link ValidationResult} from option parsing. + * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from option parsing. * @param commandlet the {@link Commandlet} that was being executed. * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. @@ -89,10 +90,11 @@ public boolean handleInvalidOption(ValidationState result, Commandlet commandlet List options = getAllOptionNames(commandlet); String suggestion = bestSuggestion(invalidOption, options); - step.error("Invalid option \"{}\".", invalidOption); + step.error("Option \"{}\" not found for commandlet \"{}\"", invalidOption, commandlet.getName()); if (suggestion != null) { IdeLogLevel.INTERACTION.log(LOG, "Did you mean \"{}\"?", suggestion); } + IdeLogLevel.INTERACTION.log(LOG, "Available options are: {}.", String.join(", ", options)); IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help {}\" for additional details.", commandlet.getName()); return true; } @@ -128,6 +130,75 @@ public boolean handleMissingCommandlet(String commandKey, StepImpl step) { } + /** + * Handles invalid argument value errors for properties and suggests corrections. + * + * @param result the {@link com.devonfw.tools.ide.validation.ValidationResult} from argument parsing. + * @param commandlet the {@link Commandlet} that was being executed. + * @return {@code true} if handled (suggestion provided), {@code false} otherwise. + */ + public boolean handleInvalidArgument(ValidationState result, Commandlet commandlet) { + + if ((result == null) || (commandlet == null)) { + return false; + } + String invalidValue = result.getInvalidArgument(); + String invalidProperty = result.getInvalidArgumentProperty(); + if (invalidValue == null || invalidProperty == null) { + return false; + } + // Find the property in the commandlet + Property property = null; + for (Property prop : commandlet.getProperties()) { + if (prop.getName().equals(invalidProperty) || (prop.getAlias() != null && prop.getAlias().equals(invalidProperty))) { + property = prop; + break; + } + } + if (property == null) { + return false; + } + // Get valid values for the property + List validValues = getValidValuesForProperty(property); + if (validValues == null || validValues.isEmpty()) { + return false; + } + + String suggestion = bestSuggestion(invalidValue, validValues); + + if (suggestion != null) { + IdeLogLevel.INTERACTION.log(LOG, "Did you mean \"{}={}\"?", property.getName(), suggestion); + } + IdeLogLevel.INTERACTION.log(LOG, "Valid values for '{}' are: {}.", invalidProperty, String.join(", ", validValues)); + IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help {}\" for additional details.", commandlet.getName()); + return true; + } + + //------------------------- Helper methods------------------------- + + /** + * Gets valid values for a property (especially for Enum properties). + * + * @param property the {@link Property}. + * @return a {@link List} of valid values, or {@code null} if the property doesn't have a limited set of valid values. + */ + private List getValidValuesForProperty(Property property) { + + List validValues = new ArrayList<>(); + + // Check if the property is an EnumProperty + if (property instanceof EnumProperty enumProperty) { + Enum[] enumConstants = enumProperty.getValueType().getEnumConstants(); + if (enumConstants != null) { + for (Enum enumConstant : enumConstants) { + validValues.add(enumConstant.name().toLowerCase(Locale.ROOT)); + } + } + } + + return validValues.isEmpty() ? null : validValues; + } + /** * Gets all option names for a commandlet. * diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 79a83aea9f..279cba74c3 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1160,10 +1160,13 @@ public int run(CliArguments arguments) { if (getCliSuggester().handleMissingProjectContext(commandletByName, step)) { return 1; } - ValidationState applyResult = (ValidationState) apply(arguments.copy(), commandletByName); - - if (getCliSuggester().handleInvalidOption(applyResult, commandletByName, step)) { - return 1; + if (cmd == commandletByName && result instanceof ValidationState validationState) { + if (getCliSuggester().handleInvalidOption(validationState, commandletByName, step)) { + return 1; + } + if (getCliSuggester().handleInvalidArgument(validationState, commandletByName)) { + return 1; + } } } else { if (getCliSuggester().handleMissingCommandlet(commandKey, step)) { @@ -1621,6 +1624,15 @@ public ValidationResult apply(CliArguments arguments, Commandlet cmd) { } boolean matches = currentProperty.apply(arguments, this, cmd, null); if (!matches) { + String invalidValue = currentProperty.getLastInvalidValue(); + if (invalidValue != null) { + ValidationState state = new ValidationState(null); + state.addInvalidArgument(invalidValue, currentProperty.getNameOrAlias()); + state.addErrorMessage( + "Invalid CLI argument '" + invalidValue + "' for property '" + currentProperty.getNameOrAlias() + "' of commandlet '" + cmd.getName() + "'"); + currentProperty.clearLastInvalidValue(); + return state; + } ValidationState state = new ValidationState(null); state.addErrorMessage("No matching property found"); return state; diff --git a/cli/src/main/java/com/devonfw/tools/ide/property/Property.java b/cli/src/main/java/com/devonfw/tools/ide/property/Property.java index dbda7b1271..016841458b 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/property/Property.java +++ b/cli/src/main/java/com/devonfw/tools/ide/property/Property.java @@ -53,6 +53,9 @@ public abstract class Property { /** @see #getValue() */ protected final List value = new ArrayList<>(); + /** The last invalid value that was attempted to be parsed. */ + private String lastInvalidValue; + /** * The constructor. * @@ -308,8 +311,10 @@ public final boolean assignValueAsString(String valueAsString, IdeContext contex try { setValueAsString(valueAsString, context); + this.lastInvalidValue = null; return true; } catch (Exception e) { + this.lastInvalidValue = valueAsString; if (e instanceof IllegalArgumentException) { LOG.warn(INVALID_ARGUMENT, valueAsString, getNameOrAlias(), commandlet.getName()); } else { @@ -319,6 +324,20 @@ public final boolean assignValueAsString(String valueAsString, IdeContext contex } } + /** + * @return the last invalid value that was attempted to be parsed, or {@code null} if no invalid value was attempted or the last attempt was successful. + */ + public String getLastInvalidValue() { + return this.lastInvalidValue; + } + + /** + * Clears the last invalid value. + */ + public void clearLastInvalidValue() { + this.lastInvalidValue = null; + } + /** * @return the {@code null} value. */ diff --git a/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java b/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java index 27eae14ec1..3b9ff7e36d 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java +++ b/cli/src/main/java/com/devonfw/tools/ide/validation/ValidationState.java @@ -11,6 +11,10 @@ public class ValidationState implements ValidationResult { private String invalidOption; + private String invalidArgument; + + private String invalidArgumentProperty; + /** * The default constructor for no property. @@ -57,6 +61,35 @@ public void addInvalidOption(String invalidOption) { this.invalidOption = invalidOption; } + /** + * @return the invalid argument value that caused the error, if applicable, for enhanced error reporting and suggestions. May be {@code null}. + */ + public String getInvalidArgument() { + if (this.invalidArgument == null) { + return null; + } + return this.invalidArgument; + } + + /** + * @return the property name associated with the invalid argument, if applicable. May be {@code null}. + */ + public String getInvalidArgumentProperty() { + if (this.invalidArgumentProperty == null) { + return null; + } + return this.invalidArgumentProperty; + } + + /** + * @param invalidArgument the invalid argument value that caused the error, if applicable, for enhanced error reporting and suggestions. + * @param propertyName the property name associated with the invalid argument. + */ + public void addInvalidArgument(String invalidArgument, String propertyName) { + this.invalidArgument = invalidArgument; + this.invalidArgumentProperty = propertyName; + } + /** * @param error the error message to add to this {@link ValidationState}. diff --git a/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java b/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java index bafadc950e..f00a6c5d15 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java +++ b/cli/src/main/java/com/devonfw/tools/ide/version/VersionIdentifier.java @@ -1,7 +1,9 @@ package com.devonfw.tools.ide.version; +import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -88,8 +90,39 @@ public static VersionIdentifier resolveVersionPattern(GenericVersionRange versio return vi; } } + List closest = findClosestVersions(version, versions, 5); + String closestStr = closest.stream().map(Object::toString).collect(Collectors.joining(", ")); throw new CliException( - "Could not find any version matching '" + version + "' - there are " + versions.size() + " version(s) available but none matched!"); + "Could not find any version matching '" + version + "' - there are " + versions.size() + + " version(s) available but none matched!\nDid you mean one of: " + closestStr + "?"); + } + + /** + * Finds the closest versions to the requested version pattern by matching the major version segment. + * + * @param version the requested version pattern or version. + * @param versions the available versions to choose from. + * @param maxCount the maximum number of versions to return. + * @return a list of the closest matching versions. + */ + private static List findClosestVersions(GenericVersionRange version, List versions, int maxCount) { + + if (version instanceof VersionIdentifier vi && !vi.isPattern()) { + long requestedMajor = vi.getStart().getNumber(); + List majorMatches = new ArrayList<>(); + for (VersionIdentifier v : versions) { + if (v.getStart().getNumber() == requestedMajor) { + majorMatches.add(v); + if (majorMatches.size() >= maxCount) { + break; + } + } + } + if (!majorMatches.isEmpty()) { + return majorMatches; + } + } + return versions.size() <= maxCount ? versions : versions.subList(0, maxCount); } /** diff --git a/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java b/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java index deb4d8a10e..35f7a0907a 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/cli/CliSuggestionTest.java @@ -13,6 +13,7 @@ *
  • Missing IDE project context
  • *
  • Invalid option with suggestion
  • *
  • Unknown commandlet with suggestion
  • + *
  • Invalid enum property value with suggestion
  • * */ class CliSuggestionTest extends AbstractIdeContextTest { @@ -53,8 +54,9 @@ void testInvalidOptionSuggestsClosestMatch() { assertThat(exit).isEqualTo(1); assertThat(context).log().hasEntries( - new IdeLogEntry(IdeLogLevel.ERROR, "Invalid option \"--mdoe\".", true), + new IdeLogEntry(IdeLogLevel.ERROR, "Option \"--mdoe\" not found for commandlet \"upgrade\"", true), new IdeLogEntry(IdeLogLevel.INTERACTION, "Did you mean \"--mode\"?", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Available options are: --mode.", true), new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help upgrade\" for additional details.", true) ); @@ -78,4 +80,50 @@ void testUnknownCommandSuggestsClosestCommand() { new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help\" for additional details.", true) ); } + + @Test + void testInvalidEnumValueSuggestsCorrectValue() { + + IdeTestContext context = newContext(PROJECT_BASIC); + context.getTestStartContext().getEntries().clear(); + + // Test set-edition commandlet with invalid --cfg value 'config' + // Valid values are: user, settings, workspace, conf (from EnvironmentVariablesFiles enum) + CliArguments args = new CliArguments("set-edition", "java", "11", "--cfg", "config"); + args.next(); + + int exit = context.run(args); + + assertThat(exit).isEqualTo(1); + + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.WARNING, + "Invalid CLI argument 'config' for property '--cfg' of commandlet 'set-edition'", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Did you mean \"--cfg=conf\"?", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Valid values for '--cfg' are: user, settings, workspace, conf.", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help set-edition\" for additional details.", true) + ); + } + + + @Test + void testInvalidEnumValueWithoutSuggestionStillShowsAllowedValues() { + + IdeTestContext context = newContext(PROJECT_BASIC); + context.getTestStartContext().getEntries().clear(); + + CliArguments args = new CliArguments("set-edition", "java", "11", "--cfg", "zzzzzz"); + args.next(); + + int exit = context.run(args); + + assertThat(exit).isEqualTo(1); + assertThat(context).log().hasEntries( + new IdeLogEntry(IdeLogLevel.WARNING, + "Invalid CLI argument 'zzzzzz' for property '--cfg' of commandlet 'set-edition'", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Valid values for '--cfg' are: user, settings, workspace, conf.", true), + new IdeLogEntry(IdeLogLevel.INTERACTION, "Call \"ide help set-edition\" for additional details.", true) + ); + assertThat(context).log().hasNoMessageContaining("Did you mean"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java b/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java index c0abaefbde..7c57fd68cd 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/version/VersionIdentifierTest.java @@ -9,6 +9,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import com.devonfw.tools.ide.cli.CliException; + /** * Test of {@link VersionIdentifier}. */ @@ -358,4 +360,39 @@ void testIsStable() { assertThat(VersionIdentifier.LATEST_UNSTABLE.isStable()).isFalse(); } + /** Test of {@link VersionIdentifier#resolveVersionPattern(GenericVersionRange, List)} - exact version found. */ + @Test + void testResolveVersionPatternVersionFound() { + + final VersionIdentifier identifier = VersionIdentifier.of("2025.01.002"); + final List availableVersions = List.of(identifier); + final VersionIdentifier resolvedVersion = VersionIdentifier.resolveVersionPattern(identifier, availableVersions); + assertThat(resolvedVersion).isEqualTo(identifier); + } + + /** Test of {@link VersionIdentifier#resolveVersionPattern(GenericVersionRange, List)} - pattern version found. */ + @Test + void testResolveVersionPatternVersionFoundPattern() { + + final VersionIdentifier identifier = VersionIdentifier.of("2025.01.002"); + final VersionIdentifier identifierPattern = VersionIdentifier.of("2025.01.*"); + final List availableVersions = List.of(identifier); + final VersionIdentifier resolvedVersion = VersionIdentifier.resolveVersionPattern(identifierPattern, availableVersions); + assertThat(resolvedVersion).isEqualTo(identifier); + } + + /** Test of {@link VersionIdentifier#resolveVersionPattern(GenericVersionRange, List)} - no version found with suggestions. */ + @Test + void testResolveVersionPatternNoVersionFound() { + + final VersionIdentifier identifier = VersionIdentifier.of("2025.01.002"); + final VersionIdentifier identifierPattern = VersionIdentifier.of("2026.01.*"); + final List availableVersions = List.of(identifier); + final String expectedMessage = "Could not find any version matching '" + identifierPattern + + "' - there are " + availableVersions.size() + " version(s) available but none matched!\nDid you mean one of: " + identifier + "?"; + + assertThatThrownBy(() -> VersionIdentifier.resolveVersionPattern(identifierPattern, availableVersions)) + .isInstanceOf(CliException.class) + .hasMessage(expectedMessage); + } } From f7542b505f8f91bbb062c08b2e682d82458f4774 Mon Sep 17 00:00:00 2001 From: KarimALotfy Date: Thu, 7 May 2026 17:21:35 +0200 Subject: [PATCH 3/3] #1457: Refactoring - Refactored methods in CliSuggester to match coding-conventions - Improved readability in run method in AbstractIdeContext by reducing nested code - Change in CHANGELOG.adoc --- CHANGELOG.adoc | 2 +- .../devonfw/tools/ide/cli/CliSuggester.java | 9 ++-- .../tools/ide/context/AbstractIdeContext.java | 46 +++++++++++-------- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 4d7b0f4981..067b709344 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -15,7 +15,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/1823[#1823]: Fix IDEasy creates duplicate entries in .gitconfig * https://github.com/devonfw/IDEasy/issues/1724[#1724]: Add gui commandlet * https://github.com/devonfw/IDEasy/issues/1853[#1853]: Add ARM releases for VSCode on Mac -* https://github.com/devonfw/IDEasy/issues/1457[#1457]: Improve CLI error messages on invalid args or options or commanlets +* https://github.com/devonfw/IDEasy/issues/1457[#1457]: Improve CLI error messages on invalid args or commandlets not available in current context * https://github.com/devonfw/IDEasy/issues/1723[#1723]: Add commandlet for GitHub Copilot CLI The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/44?closed=1[milestone 2026.05.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java index e059e1d0d2..6d4ebbc1aa 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java +++ b/cli/src/main/java/com/devonfw/tools/ide/cli/CliSuggester.java @@ -42,7 +42,7 @@ public CliSuggester(IdeContext context) { * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (message printed), {@code false} otherwise. */ - public boolean handleMissingProjectContext(Commandlet commandlet, StepImpl step) { + public boolean isMissingProjectContextHandled(Commandlet commandlet, StepImpl step) { if (commandlet == null) { return false; @@ -57,7 +57,6 @@ public boolean handleMissingProjectContext(Commandlet commandlet, StepImpl step) String name = commandlet.getName(); - // Match your expected output wording (project, not project root) step.error("The {} commandlet requires to be an IDEasy project to work.", name); IdeLogLevel.INTERACTION.log(LOG, "Please run \"icd \" before calling \"ide {}\".", name); IdeLogLevel.INTERACTION.log(LOG, "Call \"ide help\" for additional details."); @@ -74,7 +73,7 @@ public boolean handleMissingProjectContext(Commandlet commandlet, StepImpl step) * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean handleInvalidOption(ValidationState result, Commandlet commandlet, StepImpl step) { + public boolean isInvalidOptionHandled(ValidationState result, Commandlet commandlet, StepImpl step) { if ((result == null) || (commandlet == null)) { return false; @@ -107,7 +106,7 @@ public boolean handleInvalidOption(ValidationState result, Commandlet commandlet * @param step the current {@link StepImpl} for error reporting. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean handleMissingCommandlet(String commandKey, StepImpl step) { + public boolean isMissingCommandletHandled(String commandKey, StepImpl step) { // Try to find a suggestion among commandlets List commandletNames = getAllCommandletNames(); @@ -137,7 +136,7 @@ public boolean handleMissingCommandlet(String commandKey, StepImpl step) { * @param commandlet the {@link Commandlet} that was being executed. * @return {@code true} if handled (suggestion provided), {@code false} otherwise. */ - public boolean handleInvalidArgument(ValidationState result, Commandlet commandlet) { + public boolean isInvalidArgumentHandled(ValidationState result, Commandlet commandlet) { if ((result == null) || (commandlet == null)) { return false; diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 279cba74c3..0c70898f84 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1154,29 +1154,35 @@ public int run(CliArguments arguments) { activateLogging(cmd); verifyIdeMinVersion(false); String commandKey = current.getKey(); - if (commandKey != null && !commandKey.isBlank()) { - Commandlet commandletByName = findCommandletByName(commandKey); - if (commandletByName != null) { - if (getCliSuggester().handleMissingProjectContext(commandletByName, step)) { - return 1; - } - if (cmd == commandletByName && result instanceof ValidationState validationState) { - if (getCliSuggester().handleInvalidOption(validationState, commandletByName, step)) { - return 1; - } - if (getCliSuggester().handleInvalidArgument(validationState, commandletByName)) { - return 1; - } - } - } else { - if (getCliSuggester().handleMissingCommandlet(commandKey, step)) { - return 1; - } + + if (commandKey == null || commandKey.isBlank()) { + return 0; + } + Commandlet commandletByName = findCommandletByName(commandKey); + // Missing commandlet + if (commandletByName == null) { + if (getCliSuggester().isMissingCommandletHandled(commandKey, step)) { + return 1; } + return 0; + } + // Missing project context + if (getCliSuggester().isMissingProjectContextHandled(commandletByName, step)) { + return 1; + } + // Only validate options/arguments if same commandlet and proper type + if (cmd != commandletByName || !(result instanceof ValidationState validationState)) { + return 0; + } + // Invalid option + if (getCliSuggester().isInvalidOptionHandled(validationState, commandletByName, step)) { + return 1; } - if (result != null) { - LOG.error(result.getErrorMessage()); + // Invalid argument + if (getCliSuggester().isInvalidArgumentHandled(validationState, commandletByName)) { + return 1; } + LOG.error(result.getErrorMessage()); step.error("Invalid arguments: {}", current.getArgs()); IdeLogLevel.INTERACTION.log(LOG, "For additional details run ide help {}", cmd == null ? "" : cmd.getName()); return 1;