From 335530433b8bc7afebe4c309e6c89379343ed746 Mon Sep 17 00:00:00 2001 From: Remko Popma Date: Sat, 6 Apr 2019 01:31:25 +0900 Subject: [PATCH] [#536][#657] optionally interactive options and interactive options of type `char[]` * [#657] Support type `char[]` for interactive options * [#536] Support optionally interactive options Closes #536 Closes #657 --- docs/index.adoc | 59 +- src/main/java/picocli/CommandLine.java | 192 +++-- src/test/java/picocli/CommandLineTest.java | 167 ----- src/test/java/picocli/InteractiveArgTest.java | 665 ++++++++++++++++++ .../java/picocli/ModelOptionSpecTest.java | 11 +- .../picocli/ModelPositionalParamSpecTest.java | 8 +- 6 files changed, 862 insertions(+), 240 deletions(-) create mode 100644 src/test/java/picocli/InteractiveArgTest.java diff --git a/docs/index.adoc b/docs/index.adoc index 92cc41247..dd24af87e 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -150,6 +150,10 @@ assert Arrays.equals(tar.files, new File[] {new File("file1.txt"), new File("fi Picocli 3.5 introduced password support: for options and positional parameters marked as `interactive`, the user is prompted to enter a value on the console. When running on Java 6 or higher, picocli will use the https://docs.oracle.com/javase/8/docs/api/java/io/Console.html#readPassword-java.lang.String-java.lang.Object...-[`Console.readPassword`] API so that user input is not echoed to the console. +==== Example +The example below demonstrates how an interactive option can be used to specify a password. +From picocli 3.9.6, interactive options can use type `char[]` instead of String, to allow applications to null out the array after use so that sensitive information is no longer resident in memory. + Example usage: [source,java] @@ -159,12 +163,21 @@ class Login implements Callable { String user; @Option(names = {"-p", "--password"}, description = "Passphrase", interactive = true) - String password; + char[] password; public Object call() throws Exception { + byte[] bytes = new byte[password.length]; + for (int i = 0; i < bytes.length; i++) { bytes[i] = (byte) password[i]; } + MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(password.getBytes()); - System.out.printf("Hi %s, your passphrase is hashed to %s.%n", user, base64(md.digest())); + md.update(bytes); + + System.out.printf("Hi %s, your password is hashed to %s.%n", user, base64(md.digest())); + + // null out the arrays when done + Arrays.fill(bytes, (byte) 0); + Arrays.fill(password, ' '); + return null; } @@ -189,15 +202,45 @@ After the user enters a password value and presses enter, the `call()` method is Hi user123, your passphrase is hashed to 75K3eLr+dx6JJFuJ7LwIpEpOFmwGZZkRiB84PURz6U8=. ---- +==== Optionally Interactive +Interactive options by default cause the application to wait for input on stdin. For commands that need to be run interactively as well as in batch mode, it is useful if the option can optionally consume an argument from the command line. + +The default <> for interactive options is zero, meaning that the option takes no parameters. From picocli 3.9.6, interactive options can also take a value from the command line if configured with `arity = "0..1"`. + +For example, if an application has these options: + +```java +@Option(names = "--user") +String user; + +@Option(names = "--password", arity = "0..1", interactive = true) +char[] password; +``` + +With the following input, the `password` field will be initialized to `"123"` without prompting the user for input: + +``` +--password 123 --user Joe +``` + +However, if the password is not specified, the user will be prompted to enter a value. In the following example, the password option has no parameter, so the user will be prompted to type in a value on the console: + +``` +--password --user Joe +``` + + [TIP] -.Supporting both Interactive and Batch (Script) Mode +.Providing Passwords to Batch Scripts Securely ==== -Interactive options will cause the application to wait for input on stdin. If your command also needs to be run in (non-interactive) batch mode, it should provide additional non-interactive alternative options to allow end users to run the command interactively as well as in batch mode. +Note that specifying a password in plain text on the command line or in scripts is not secure. There are alternatives that are more secure. + +One idea is to add a separate different option (that could be named `--password:file`) that takes a `File` or `Path` parameter, where the application reads the password from the specified file. +Another idea is to add a separate different option (that could be named `--password:env`) that takes an environment variable name parameter, where the application gets the password from the user’s environment variables. -In the above example, one idea is to add a `--password:file` option that takes a `File` or `Path` parameter, where the application reads the password from the specified file. -Another idea is to add a `--password:env` option that takes an environment variable name parameter, where the application gets the password from the user’s environment variables. +A command that combines either of these with an interactive `--password` option (with the default `arity = "0"`) allows end users to provide a password without specifying it in plain text on the command line. Such a command can be executed both interactively and in batch mode. -A command that combines either of these with an interactive `--password` option allows end users to provide a password without specifying it in plain text on the command line, and can be executed both interactively and in batch mode. +The `picocli-examples` module has https://github.com/remkop/picocli/blob/master/picocli-examples/src/main/java/picocli/examples/interactive/PasswordDemo.java[an example]. ==== === Short Options diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index 1f81b15c5..5aecb57ed 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -3520,7 +3520,8 @@ private static Range parameterIndex(TypedMember member) { static Range adjustForType(Range result, TypedMember member) { return result.isUnspecified ? defaultArity(member) : result; } - /** Returns the default arity {@code Range}: for {@link Option options} this is 0 for booleans and 1 for + /** Returns the default arity {@code Range}: for interactive options/positional parameters, + * this is "0..1"; for {@link Option options} this is 0 for booleans and 1 for * other types, for {@link Parameters parameters} booleans have arity 0, arrays or Collections have * arity "0..*", and other types have arity 1. * @param field the field whose default arity to return @@ -3528,6 +3529,7 @@ static Range adjustForType(Range result, TypedMember member) { * @since 2.0 */ public static Range defaultArity(Field field) { return defaultArity(new TypedMember(field)); } private static Range defaultArity(TypedMember member) { + if (member.isInteractive()) { return Range.valueOf("0").unspecified(true); } Class type = member.getType(); if (member.isAnnotationPresent(Option.class)) { Class[] typeAttribute = ArgsReflection @@ -3634,6 +3636,8 @@ public int compareTo(Range other) { int result = min - other.min; return (result == 0) ? max - other.max : result; } + /** Returns true for these ranges: 0 and 0..1. */ + boolean isValidForInteractiveArgs() { return (min == 0 && (max == 0 || max == 1)); } } private static void validatePositionalParameters(List positionalParametersFields) { int min = 0; @@ -5069,7 +5073,9 @@ private > ArgSpec(Builder builder) { Range tempArity = builder.arity; if (tempArity == null) { - if (isOption()) { + if (interactive) { + tempArity = Range.valueOf("0"); + } else if (isOption()) { tempArity = (builder.type == null || isBoolean(builder.type)) ? Range.valueOf("0") : Range.valueOf("1"); } else { tempArity = Range.valueOf("1"); @@ -5080,7 +5086,9 @@ private > ArgSpec(Builder builder) { if (builder.type == null) { if (builder.auxiliaryTypes == null || builder.auxiliaryTypes.length == 0) { - if (arity.isVariable || arity.max > 1) { + if (interactive) { + type = char[].class; + } else if (arity.isVariable || arity.max > 1) { type = String[].class; } else if (arity.max == 1) { type = String.class; @@ -5095,9 +5103,13 @@ private > ArgSpec(Builder builder) { } if (builder.auxiliaryTypes == null || builder.auxiliaryTypes.length == 0) { if (type.isArray()) { - auxiliaryTypes = new Class[]{type.getComponentType()}; + if (interactive && type.equals(char[].class)) { + auxiliaryTypes = new Class[]{char[].class}; + } else { + auxiliaryTypes = new Class[]{type.getComponentType()}; + } } else if (Collection.class.isAssignableFrom(type)) { // type is a collection but element type is unspecified - auxiliaryTypes = new Class[] {String.class}; // use String elements + auxiliaryTypes = new Class[] {interactive ? char[].class : String.class}; // use String elements } else if (Map.class.isAssignableFrom(type)) { // type is a map but element type is unspecified auxiliaryTypes = new Class[] {String.class, String.class}; // use String keys and String values } else { @@ -5113,8 +5125,8 @@ private > ArgSpec(Builder builder) { } else { completionCandidates = builder.completionCandidates; } - if (interactive && (arity.min != 1 || arity.max != 1)) { - throw new InitializationException("Interactive options and positional parameters are only supported for arity=1, not for arity=" + arity); + if (interactive && !arity.isValidForInteractiveArgs()) { + throw new InitializationException("Interactive options and positional parameters are only supported for arity=0 and arity=0..1; not for arity=" + arity); } } @@ -6188,6 +6200,7 @@ static boolean isAnnotated(AnnotatedElement e) { boolean isUnmatched() { return isAnnotationPresent(Unmatched.class); } boolean isInjectSpec() { return isAnnotationPresent(Spec.class); } boolean isMultiValue() { return CommandLine.isMultiValue(getType()); } + boolean isInteractive() { return (isOption() && getAnnotation(Option.class).interactive()) || (isParameter() && getAnnotation(Parameters.class).interactive()); } IGetter getter() { return getter; } ISetter setter() { return setter; } Class getType() { return type; } @@ -6727,7 +6740,7 @@ private static Class[] inferTypes(Class propertyType, Class[] annotatio Class[] result = new Class[paramTypes.length]; for (int i = 0; i < paramTypes.length; i++) { if (paramTypes[i] instanceof Class) { result[i] = (Class) paramTypes[i]; continue; } // e.g. Long - if (paramTypes[i] instanceof WildcardType) { // e.g. ? extends Number + else if (paramTypes[i] instanceof WildcardType) { // e.g. ? extends Number WildcardType wildcardType = (WildcardType) paramTypes[i]; Type[] lower = wildcardType.getLowerBounds(); // e.g. [] if (lower.length > 0 && lower[0] instanceof Class) { result[i] = (Class) lower[0]; continue; } @@ -7041,6 +7054,7 @@ private class Interpreter { private final Map, ITypeConverter> converterRegistry = new HashMap, ITypeConverter>(); private boolean isHelpRequested; private int position; + private int interactiveCount; private boolean endOfOptions; private ParseResult.Builder parseResult; @@ -7050,6 +7064,7 @@ private void registerBuiltInConverters() { converterRegistry.put(Object.class, new BuiltIn.StringConverter()); converterRegistry.put(String.class, new BuiltIn.StringConverter()); converterRegistry.put(StringBuilder.class, new BuiltIn.StringBuilderConverter()); + converterRegistry.put(char[].class, new BuiltIn.CharArrayConverter()); converterRegistry.put(CharSequence.class, new BuiltIn.CharSequenceConverter()); converterRegistry.put(Byte.class, new BuiltIn.ByteConverter()); converterRegistry.put(Byte.TYPE, new BuiltIn.ByteConverter()); @@ -7407,6 +7422,7 @@ private void processPositionalParameter(Collection required, Set required, Set 0 || actuallyConsumed > 0) { required.remove(positionalParam); - if (positionalParam.interactive()) { interactiveConsumed++; } + interactiveConsumed = this.interactiveCount - originalInteractiveCount; } argsConsumed = Math.max(argsConsumed, count); while (parseResult.nowProcessing.size() > originalNowProcessingSize + count) { @@ -7548,17 +7564,8 @@ private int applyOption(ArgSpec argSpec, if (!assertNoMissingParameters(argSpec, arity, args)) { return 0; } // #389 collectErrors parsing } - if (argSpec.interactive()) { - String name = argSpec.isOption() ? ((OptionSpec) argSpec).longestName() : "position " + position; - String prompt = String.format("Enter value for %s (%s): ", name, str(argSpec.renderedDescription(), 0)); - if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);} - char[] value = readPassword(prompt); - if (tracer.isDebug()) {tracer.debug("User entered '%s' for %s.%n", value, name);} - workingStack.push(new String(value)); - } - int result; - if (argSpec.type().isArray()) { + if (argSpec.type().isArray() && !(argSpec.interactive() && argSpec.type() == char[].class)) { result = applyValuesToArrayField(argSpec, lookBehind, arity, workingStack, initialized, argDescription); } else if (Collection.class.isAssignableFrom(argSpec.type())) { result = applyValuesToCollectionField(argSpec, lookBehind, arity, workingStack, initialized, argDescription); @@ -7587,65 +7594,96 @@ private int applyValueToSingleValuedField(ArgSpec argSpec, throw new MaxValuesExceededException(CommandLine.this, optionDescription("", argSpec, 0) + " should be specified without '" + value + "' parameter"); } - int result = arity.min; // the number or args we need to consume + int consumed = arity.min; // the number or args we need to consume + String actualValue = value; + char[] interactiveValue = null; Class cls = argSpec.auxiliaryTypes()[0]; // field may be interface/abstract type, use annotation to get concrete type if (arity.min <= 0) { // value may be optional + boolean optionalValueExists = true; // assume we will use the command line value + consumed = 1; // special logic for booleans: BooleanConverter accepts only "true" or "false". if (cls == Boolean.class || cls == Boolean.TYPE) { // boolean option with arity = 0..1 or 0..*: value MAY be a param - if (arity.max > 0 && ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value))) { - result = 1; // if it is a varargs we only consume 1 argument if it is a boolean value - if (!lookBehind.isAttached()) { parseResult.nowProcessing(argSpec, value); } - } else if (lookBehind != LookBehind.ATTACHED_WITH_SEPARATOR) { // if attached, try converting the value to boolean (and fail if invalid value) - // it's okay to ignore value if not attached to option - if (value != null) { - args.push(value); // we don't consume the value - } + boolean optionalWithBooleanValue = arity.max > 0 && ("true".equalsIgnoreCase(value) || "false".equalsIgnoreCase(value)); + if (!optionalWithBooleanValue && lookBehind != LookBehind.ATTACHED_WITH_SEPARATOR) { // if attached, try converting the value to boolean (and fail if invalid value) + // don't process cmdline arg: it's okay to ignore value if not attached to option if (commandSpec.parser().toggleBooleanFlags()) { Boolean currentValue = (Boolean) argSpec.getValue(); - value = String.valueOf(currentValue == null || !currentValue); // #147 toggle existing boolean value + actualValue = String.valueOf(currentValue == null || !currentValue); // #147 toggle existing boolean value } else { - value = "true"; + actualValue = "true"; } + optionalValueExists = false; + consumed = 0; } } else { // non-boolean option with optional value #325, #279 - if (isOption(value)) { - args.push(value); // we don't consume the value - value = ""; - } else if (value == null) { - value = ""; - } else { - if (!lookBehind.isAttached()) { parseResult.nowProcessing(argSpec, value); } + if (isOption(value)) { // value is not a parameter + actualValue = ""; + optionalValueExists = false; + consumed = 0; + } else if (value == null) { // stack is empty, option with arity=0..1 was the last arg + actualValue = ""; + optionalValueExists = false; + consumed = 0; } } - } else { - if (!lookBehind.isAttached()) { parseResult.nowProcessing(argSpec, value); } + // if argSpec is interactive, we may need to read the password from the console: + // - if arity = 0 : ALWAYS read from console + // - if arity = 0..1: ONLY read from console if user specified a non-option value + if (argSpec.interactive() && (arity.max == 0 || !optionalValueExists)) { + interactiveValue = readPassword(argSpec); + consumed = 0; + } + } + if (consumed == 0) { // optional value was not specified on command line, we made something up + if (value != null) { + args.push(value); // we don't consume the command line value + } + } else { // value was non-optional or optional value was actually specified + // process the command line value + if (!lookBehind.isAttached()) { parseResult.nowProcessing(argSpec, value); } // update position for Completers } - if (noMoreValues && value == null) { + if (noMoreValues && actualValue == null && interactiveValue == null) { return 0; } - ITypeConverter converter = getTypeConverter(cls, argSpec, 0); - Object newValue = tryConvert(argSpec, -1, converter, value, cls); + Object newValue = interactiveValue; + String initValueMessage = "Setting %s to *** (masked interactive value) for %4$s%n"; + String overwriteValueMessage = "Overwriting %s value with *** (masked interactive value) for %s%n"; + if (!char[].class.equals(cls) && !char[].class.equals(argSpec.type())) { + if (interactiveValue != null) { + actualValue = new String(interactiveValue); + } + ITypeConverter converter = getTypeConverter(cls, argSpec, 0); + newValue = tryConvert(argSpec, -1, converter, actualValue, cls); + initValueMessage = "Setting %s to '%3$s' (was '%2$s') for %4$s%n"; + overwriteValueMessage = "Overwriting %s value '%s' with '%s' for %s%n"; + } else { + if (interactiveValue == null) { // setting command line arg to char[] field + newValue = actualValue.toCharArray(); + } else { + actualValue = "***"; // mask interactive value + } + } Object oldValue = argSpec.getValue(); - String traceMessage = "Setting %s to '%3$s' (was '%2$s') for %4$s%n"; + String traceMessage = initValueMessage; if (initialized.contains(argSpec)) { if (!isOverwrittenOptionsAllowed()) { throw new OverwrittenOptionException(CommandLine.this, argSpec, optionDescription("", argSpec, 0) + " should be specified only once"); } - traceMessage = "Overwriting %s value '%s' with '%s' for %s%n"; + traceMessage = overwriteValueMessage; } initialized.add(argSpec); if (tracer.isInfo()) { tracer.info(traceMessage, argSpec.toString(), String.valueOf(oldValue), String.valueOf(newValue), argDescription); } argSpec.setValue(newValue); - parseResult.addOriginalStringValue(argSpec, value);// #279 track empty string value if no command line argument was consumed - parseResult.addStringValue(argSpec, value); + parseResult.addOriginalStringValue(argSpec, actualValue);// #279 track empty string value if no command line argument was consumed + parseResult.addStringValue(argSpec, actualValue); parseResult.addTypedValues(argSpec, position, newValue); parseResult.add(argSpec, position); - return result; + return 1; } private int applyValuesToMapField(ArgSpec argSpec, LookBehind lookBehind, @@ -7861,19 +7899,26 @@ private List consumeArguments(ArgSpec argSpec, consumed = consumedCount(i + 1, initialSize, argSpec); lookBehind = LookBehind.SEPARATE; } + if (argSpec.interactive() && argSpec.arity().max == 0) { + consumed = addPasswordToList(argSpec, type, result, consumed, argDescription); + } // now process the varargs if any for (int i = consumed; consumed < arity.max && !args.isEmpty(); i++) { - if (!varargCanConsumeNextValue(argSpec, args.peek())) { break; } - - List typedValuesAtPosition = new ArrayList(); - parseResult.addTypedValues(argSpec, currentPosition++, typedValuesAtPosition); - if (!canConsumeOneArgument(argSpec, arity, consumed, args.peek(), type, argDescription)) { - break; // leave empty list at argSpec.typedValueAtPosition[currentPosition] so we won't try to consume that position again + if (argSpec.interactive() && argSpec.arity().max == 1 && !varargCanConsumeNextValue(argSpec, args.peek())) { + // if interactive and arity = 0..1, we consume from command line if possible (if next arg not an option or subcommand) + consumed = addPasswordToList(argSpec, type, result, consumed, argDescription); + } else { + if (!varargCanConsumeNextValue(argSpec, args.peek())) { break; } + List typedValuesAtPosition = new ArrayList(); + parseResult.addTypedValues(argSpec, currentPosition++, typedValuesAtPosition); + if (!canConsumeOneArgument(argSpec, arity, consumed, args.peek(), type, argDescription)) { + break; // leave empty list at argSpec.typedValueAtPosition[currentPosition] so we won't try to consume that position again + } + consumeOneArgument(argSpec, lookBehind, arity, consumed, args.pop(), type, typedValuesAtPosition, i, argDescription); + result.addAll(typedValuesAtPosition); + consumed = consumedCount(i + 1, initialSize, argSpec); + lookBehind = LookBehind.SEPARATE; } - consumeOneArgument(argSpec, lookBehind, arity, consumed, args.pop(), type, typedValuesAtPosition, i, argDescription); - result.addAll(typedValuesAtPosition); - consumed = consumedCount(i + 1, initialSize, argSpec); - lookBehind = LookBehind.SEPARATE; } if (result.isEmpty() && arity.min == 0 && arity.max <= 1 && isBoolean(type)) { return Arrays.asList((Object) Boolean.TRUE); @@ -7889,6 +7934,22 @@ private int consumedCountMap(int i, int initialSize, ArgSpec arg) { return commandSpec.parser().splitFirst() ? (arg.stringValues().size() - initialSize) / 2 : i; } + private int addPasswordToList(ArgSpec argSpec, Class type, List result, int consumed, String argDescription) { + char[] password = readPassword(argSpec); + if (tracer.isInfo()) { + tracer.info("Adding *** (masked interactive value) to %s for %s%n", argSpec.toString(), argDescription); + } + parseResult.addStringValue(argSpec, "***"); + parseResult.addOriginalStringValue(argSpec, "***"); + if (!char[].class.equals(argSpec.auxiliaryTypes()[0]) && !char[].class.equals(argSpec.type())) { + Object value = tryConvert(argSpec, consumed, getTypeConverter(type, argSpec, consumed), new String(password), type); + result.add(value); + } else { + result.add(password); + } + consumed++; + return consumed; + } private int consumeOneArgument(ArgSpec argSpec, LookBehind lookBehind, Range arity, @@ -7913,6 +7974,7 @@ private int consumeOneArgument(ArgSpec argSpec, return ++index; } private boolean canConsumeOneArgument(ArgSpec argSpec, Range arity, int consumed, String arg, Class type, String argDescription) { + if (char[].class.equals(argSpec.auxiliaryTypes()[0]) || char[].class.equals(argSpec.type())) { return true; } ITypeConverter converter = getTypeConverter(type, argSpec, 0); try { String[] values = argSpec.splitValue(trim(arg), commandSpec.parser(), arity, consumed); @@ -7940,7 +8002,7 @@ private boolean varargCanConsumeNextValue(ArgSpec argSpec, String nextValue) { return !isCommand && !isOption(nextValue); } - /** + /** Returns true if the specified arg is "--", a registered option, or potentially a clustered POSIX option. * Called when parsing varargs parameters for a multi-value option. * When an option is encountered, the remainder should not be interpreted as vararg elements. * @param arg the string to determine whether it is an option or not @@ -8038,6 +8100,7 @@ private Collection createCollection(Class collectionClass, Class e } private ITypeConverter getTypeConverter(final Class type, ArgSpec argSpec, int index) { if (argSpec.converters().length > index) { return argSpec.converters()[index]; } + if (char[].class.equals(argSpec.type())) { return converterRegistry.get(char[].class); } if (converterRegistry.containsKey(type)) { return converterRegistry.get(type); } if (type.isEnum()) { return new ITypeConverter() { @@ -8119,6 +8182,14 @@ private String unquote(String value) { : value; } + char[] readPassword(ArgSpec argSpec) { + String name = argSpec.isOption() ? ((OptionSpec) argSpec).longestName() : "position " + position; + String prompt = String.format("Enter value for %s (%s): ", name, str(argSpec.renderedDescription(), 0)); + if (tracer.isDebug()) {tracer.debug("Reading value for %s from console...%n", name);} + char[] result = readPassword(prompt); + if (tracer.isDebug()) {tracer.debug("User entered %d characters for %s.%n", result.length, name);} + return result; + } char[] readPassword(String prompt) { try { Object console = System.class.getDeclaredMethod("console").invoke(null); @@ -8133,6 +8204,8 @@ char[] readPassword(String prompt) { } catch (IOException ex2) { throw new IllegalStateException(ex2); } + } finally { + interactiveCount++; } } } @@ -8148,6 +8221,9 @@ public int compare(ArgSpec p1, ArgSpec p2) { * Inner class to group the built-in {@link ITypeConverter} implementations. */ private static class BuiltIn { + static class CharArrayConverter implements ITypeConverter { + public char[] convert(String value) { return value.toCharArray(); } + } static class StringConverter implements ITypeConverter { public String convert(String value) { return value; } } diff --git a/src/test/java/picocli/CommandLineTest.java b/src/test/java/picocli/CommandLineTest.java index 75bd3901c..48870f556 100644 --- a/src/test/java/picocli/CommandLineTest.java +++ b/src/test/java/picocli/CommandLineTest.java @@ -3790,173 +3790,6 @@ public void testUnmatchedArgumentDoesNotSuggestOptionsIfNoMatch() { assertFalse(actual, actual.contains("Possible solutions:")); } - @Test - public void testInteractiveOptionReadsFromStdIn() { - class App { - @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true) int x; - @Option(names = "-z") int z; - } - - PrintStream out = System.out; - InputStream in = System.in; - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - System.setIn(new ByteArrayInputStream("123".getBytes())); - - App app = new App(); - CommandLine cmd = new CommandLine(app); - cmd.parse("-x"); - - assertEquals("Enter value for -x (Pwd): ", baos.toString()); - assertEquals(123, app.x); - assertEquals(0, app.z); - - cmd.parse("-z", "678"); - - assertEquals(0, app.x); - assertEquals(678, app.z); - } finally { - System.setOut(out); - System.setIn(in); - } - } - - @Test - public void testInteractiveOptionReadsFromStdInMultiLinePrompt() { - class App { - @Option(names = "-x", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; - @Option(names = "-z") int z; - } - - PrintStream out = System.out; - InputStream in = System.in; - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - System.setIn(new ByteArrayInputStream("123".getBytes())); - - App app = new App(); - CommandLine cmd = new CommandLine(app); - cmd.parse("-x", "-z", "987"); - - String expectedPrompt = format("Enter value for -x (Pwd%nline2): "); - assertEquals(expectedPrompt, baos.toString()); - assertEquals(123, app.x); - assertEquals(987, app.z); - } finally { - System.setOut(out); - System.setIn(in); - } - } - - @Test - public void testInteractivePositionalReadsFromStdIn() { - class App { - @Parameters(index = "0", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; - @Parameters(index = "1") int z; - } - - PrintStream out = System.out; - InputStream in = System.in; - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - System.setIn(new ByteArrayInputStream("123".getBytes())); - - App app = new App(); - CommandLine cmd = new CommandLine(app); - cmd.parse("987"); - - String expectedPrompt = format("Enter value for position 0 (Pwd%nline2): "); - assertEquals(expectedPrompt, baos.toString()); - assertEquals(123, app.x); - assertEquals(987, app.z); - } finally { - System.setOut(out); - System.setIn(in); - } - } - - @Test - public void testInteractivePositional2ReadsFromStdIn() { - class App { - @Parameters(index = "0") int a; - @Parameters(index = "1", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; - @Parameters(index = "2") int z; - } - - PrintStream out = System.out; - InputStream in = System.in; - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - System.setIn(new ByteArrayInputStream("123".getBytes())); - - App app = new App(); - CommandLine cmd = new CommandLine(app); - cmd.parse("333", "987"); - - String expectedPrompt = format("Enter value for position 1 (Pwd%nline2): "); - assertEquals(expectedPrompt, baos.toString()); - assertEquals(333, app.a); - assertEquals(123, app.x); - assertEquals(987, app.z); - } finally { - System.setOut(out); - System.setIn(in); - } - } - - @Test - public void testLoginExample() { - class Login implements Callable { - @Option(names = {"-u", "--user"}, description = "User name") - String user; - - @Option(names = {"-p", "--password"}, description = "Password or passphrase", interactive = true) - String password; - - public Object call() throws Exception { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(password.getBytes()); - System.out.printf("Hi %s, your password is hashed to %s.%n", user, base64(md.digest())); - return null; - } - - private String base64(byte[] arr) throws Exception { - //return javax.xml.bind.DatatypeConverter.printBase64Binary(arr); - try { - Object enc = Class.forName("java.util.Base64").getDeclaredMethod("getEncoder").invoke(null, new Object[0]); - return (String) Class.forName("java.util.Base64$Encoder").getDeclaredMethod("encodeToString", new Class[]{byte[].class}).invoke(enc, new Object[] {arr}); - } catch (Exception beforeJava8) { - //return new sun.misc.BASE64Encoder().encode(arr); - return "75K3eLr+dx6JJFuJ7LwIpEpOFmwGZZkRiB84PURz6U8="; // :-) - } - } - } - - PrintStream out = System.out; - InputStream in = System.in; - try { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - System.setOut(new PrintStream(baos)); - System.setIn(new ByteArrayInputStream("password123".getBytes())); - - Login login = new Login(); - CommandLine.call(login, "-u", "user123", "-p"); - - String expectedPrompt = format("Enter value for --password (Password or passphrase): " + - "Hi user123, your password is hashed to 75K3eLr+dx6JJFuJ7LwIpEpOFmwGZZkRiB84PURz6U8=.%n"); - assertEquals(expectedPrompt, baos.toString()); - assertEquals("user123", login.user); - assertEquals("password123", login.password); - } finally { - System.setOut(out); - System.setIn(in); - } - } - @Test public void testEmptyObjectArray() throws Exception { Method m = CommandLine.class.getDeclaredMethod("empty", new Class[] {Object[].class}); diff --git a/src/test/java/picocli/InteractiveArgTest.java b/src/test/java/picocli/InteractiveArgTest.java new file mode 100644 index 000000000..6b608a269 --- /dev/null +++ b/src/test/java/picocli/InteractiveArgTest.java @@ -0,0 +1,665 @@ +package picocli; + +import org.junit.Test; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; +import picocli.CommandLine.UnmatchedArgumentException; + +import java.io.*; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; + +import static java.lang.String.format; +import static org.junit.Assert.*; + +public class InteractiveArgTest { + + @Test + public void testInteractiveOptionReadsFromStdIn() { + class App { + @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true) int x; + @Option(names = "-z") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(new ByteArrayInputStream("123".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x"); + + assertEquals("Enter value for -x (Pwd): ", baos.toString()); + assertEquals(123, app.x); + assertEquals(0, app.z); + + cmd.parse("-z", "678"); + + assertEquals(0, app.x); + assertEquals(678, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsListOfIntegers() throws IOException { + class App { + @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true) + List x; + + @Option(names = "-z") + int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-x"); + + assertEquals("Enter value for -x (Pwd): Enter value for -x (Pwd): ", baos.toString()); + assertEquals(Arrays.asList(123, 123), app.x); + assertEquals(0, app.z); + + cmd.parse("-z", "678"); + + assertNull(app.x); + assertEquals(678, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + ByteArrayInputStream inputStream(final String value) { + return new ByteArrayInputStream(value.getBytes()) { + int count; + + @Override + public synchronized int read(byte[] b, int off, int len) { + System.arraycopy(value.getBytes(), 0, b, off, value.length()); + return (count++ % 3) == 0 ? value.length() : -1; + } + }; + } + + @Test + public void testInteractiveOptionAsListOfCharArrays() throws IOException { + class App { + @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true) + List x; + + @Option(names = "-z") + int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-x"); + + assertEquals("Enter value for -x (Pwd): Enter value for -x (Pwd): ", baos.toString()); + assertEquals(2, app.x.size()); + assertArrayEquals("123".toCharArray(), app.x.get(0)); + assertArrayEquals("123".toCharArray(), app.x.get(1)); + assertEquals(0, app.z); + + cmd.parse("-z", "678"); + + assertNull(app.x); + assertEquals(678, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsCharArray() throws IOException { + class App { + @Option(names = "-x", description = {"Pwd", "line2"}, interactive = true) + char[] x; + + @Option(names = "-z") + int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x"); + + assertEquals("Enter value for -x (Pwd): ", baos.toString()); + assertArrayEquals("123".toCharArray(), app.x); + assertEquals(0, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionArity_0_1_ConsumesFromCommandLineIfPossible() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + char[] x; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "456", "abc"); + + assertArrayEquals("456".toCharArray(), app.x); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsListOfCharArraysArity_0_1_ConsumesFromCommandLineIfPossible() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + List x; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "456", "-x", "789", "abc"); + + assertEquals(2, app.x.size()); + assertArrayEquals("456".toCharArray(), app.x.get(0)); + assertArrayEquals("789".toCharArray(), app.x.get(1)); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionArity_0_1_AvoidsConsumingOption() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + char[] x; + + @Option(names = "-z") + int z; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-z", "456", "abc"); + + assertArrayEquals("123".toCharArray(), app.x); + assertEquals(456, app.z); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsListOfCharArraysArity_0_1_AvoidsConsumingOption() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + List x; + + @Option(names = "-z") + int z; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-z", "456", "abc"); + + assertEquals(1, app.x.size()); + assertArrayEquals("123".toCharArray(), app.x.get(0)); + assertEquals(456, app.z); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionArity_0_1_ConsumesUnknownOption() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + char[] x; + + @Option(names = "-z") + int z; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-y", "456", "abc"); + + assertArrayEquals("-y".toCharArray(), app.x); + assertEquals(0, app.z); + assertArrayEquals(new String[]{"456", "abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionAsListOfCharArraysArity_0_1_ConsumesUnknownOption() throws IOException { + class App { + @Option(names = "-x", arity = "0..1", interactive = true) + List x; + + @Option(names = "-z") + int z; + + @Parameters() + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-y", "-x", "-w", "456", "abc"); + + assertEquals(2, app.x.size()); + assertArrayEquals("-y".toCharArray(), app.x.get(0)); + assertArrayEquals("-w".toCharArray(), app.x.get(1)); + assertEquals(0, app.z); + assertArrayEquals(new String[]{"456", "abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractiveOptionReadsFromStdInMultiLinePrompt() { + class App { + @Option(names = "-x", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; + @Option(names = "-z") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(new ByteArrayInputStream("123".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("-x", "-z", "987"); + + String expectedPrompt = format("Enter value for -x (Pwd%nline2): "); + assertEquals(expectedPrompt, baos.toString()); + assertEquals(123, app.x); + assertEquals(987, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalReadsFromStdIn() { + class App { + @Parameters(index = "0", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; + @Parameters(index = "1") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(new ByteArrayInputStream("123".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("987"); + + String expectedPrompt = format("Enter value for position 0 (Pwd%nline2): "); + assertEquals(expectedPrompt, baos.toString()); + assertEquals(123, app.x); + assertEquals(987, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositional2ReadsFromStdIn() { + class App { + @Parameters(index = "0") int a; + @Parameters(index = "1", description = {"Pwd%nline2", "ignored"}, interactive = true) int x; + @Parameters(index = "2") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(new ByteArrayInputStream("123".getBytes())); + + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("333", "987"); + + String expectedPrompt = format("Enter value for position 1 (Pwd%nline2): "); + assertEquals(expectedPrompt, baos.toString()); + assertEquals(333, app.a); + assertEquals(123, app.x); + assertEquals(987, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testLoginExample() { + class Login implements Callable { + @Option(names = {"-u", "--user"}, description = "User name") + String user; + + @Option(names = {"-p", "--password"}, description = "Password or passphrase", interactive = true) + char[] password; + + public Object call() throws Exception { + byte[] bytes = new byte[password.length]; + for (int i = 0; i < bytes.length; i++) { bytes[i] = (byte) password[i]; } + + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bytes); + + System.out.printf("Hi %s, your password is hashed to %s.%n", user, base64(md.digest())); + + // null out the arrays when done + Arrays.fill(bytes, (byte) 0); + Arrays.fill(password, ' '); + + return null; + } + + private String base64(byte[] arr) throws Exception { + //return javax.xml.bind.DatatypeConverter.printBase64Binary(arr); + try { + Object enc = Class.forName("java.util.Base64").getDeclaredMethod("getEncoder").invoke(null, new Object[0]); + return (String) Class.forName("java.util.Base64$Encoder").getDeclaredMethod("encodeToString", new Class[]{byte[].class}).invoke(enc, new Object[] {arr}); + } catch (Exception beforeJava8) { + //return new sun.misc.BASE64Encoder().encode(arr); + return "75K3eLr+dx6JJFuJ7LwIpEpOFmwGZZkRiB84PURz6U8="; // :-) + } + } + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("password123")); + + Login login = new Login(); + CommandLine.call(login, "-u", "user123", "-p"); + + String expectedPrompt = format("Enter value for --password (Password or passphrase): " + + "Hi user123, your password is hashed to 75K3eLr+dx6JJFuJ7LwIpEpOFmwGZZkRiB84PURz6U8=.%n"); + assertEquals(expectedPrompt, baos.toString()); + assertEquals("user123", login.user); + assertArrayEquals(" ".toCharArray(), login.password); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalAsListOfCharArrays() throws IOException { + class App { + @Parameters(index = "0..1", description = {"Pwd", "line2"}, interactive = true) + List x; + @Parameters(index = "2") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("999"); + + assertEquals("Enter value for position 0 (Pwd): Enter value for position 1 (Pwd): ", baos.toString()); + assertEquals(2, app.x.size()); + assertArrayEquals("123".toCharArray(), app.x.get(0)); + assertArrayEquals("123".toCharArray(), app.x.get(1)); + assertEquals(999, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalAsCharArray() throws IOException { + class App { + @Parameters(index = "0", description = {"Pwd", "line2"}, interactive = true) + char[] x; + @Parameters(index = "1") int z; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + System.setOut(new PrintStream(baos)); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("9"); + + assertEquals("Enter value for position 0 (Pwd): ", baos.toString()); + assertArrayEquals("123".toCharArray(), app.x); + assertEquals(9, app.z); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalArity_0_1_ConsumesFromCommandLineIfPossible() throws IOException { + class App { + @Parameters(index = "0", arity = "0..1", interactive = true) + char[] x; + + @Parameters(index = "1") + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("456", "abc"); + + assertArrayEquals("456".toCharArray(), app.x); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalAsListOfCharArraysArity_0_1_ConsumesFromCommandLineIfPossible() throws IOException { + class App { + @Parameters(index = "0..1", arity = "0..1", interactive = true) + List x; + + @Parameters(index = "2") + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + cmd.parse("456", "789", "abc"); + + assertEquals(2, app.x.size()); + assertArrayEquals("456".toCharArray(), app.x.get(0)); + assertArrayEquals("789".toCharArray(), app.x.get(1)); + assertArrayEquals(new String[]{"abc"}, app.remainder); + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalArity_0_1_DoesNotConsumeUnknownOption() throws IOException { + class App { + @Parameters(index = "0", arity = "0..1", interactive = true) + char[] x; + + @Option(names = "-z") + int z; + + @Parameters(index = "1") + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + try { + cmd.parse("-y", "456", "abc"); + fail("Expect exception"); + } catch (UnmatchedArgumentException ex) { + assertEquals("Unknown option: -y", ex.getMessage()); + } + } finally { + System.setOut(out); + System.setIn(in); + } + } + + @Test + public void testInteractivePositionalAsListOfCharArraysArity_0_1_DoesNotConsumeUnknownOption() throws IOException { + class App { + @Parameters(index = "0..1", arity = "0..1", interactive = true) + List x; + + @Option(names = "-z") + int z; + + @Parameters(index = "2") + String[] remainder; + } + + PrintStream out = System.out; + InputStream in = System.in; + try { + System.setOut(new PrintStream(new ByteArrayOutputStream())); + System.setIn(inputStream("123")); + App app = new App(); + CommandLine cmd = new CommandLine(app); + try { + cmd.parse("-y", "-w", "456", "abc"); + fail("Expect exception"); + } catch (UnmatchedArgumentException ex) { + assertEquals("Unknown options: -y, -w", ex.getMessage()); + } + } finally { + System.setOut(out); + System.setIn(in); + } + } + +} diff --git a/src/test/java/picocli/ModelOptionSpecTest.java b/src/test/java/picocli/ModelOptionSpecTest.java index fe6c790b0..4a3bce725 100644 --- a/src/test/java/picocli/ModelOptionSpecTest.java +++ b/src/test/java/picocli/ModelOptionSpecTest.java @@ -274,7 +274,7 @@ public void testOptionInteractiveFalseByDefault() { @Test public void testOptionInteractiveIfSet() { assertTrue(OptionSpec.builder("-x").interactive(true).interactive()); - assertTrue(OptionSpec.builder("-x").arity("1").interactive(true).build().interactive()); + assertTrue(OptionSpec.builder("-x").arity("0").interactive(true).build().interactive()); } @Test @@ -294,8 +294,7 @@ class App { @Test public void testOptionInteractiveNotSupportedForMultiValue() { OptionSpec.Builder[] options = new OptionSpec.Builder[]{ - OptionSpec.builder("-x").arity("0").interactive(true), - OptionSpec.builder("-x").arity("0..1").interactive(true), + OptionSpec.builder("-x").arity("1").interactive(true), OptionSpec.builder("-x").arity("2").interactive(true), OptionSpec.builder("-x").arity("3").interactive(true), OptionSpec.builder("-x").arity("1..2").interactive(true), @@ -307,9 +306,13 @@ public void testOptionInteractiveNotSupportedForMultiValue() { opt.build(); fail("Expected exception"); } catch (InitializationException ex) { - assertEquals("Interactive options and positional parameters are only supported for arity=1, not for arity=" + opt.arity(), ex.getMessage()); + assertEquals("Interactive options and positional parameters are only supported for arity=0 and arity=0..1; not for arity=" + opt.arity(), ex.getMessage()); } } + + // no errors + OptionSpec.builder("-x").arity("0").interactive(true).build(); + OptionSpec.builder("-x").arity("0..1").interactive(true).build(); } @Test diff --git a/src/test/java/picocli/ModelPositionalParamSpecTest.java b/src/test/java/picocli/ModelPositionalParamSpecTest.java index 0a595ca7e..11d1316d7 100644 --- a/src/test/java/picocli/ModelPositionalParamSpecTest.java +++ b/src/test/java/picocli/ModelPositionalParamSpecTest.java @@ -198,8 +198,7 @@ public void testPositionalInteractiveIfSet() { @Test public void testPositionalInteractiveNotSupportedForMultiValue() { PositionalParamSpec.Builder[] options = new PositionalParamSpec.Builder[]{ - PositionalParamSpec.builder().arity("0").interactive(true), - PositionalParamSpec.builder().arity("0..1").interactive(true), + PositionalParamSpec.builder().arity("1").interactive(true), PositionalParamSpec.builder().arity("2").interactive(true), PositionalParamSpec.builder().arity("3").interactive(true), PositionalParamSpec.builder().arity("1..2").interactive(true), @@ -211,9 +210,12 @@ public void testPositionalInteractiveNotSupportedForMultiValue() { opt.build(); fail("Expected exception"); } catch (CommandLine.InitializationException ex) { - assertEquals("Interactive options and positional parameters are only supported for arity=1, not for arity=" + opt.arity(), ex.getMessage()); + assertEquals("Interactive options and positional parameters are only supported for arity=0 and arity=0..1; not for arity=" + opt.arity(), ex.getMessage()); } } + // no errors + PositionalParamSpec.builder().arity("0").interactive(true).build(); + PositionalParamSpec.builder().arity("0..1").interactive(true).build(); } @Test