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 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 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);