From 3891a8b37598bdaf5e94861b84ac6ecd4ca6e71c Mon Sep 17 00:00:00 2001
From: Janne Valkealahti
Date: Mon, 30 May 2022 21:26:11 +0100
Subject: [PATCH] Add support for exit codes
- New configurations to CommandRegistration
- Re-using exit code concepts from boot
- Handling exit codes only in non-interactive mode
- Adding e2e commands and tests for better coverage
- Fixes #431
---
.vscode/launch.json | 60 +++++++
.../test/sample-e2e-exit-code.test.ts | 153 ++++++++++++++++++
.../shell/boot/ExitCodeAutoConfiguration.java | 87 ++++++++++
.../boot/SpringShellAutoConfiguration.java | 7 +-
.../main/resources/META-INF/spring.factories | 1 +
.../java/org/springframework/shell/Shell.java | 32 +++-
.../shell/command/CommandExitCode.java | 68 ++++++++
.../shell/command/CommandRegistration.java | 102 +++++++++++-
.../shell/exit/ExitCodeMappings.java | 37 +++++
.../org/springframework/shell/ShellTests.java | 13 +-
.../command/CommandRegistrationTests.java | 33 +++-
.../using-shell-commands-exitcode.adoc | 39 +++++
.../main/asciidoc/using-shell-commands.adoc | 2 +
.../shell/docs/ExitCodeSnippets.java | 53 ++++++
.../shell/samples/standard/E2ECommands.java | 37 +++++
.../shell/standard/commands/Script.java | 3 +-
16 files changed, 707 insertions(+), 20 deletions(-)
create mode 100644 .vscode/launch.json
create mode 100644 e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
create mode 100644 spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/command/CommandExitCode.java
create mode 100644 spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeMappings.java
create mode 100644 spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc
create mode 100644 spring-shell-docs/src/test/java/org/springframework/shell/docs/ExitCodeSnippets.java
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 00000000..3398db61
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,60 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "type": "java",
+ "name": "Sample interactive",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples"
+ },
+ {
+ "type": "java",
+ "name": "Sample help",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "help"
+ },
+ {
+ "type": "java",
+ "name": "Sample fail noarg",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "fail"
+ },
+ {
+ "type": "java",
+ "name": "Sample fail arg",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "fail --elementType TYPE"
+ },
+ {
+ "type": "java",
+ "name": "e2e exit-code noarg",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg exit-code"
+ },
+ {
+ "type": "java",
+ "name": "e2e exit-code arg hi",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg exit-code --arg1 hi"
+ },
+ {
+ "type": "java",
+ "name": "e2e exit-code arg fun",
+ "request": "launch",
+ "mainClass": "org.springframework.shell.samples.SpringShellSample",
+ "projectName": "spring-shell-samples",
+ "args": "e2e reg exit-code --arg1 fun"
+ }
+ ]
+}
diff --git a/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts b/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
new file mode 100644
index 00000000..def5dc74
--- /dev/null
+++ b/e2e/spring-shell-e2e-tests/test/sample-e2e-exit-code.test.ts
@@ -0,0 +1,153 @@
+import 'jest-extended';
+import waitForExpect from 'wait-for-expect';
+import { Cli } from 'spring-shell-e2e';
+import {
+ nativeDesc,
+ jarDesc,
+ jarCommand,
+ nativeCommand,
+ jarOptions,
+ waitForExpectDefaultTimeout,
+ waitForExpectDefaultInterval,
+ testTimeout
+} from '../src/utils';
+
+describe('e2e commands exit-code', () => {
+ let cli: Cli;
+ let command: string;
+ let options: string[] = [];
+
+ const missingArgRet2Desc = 'missing arg returns 2';
+ const missingArgRet2Command = ['e2e reg exit-code'];
+ const missingArgRet2 = async (cli: Cli) => {
+ cli.run();
+ await waitForExpect(async () => {
+ const screen = cli.screen();
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('Missing option')]));
+ });
+ await expect(cli.exitCode()).resolves.toBe(2);
+ };
+
+ const customArgRet3Desc = 'custom arg returns 3';
+ const customArgRet3Command = ['e2e reg exit-code --arg1 ok'];
+ const customArgRet3 = async (cli: Cli) => {
+ cli.run();
+ await waitForExpect(async () => {
+ const screen = cli.screen();
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('ok')]));
+ });
+ await expect(cli.exitCode()).resolves.toBe(3);
+ };
+
+ const customArgRet4Desc = 'custom arg returns 4';
+ const customArgRet4Command = ['e2e reg exit-code --arg1 fun'];
+ const customArgRet4 = async (cli: Cli) => {
+ cli.run();
+ await waitForExpect(async () => {
+ const screen = cli.screen();
+ expect(screen).toEqual(expect.arrayContaining([expect.stringContaining('fun')]));
+ });
+ await expect(cli.exitCode()).resolves.toBe(4);
+ };
+
+ beforeEach(async () => {
+ waitForExpect.defaults.timeout = waitForExpectDefaultTimeout;
+ waitForExpect.defaults.interval = waitForExpectDefaultInterval;
+ }, testTimeout);
+
+ afterEach(async () => {
+ cli?.dispose();
+ }, testTimeout);
+
+ /**
+ * fatjar commands
+ */
+ describe(jarDesc, () => {
+ beforeAll(() => {
+ command = jarCommand;
+ options = jarOptions;
+ });
+
+ it(
+ missingArgRet2Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...missingArgRet2Command]
+ });
+ await missingArgRet2(cli);
+ },
+ testTimeout
+ );
+
+ it(
+ customArgRet3Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...customArgRet3Command]
+ });
+ await customArgRet3(cli);
+ },
+ testTimeout
+ );
+
+ it(
+ customArgRet4Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...customArgRet4Command]
+ });
+ await customArgRet4(cli);
+ },
+ testTimeout
+ );
+ });
+
+ /**
+ * native commands
+ */
+ describe(nativeDesc, () => {
+ beforeAll(() => {
+ command = nativeCommand;
+ options = [];
+ });
+
+ it(
+ missingArgRet2Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...missingArgRet2Command]
+ });
+ await missingArgRet2(cli);
+ },
+ testTimeout
+ );
+
+ it(
+ customArgRet3Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...customArgRet3Command]
+ });
+ await customArgRet3(cli);
+ },
+ testTimeout
+ );
+
+ it(
+ customArgRet4Desc,
+ async () => {
+ cli = new Cli({
+ command: command,
+ options: [...options, ...customArgRet4Command]
+ });
+ await customArgRet4(cli);
+ },
+ testTimeout
+ );
+ });
+});
diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
new file mode 100644
index 00000000..38fa449e
--- /dev/null
+++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ExitCodeAutoConfiguration.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.shell.boot;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+import org.springframework.boot.ExitCodeExceptionMapper;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.shell.command.CommandExecution;
+import org.springframework.shell.exit.ExitCodeMappings;
+
+/**
+ * {@link EnableAutoConfiguration Auto-configuration} for exit codes.
+ *
+ * @author Janne Valkealahti
+ */
+@Configuration(proxyBeanMethods = false)
+public class ExitCodeAutoConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public ShellExitCodeExceptionMapper shellExitCodeExceptionMapper() {
+ return new ShellExitCodeExceptionMapper();
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public ShellExitCodeMappingsExceptionMapper shellExitCodeMappingsExceptionMapper() {
+ return new ShellExitCodeMappingsExceptionMapper();
+ }
+
+ static class ShellExitCodeExceptionMapper implements ExitCodeExceptionMapper {
+
+ @Override
+ public int getExitCode(Throwable exception) {
+ if (exception.getCause() instanceof CommandExecution.CommandParserExceptionsException) {
+ return 2;
+ }
+ return 1;
+ }
+ }
+
+ static class ShellExitCodeMappingsExceptionMapper implements ExitCodeExceptionMapper, ExitCodeMappings {
+
+ private final List> functions = new ArrayList<>();
+
+ @Override
+ public void reset(List> functions) {
+ this.functions.clear();
+ if (functions != null) {
+ this.functions.addAll(functions);
+ }
+ }
+
+ @Override
+ public int getExitCode(Throwable exception) {
+ int exitCode = 0;
+ for (Function function : functions) {
+ Integer code = function.apply(exception.getCause());
+ if (code != null) {
+ if (code > 0 && code > exitCode || code < 0 && code < exitCode) {
+ exitCode = code;
+ }
+ }
+ }
+ return exitCode;
+ }
+ }
+}
diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java
index de591e9a..daff4cdb 100644
--- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java
+++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java
@@ -34,6 +34,8 @@ import org.springframework.shell.ResultHandler;
import org.springframework.shell.ResultHandlerService;
import org.springframework.shell.Shell;
import org.springframework.shell.command.CommandCatalog;
+import org.springframework.shell.context.ShellContext;
+import org.springframework.shell.exit.ExitCodeMappings;
import org.springframework.shell.result.GenericResultHandlerService;
import org.springframework.shell.result.ResultHandlerConfig;
@@ -65,8 +67,9 @@ public class SpringShellAutoConfiguration {
@Bean
public Shell shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
- @Qualifier("shellConversionService") ConversionService shellConversionService) {
- Shell shell = new Shell(resultHandlerService, commandRegistry, terminal);
+ @Qualifier("shellConversionService") ConversionService shellConversionService, ShellContext shellContext,
+ ExitCodeMappings exitCodeMappings) {
+ Shell shell = new Shell(resultHandlerService, commandRegistry, terminal, shellContext, exitCodeMappings);
shell.setConversionService(shellConversionService);
return shell;
}
diff --git a/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories
index b88600ad..1d1eb730 100644
--- a/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories
+++ b/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories
@@ -1,4 +1,5 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+org.springframework.shell.boot.ExitCodeAutoConfiguration,\
org.springframework.shell.boot.ShellContextAutoConfiguration,\
org.springframework.shell.boot.SpringShellAutoConfiguration,\
org.springframework.shell.boot.ShellRunnerAutoConfiguration,\
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java
index 62838cc1..81ad1cc0 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java
@@ -15,12 +15,12 @@
*/
package org.springframework.shell;
-import java.io.IOException;
import java.lang.reflect.UndeclaredThrowableException;
import java.nio.channels.ClosedByInterruptException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.function.Function;
import java.util.stream.Collectors;
import javax.validation.Validator;
@@ -42,6 +42,9 @@ import org.springframework.shell.command.CommandExecution.CommandExecutionExcept
import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.completion.CompletionResolver;
+import org.springframework.shell.context.InteractionMode;
+import org.springframework.shell.context.ShellContext;
+import org.springframework.shell.exit.ExitCodeMappings;
/**
* Main class implementing a shell loop.
@@ -65,6 +68,8 @@ public class Shell {
protected List completionResolvers = new ArrayList<>();
private CommandExecutionHandlerMethodArgumentResolvers argumentResolvers;
private ConversionService conversionService = new DefaultConversionService();
+ private final ShellContext shellContext;
+ private final ExitCodeMappings exitCodeMappings;
/**
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid
@@ -74,10 +79,13 @@ public class Shell {
private Validator validator = Utils.defaultValidator();
- public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal) {
+ public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
+ ShellContext shellContext, ExitCodeMappings exitCodeMappings) {
this.resultHandlerService = resultHandlerService;
this.commandRegistry = commandRegistry;
this.terminal = terminal;
+ this.shellContext = shellContext;
+ this.exitCodeMappings = exitCodeMappings;
}
@Autowired
@@ -108,7 +116,7 @@ public class Shell {
* (e.g. a {@literal script} command).
*
*/
- public void run(InputProvider inputProvider) throws IOException {
+ public void run(InputProvider inputProvider) throws Exception {
Object result = null;
while (!(result instanceof ExitRequest)) { // Handles ExitRequest thrown from Quit command
Input input;
@@ -130,6 +138,18 @@ public class Shell {
if (result != NO_INPUT && !(result instanceof ExitRequest)) {
resultHandlerService.handle(result);
}
+
+ // throw if not in interactive mode so that boot's exit code feature
+ // can contribute exit code. we can't throw when in interactive mode as
+ // that would exit a shell
+ if (this.shellContext != null && this.shellContext.getInteractionMode() != InteractionMode.INTERACTIVE) {
+ if (result instanceof CommandExecution.CommandParserExceptionsException) {
+ throw (CommandExecution.CommandParserExceptionsException) result;
+ }
+ else if (result instanceof Exception) {
+ throw (Exception) result;
+ }
+ }
}
}
@@ -169,6 +189,12 @@ public class Shell {
.findFirst();
if (commandRegistration.isPresent()) {
+ if (this.exitCodeMappings != null) {
+ List> mappingFunctions = commandRegistration.get().getExitCode()
+ .getMappingFunctions();
+ this.exitCodeMappings.reset(mappingFunctions);
+ }
+
List wordsForArgs = wordsForArguments(command, words);
Thread commandThread = Thread.currentThread();
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExitCode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExitCode.java
new file mode 100644
index 00000000..e2de9c8b
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExitCode.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.shell.command;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Interface representing an exit code in a command.
+ *
+ * @author Janne Valkealahti
+ */
+public interface CommandExitCode {
+
+ /**
+ * Gets a function mappings from exceptions to exit codes.
+ *
+ * @return function mappings
+ */
+ List> getMappingFunctions();
+
+ /**
+ * Gets an instance of a default {@link CommandExitCode}.
+ *
+ * @return a command exit code
+ */
+ public static CommandExitCode of() {
+ return of(new ArrayList<>());
+ }
+
+ /**
+ * Gets an instance of a default {@link CommandExitCode}.
+ *
+ * @param functions the function mappings
+ * @return a command exit code
+ */
+ public static CommandExitCode of(List> functions) {
+ return new DefaultCommandExitCode(functions);
+ }
+
+ static class DefaultCommandExitCode implements CommandExitCode {
+
+ private final List> functions;
+
+ DefaultCommandExitCode( List> functions) {
+ this.functions = functions;
+ }
+
+ @Override
+ public List> getMappingFunctions() {
+ return functions;
+ }
+ }
+}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
index 18bb90f4..0a6a56f3 100644
--- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
+++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java
@@ -98,6 +98,13 @@ public interface CommandRegistration {
*/
List getAliases();
+ /**
+ * Gets an exit code.
+ *
+ * @return the exit code
+ */
+ CommandExitCode getExitCode();
+
/**
* Gets a new instance of a {@link Buidler}.
*
@@ -386,6 +393,35 @@ public interface CommandRegistration {
Builder and();
}
+ /**
+ * Spec defining an exit code.
+ */
+ public interface ExitCodeSpec {
+
+ /**
+ * Define mapping from exception to code.
+ *
+ * @param e the exception
+ * @param code the exit code
+ * @return a target spec for chaining
+ */
+ ExitCodeSpec map(Class extends Throwable> e, int code);
+
+ /**
+ *
+ * @param function
+ * @return
+ */
+ ExitCodeSpec map(Function function);
+
+ /**
+ * Return a builder for chaining.
+ *
+ * @return a builder for chaining
+ */
+ Builder and();
+ }
+
/**
* Builder interface for {@link CommandRegistration}.
*/
@@ -456,6 +492,13 @@ public interface CommandRegistration {
*/
AliasSpec withAlias();
+ /**
+ * Define an exit code what this command should execute
+ *
+ * @return exit code spec for chaining
+ */
+ ExitCodeSpec withExitCode();
+
/**
* Builds a {@link CommandRegistration}.
*
@@ -689,6 +732,39 @@ public interface CommandRegistration {
}
}
+ static class DefaultExitCodeSpec implements ExitCodeSpec {
+
+ private BaseBuilder builder;
+ private final List> functions = new ArrayList<>();
+
+ DefaultExitCodeSpec(BaseBuilder builder) {
+ this.builder = builder;
+ }
+
+ @Override
+ public ExitCodeSpec map(Class extends Throwable> e, int code) {
+ Function f = t -> {
+ if (ObjectUtils.nullSafeEquals(t.getClass(), e)) {
+ return code;
+ }
+ return 0;
+ };
+ this.functions.add(f);
+ return this;
+ }
+
+ @Override
+ public ExitCodeSpec map(Function function) {
+ this.functions.add(function);
+ return this;
+ }
+
+ @Override
+ public Builder and() {
+ return builder;
+ }
+ }
+
static class DefaultCommandRegistration implements CommandRegistration {
private String command;
@@ -699,10 +775,11 @@ public interface CommandRegistration {
private List optionSpecs;
private DefaultTargetSpec targetSpec;
private List aliasSpecs;
+ private DefaultExitCodeSpec exitCodeSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String group,
String description, Supplier availability, List optionSpecs,
- DefaultTargetSpec targetSpec, List aliasSpecs) {
+ DefaultTargetSpec targetSpec, List aliasSpecs, DefaultExitCodeSpec exitCodeSpec) {
this.command = commandArrayToName(commands);
this.interactionMode = interactionMode;
this.group = group;
@@ -711,6 +788,7 @@ public interface CommandRegistration {
this.optionSpecs = optionSpecs;
this.targetSpec = targetSpec;
this.aliasSpecs = aliasSpecs;
+ this.exitCodeSpec = exitCodeSpec;
}
@Override
@@ -769,6 +847,16 @@ public interface CommandRegistration {
.collect(Collectors.toList());
}
+ @Override
+ public CommandExitCode getExitCode() {
+ if (this.exitCodeSpec == null) {
+ return CommandExitCode.of();
+ }
+ else {
+ return CommandExitCode.of(exitCodeSpec.functions);
+ }
+ }
+
private static String commandArrayToName(String[] commands) {
return Arrays.asList(commands).stream()
.flatMap(c -> Stream.of(c.split(" ")))
@@ -792,6 +880,7 @@ public interface CommandRegistration {
private List optionSpecs = new ArrayList<>();
private List aliasSpecs = new ArrayList<>();
private DefaultTargetSpec targetSpec;
+ private DefaultExitCodeSpec exitCodeSpec;
@Override
public Builder command(String... commands) {
@@ -848,7 +937,14 @@ public interface CommandRegistration {
DefaultAliasSpec spec = new DefaultAliasSpec(this);
this.aliasSpecs.add(spec);
return spec;
- };
+ }
+
+ @Override
+ public ExitCodeSpec withExitCode() {
+ DefaultExitCodeSpec spec = new DefaultExitCodeSpec(this);
+ this.exitCodeSpec = spec;
+ return spec;
+ }
@Override
public CommandRegistration build() {
@@ -856,7 +952,7 @@ public interface CommandRegistration {
Assert.notNull(targetSpec, "target cannot be empty");
Assert.state(!(targetSpec.bean != null && targetSpec.function != null), "only one target can exist");
return new DefaultCommandRegistration(commands, interactionMode, group, description, availability,
- optionSpecs, targetSpec, aliasSpecs);
+ optionSpecs, targetSpec, aliasSpecs, exitCodeSpec);
}
}
}
diff --git a/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeMappings.java b/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeMappings.java
new file mode 100644
index 00000000..01c81a0e
--- /dev/null
+++ b/spring-shell-core/src/main/java/org/springframework/shell/exit/ExitCodeMappings.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.shell.exit;
+
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Interface used with implementation of a boot's ExitCodeExceptionMapper
+ * in a context of spring-shell spesific one. Mostly needed not to have a
+ * direct dependencies to boot classes as currently only one implementation
+ * instance can exist which we need to reset between command executions.
+ *
+ * @author Janne Valkealahti
+ */
+public interface ExitCodeMappings {
+
+ /**
+ * Reset mappings into a given functions.
+ *
+ * @param functions the mapping functions
+ */
+ void reset(List> functions);
+}
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java
index 85693735..391bfcc5 100644
--- a/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java
+++ b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java
@@ -15,7 +15,6 @@
*/
package org.springframework.shell;
-import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
@@ -71,7 +70,7 @@ public class ShellTests {
}
@Test
- public void commandMatch() throws IOException {
+ public void commandMatch() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?");
doThrow(new Exit()).when(resultHandlerService).handle(any());
@@ -97,7 +96,7 @@ public class ShellTests {
}
@Test
- public void commandNotFound() throws IOException {
+ public void commandNotFound() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?");
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
@@ -122,7 +121,7 @@ public class ShellTests {
@Test
// See https://github.com/spring-projects/spring-shell/issues/142
- public void commandNotFoundPrefix() throws IOException {
+ public void commandNotFoundPrefix() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "helloworld how are you doing ?");
doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class));
@@ -146,7 +145,7 @@ public class ShellTests {
}
@Test
- public void noCommand() throws IOException {
+ public void noCommand() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "", () -> "hello world how are you doing ?", null);
doThrow(new Exit()).when(resultHandlerService).handle(any());
@@ -172,7 +171,7 @@ public class ShellTests {
}
@Test
- public void commandThrowingAnException() throws IOException {
+ public void commandThrowingAnException() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "fail");
doThrow(new Exit()).when(resultHandlerService).handle(isA(SomeException.class));
@@ -199,7 +198,7 @@ public class ShellTests {
}
@Test
- public void comments() throws IOException {
+ public void comments() throws Exception {
when(inputProvider.readInput()).thenReturn(() -> "// This is a comment", (Input) null);
shell.run(inputProvider);
diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
index 9e187f52..a1b376f8 100644
--- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
+++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java
@@ -336,7 +336,7 @@ public class CommandRegistrationTests extends AbstractCommandTests {
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0);
assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0);
- }
+ }
@Test
public void testArityViaEnum() {
@@ -353,8 +353,7 @@ public class CommandRegistrationTests extends AbstractCommandTests {
assertThat(registration.getOptions()).hasSize(1);
assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0);
assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0);
- }
-
+ }
@Test
public void testAliases() {
@@ -381,4 +380,32 @@ public class CommandRegistrationTests extends AbstractCommandTests {
assertThat(registration.getAliases().get(0).getGroup()).isEqualTo("Alias Group");
assertThat(registration.getAliases().get(1).getGroup()).isEqualTo("Alias Group");
}
+
+ @Test
+ public void testExitCodes() {
+ CommandRegistration registration;
+ registration = CommandRegistration.builder()
+ .command("command1")
+ .withTarget()
+ .function(function1)
+ .and()
+ .build();
+ assertThat(registration.getExitCode()).isNotNull();
+ assertThat(registration.getExitCode().getMappingFunctions()).hasSize(0);
+
+ registration = CommandRegistration.builder()
+ .command("command1")
+ .withExitCode()
+ .map(RuntimeException.class, 1)
+ .map(IllegalArgumentException.class, 2)
+ .map(e -> 1)
+ .map(e -> 2)
+ .and()
+ .withTarget()
+ .function(function1)
+ .and()
+ .build();
+ assertThat(registration.getExitCode()).isNotNull();
+ assertThat(registration.getExitCode().getMappingFunctions()).hasSize(4);
+ }
}
diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc
new file mode 100644
index 00000000..23f19dc2
--- /dev/null
+++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands-exitcode.adoc
@@ -0,0 +1,39 @@
+[[dynamic-command-exitcode]]
+==== Exit Code
+ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs]
+
+Many command line applications when applicable return an _exit code_ which running environment
+can use to differentiate if command has been executed successfully or not. In a `spring-shell`
+this mostly relates when a command is run on a non-interactive mode meaning one command
+is always executed once with an instance of a `spring-shell`.
+
+Default behaviour of an exit codes is as:
+
+- Errors from a command option parsing will result code of `2`
+- Any generic error will result result code of `1`
+- Obviously in any other case result code is `0`
+
+Every `CommandRegistration` can define its own mappings between _Exception_ and _exit code_.
+Essentially we're bound to functionality in `Spring Boot` regarding _exit code_ and simply
+integrate into that.
+
+Assuming there is an exception show below which would be thrown from a command:
+
+====
+[source, java, indent=0]
+----
+include::{snippets}/ExitCodeSnippets.java[tag=my-exception-class]
+----
+====
+
+It is possible to define a mapping function between `Throwable` and exit code. You can also
+just configure a _class_ to _exit code_ which is just a syntactic sugar within configurations.
+
+====
+[source, java, indent=0]
+----
+include::{snippets}/ExitCodeSnippets.java[tag=example1]
+----
+====
+
+NOTE: Exit codes cannot be customized with annotation based configuration
diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
index 81e17a8b..4d226a94 100644
--- a/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
+++ b/spring-shell-docs/src/main/asciidoc/using-shell-commands.adoc
@@ -18,3 +18,5 @@ include::using-shell-commands-programmaticmodel.adoc[]
include::using-shell-commands-organize.adoc[]
include::using-shell-commands-availability.adoc[]
+
+include::using-shell-commands-exitcode.adoc[]
diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/ExitCodeSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/ExitCodeSnippets.java
new file mode 100644
index 00000000..6c458782
--- /dev/null
+++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/ExitCodeSnippets.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2022 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.shell.docs;
+
+import org.springframework.shell.command.CommandRegistration;
+
+public class ExitCodeSnippets {
+
+ // tag::my-exception-class[]
+ static class MyException extends RuntimeException {
+
+ private final int code;
+
+ MyException(String msg, int code) {
+ super(msg);
+ this.code = code;
+ }
+
+ public int getCode() {
+ return code;
+ }
+ }
+ // end::my-exception-class[]
+
+ void dump1() {
+ // tag::example1[]
+ CommandRegistration.builder()
+ .withExitCode()
+ .map(MyException.class, 3)
+ .map(t -> {
+ if (t instanceof MyException) {
+ return ((MyException) t).getCode();
+ }
+ return 0;
+ })
+ .and()
+ .build();
+ // end::example1[]
+ }
+}
diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/E2ECommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/E2ECommands.java
index f8999085..42e4b229 100644
--- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/E2ECommands.java
+++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/E2ECommands.java
@@ -106,4 +106,41 @@ public class E2ECommands {
.build();
}
+ @Bean
+ public CommandRegistration testExitCodeRegistration() {
+ return CommandRegistration.builder()
+ .command("e2e", "reg", "exit-code")
+ .group("E2E Commands")
+ .withOption()
+ .longNames("arg1")
+ .required()
+ .and()
+ .withTarget()
+ .consumer(ctx -> {
+ String arg1 = ctx.getOptionValue("arg1");
+ throw new MyException(arg1);
+ })
+ .and()
+ .withExitCode()
+ .map(MyException.class, 3)
+ .map(t -> {
+ String msg = t.getMessage();
+ if (msg != null && msg.contains("ok")) {
+ return 0;
+ }
+ else if (msg != null && msg.contains("fun")) {
+ return 4;
+ }
+ return 0;
+ })
+ .and()
+ .build();
+ }
+
+ static class MyException extends RuntimeException {
+
+ MyException(String msg) {
+ super(msg);
+ }
+ }
}
diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Script.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Script.java
index c0d855b3..f1308030 100644
--- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Script.java
+++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Script.java
@@ -17,7 +17,6 @@ package org.springframework.shell.standard.commands;
import java.io.File;
import java.io.FileReader;
-import java.io.IOException;
import java.io.Reader;
import org.jline.reader.Parser;
@@ -57,7 +56,7 @@ public class Script extends AbstractShellComponent {
}
@ShellMethod(value = "Read and execute commands from a file.")
- public void script(File file) throws IOException {
+ public void script(File file) throws Exception {
Reader reader = new FileReader(file);
try (FileInputProvider inputProvider = new FileInputProvider(reader, parser)) {
getShell().run(inputProvider);