diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/JLineAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/JLineAutoConfiguration.java index 386881a3..358789f2 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/JLineAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/JLineAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-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. @@ -17,7 +17,6 @@ package org.springframework.shell.boot; import org.jline.reader.impl.history.DefaultHistory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -30,7 +29,7 @@ public class JLineAutoConfiguration { public static class JLineHistoryConfiguration { @Bean - public org.jline.reader.History history(@Value("${spring.application.name:spring-shell}.log") String historyPath) { + public org.jline.reader.History history() { return new DefaultHistory(); } } diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java index d256a242..0890c7c2 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java @@ -16,6 +16,7 @@ package org.springframework.shell.boot; import java.io.IOException; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.regex.Pattern; @@ -28,17 +29,25 @@ import org.jline.terminal.Terminal; import org.jline.utils.AttributedString; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.config.UserConfigPathProvider; +import org.springframework.util.StringUtils; @Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(SpringShellProperties.class) public class LineReaderAutoConfiguration { + private final static Logger log = LoggerFactory.getLogger(LineReaderAutoConfiguration.class); + private Terminal terminal; private Completer completer; @@ -50,15 +59,21 @@ public class LineReaderAutoConfiguration { private org.jline.reader.History jLineHistory; @Value("${spring.application.name:spring-shell}.log") - private String historyPath; + private String fallbackHistoryFileName; + + private SpringShellProperties springShellProperties; + private UserConfigPathProvider userConfigPathProvider; public LineReaderAutoConfiguration(Terminal terminal, Completer completer, Parser parser, - CommandCatalog commandRegistry, org.jline.reader.History jLineHistory) { + CommandCatalog commandRegistry, org.jline.reader.History jLineHistory, + SpringShellProperties springShellProperties, UserConfigPathProvider userConfigPathProvider) { this.terminal = terminal; this.completer = completer; this.parser = parser; this.commandRegistry = commandRegistry; this.jLineHistory = jLineHistory; + this.springShellProperties = springShellProperties; + this.userConfigPathProvider = userConfigPathProvider; } @EventListener @@ -104,7 +119,20 @@ public class LineReaderAutoConfiguration { .parser(parser); LineReader lineReader = lineReaderBuilder.build(); - lineReader.setVariable(LineReader.HISTORY_FILE, Paths.get(historyPath)); + if (this.springShellProperties.getHistory().isEnabled()) { + // Discover history location + Path userConfigPath = this.userConfigPathProvider.provide(); + log.debug("Resolved userConfigPath {}", userConfigPath); + String historyFileName = this.springShellProperties.getHistory().getName(); + if (!StringUtils.hasText(historyFileName)) { + historyFileName = fallbackHistoryFileName; + } + log.debug("Resolved historyFileName {}", historyFileName); + String historyPath = userConfigPath.resolve(historyFileName).toAbsolutePath().toString(); + log.debug("Resolved historyPath {}", historyPath); + // set history file + lineReader.setVariable(LineReader.HISTORY_FILE, Paths.get(historyPath)); + } lineReader.unsetOpt(LineReader.Option.INSERT_TAB); // This allows completion on an empty buffer, rather than inserting a tab jLineHistory.attach(lineReader); return lineReader; diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java index 1d62fa92..59196681 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java @@ -25,20 +25,38 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "spring.shell") public class SpringShellProperties { + private History history = new History(); + private Config config = new Config(); private Script script = new Script(); private Interactive interactive = new Interactive(); private Noninteractive noninteractive = new Noninteractive(); private Theme theme = new Theme(); private Command command = new Command(); - public void setScript(Script script) { - this.script = script; + public void setConfig(Config config) { + this.config = config; + } + + public Config getConfig() { + return config; + } + + public History getHistory() { + return history; + } + + public void setHistory(History history) { + this.history = history; } public Script getScript() { return script; } + public void setScript(Script script) { + this.script = script; + } + public void setInteractive(Interactive interactive) { this.interactive = interactive; } @@ -71,6 +89,50 @@ public class SpringShellProperties { this.command = command; } + public static class Config { + + private String env; + private String location; + + public String getEnv() { + return env; + } + + public void setEnv(String env) { + this.env = env; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + } + + public static class History { + + private String name; + private boolean enabled = true; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + public static class Script { private boolean enabled = true; diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/UserConfigAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/UserConfigAutoConfiguration.java new file mode 100644 index 00000000..7f777567 --- /dev/null +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/UserConfigAutoConfiguration.java @@ -0,0 +1,97 @@ +/* + * 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.nio.file.Path; +import java.nio.file.Paths; +import java.util.function.Function; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.config.UserConfigPathProvider; +import org.springframework.util.StringUtils; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(SpringShellProperties.class) +public class UserConfigAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(UserConfigPathProvider.class) + public UserConfigPathProvider userConfigPathProvider(SpringShellProperties springShellProperties) { + return () -> { + LocationResolver resolver = new LocationResolver(springShellProperties.getConfig().getEnv(), + springShellProperties.getConfig().getLocation()); + return resolver.resolve(); + }; + } + + static class LocationResolver { + + private final static String XDG_CONFIG_HOME = "XDG_CONFIG_HOME"; + private final static String APP_DATA = "APP_DATA"; + private static final String USERCONFIG_PLACEHOLDER = "{userconfig}"; + private Function pathProvider = (path) -> Paths.get(path); + private final String configDirEnv; + private final String configDirLocation; + + LocationResolver(String configDirEnv, String configDirLocation) { + this.configDirEnv = configDirEnv; + this.configDirLocation = configDirLocation; + } + + Path resolve() { + String location; + if (StringUtils.hasText(configDirEnv) && StringUtils.hasText(System.getenv(configDirEnv))) { + location = System.getenv(configDirEnv); + } + else if (StringUtils.hasText(configDirLocation)) { + location = configDirLocation; + } + else { + location = ""; + } + if (usesUserConfigLocation(location)) { + location = resolveUserConfigLocation(location); + } + return pathProvider.apply(location); + } + + private boolean usesUserConfigLocation(String location) { + return location.contains(USERCONFIG_PLACEHOLDER); + } + + private String resolveUserConfigLocation(String location) { + String userConfigHome = ""; + if (StringUtils.hasText(System.getenv(XDG_CONFIG_HOME))) { + userConfigHome = System.getenv(XDG_CONFIG_HOME); + } + else if (isWindows() && StringUtils.hasText(System.getenv(APP_DATA))) { + userConfigHome = System.getenv(APP_DATA); + } + else { + userConfigHome = System.getProperty("user.home") + "/" + ".config"; + } + return location.replace(USERCONFIG_PLACEHOLDER, userConfigHome); + } + + private boolean isWindows() { + String os = System.getProperty("os.name"); + return os.startsWith("Windows"); + } + } +} 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 1b7d570c..b88600ad 100644 --- a/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories @@ -6,6 +6,7 @@ org.springframework.shell.boot.ApplicationRunnerAutoConfiguration,\ org.springframework.shell.boot.CommandCatalogAutoConfiguration,\ org.springframework.shell.boot.LineReaderAutoConfiguration,\ org.springframework.shell.boot.CompleterAutoConfiguration,\ +org.springframework.shell.boot.UserConfigAutoConfiguration,\ org.springframework.shell.boot.JLineAutoConfiguration,\ org.springframework.shell.boot.JLineShellAutoConfiguration,\ org.springframework.shell.boot.ParameterResolverAutoConfiguration,\ diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/LineReaderAutoConfigurationTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/LineReaderAutoConfigurationTests.java new file mode 100644 index 00000000..f4aa0fcf --- /dev/null +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/LineReaderAutoConfigurationTests.java @@ -0,0 +1,114 @@ +/* + * 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.nio.file.Paths; + +import org.jline.reader.Completer; +import org.jline.reader.History; +import org.jline.reader.LineReader; +import org.jline.reader.Parser; +import org.jline.terminal.Size; +import org.jline.terminal.Terminal; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.config.UserConfigPathProvider; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class LineReaderAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LineReaderAutoConfiguration.class)); + + @Test + public void testLineReaderCreated() { + this.contextRunner + .withUserConfiguration(MockConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(LineReader.class); + LineReader lineReader = context.getBean(LineReader.class); + assertThat(lineReader.getVariable(LineReader.HISTORY_FILE)).asString().contains("spring-shell.log"); + }); + } + + @Test + public void testLineReaderCreatedNoHistoryFile() { + this.contextRunner + .withUserConfiguration(MockConfiguration.class) + .withPropertyValues("spring.shell.history.enabled=false") + .run(context -> { + assertThat(context).hasSingleBean(LineReader.class); + LineReader lineReader = context.getBean(LineReader.class); + assertThat(lineReader.getVariable(LineReader.HISTORY_FILE)).isNull(); + }); + } + + @Test + public void testLineReaderCreatedCustomHistoryFile() { + this.contextRunner + .withUserConfiguration(MockConfiguration.class) + .withPropertyValues("spring.shell.history.name=fakehistory.txt") + .run(context -> { + assertThat(context).hasSingleBean(LineReader.class); + LineReader lineReader = context.getBean(LineReader.class); + assertThat(lineReader.getVariable(LineReader.HISTORY_FILE)).asString().contains("fakehistory.txt"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MockConfiguration { + + @Bean + Terminal mockTerminal() { + Terminal terminal = mock(Terminal.class); + when(terminal.getBufferSize()).thenReturn(new Size()); + return terminal; + } + + @Bean + Completer mockCompleter() { + return mock(Completer.class); + } + + @Bean + Parser mockParser() { + return mock(Parser.class); + } + + @Bean + CommandCatalog mockCommandCatalog() { + return mock(CommandCatalog.class); + } + + @Bean + History mockHistory() { + return mock(History.class); + } + + @Bean + UserConfigPathProvider mockUserConfigPathProvider() { + return () -> Paths.get("mockpath"); + } + } +} diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java index b5f8b17e..b6260c84 100644 --- a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java @@ -33,6 +33,10 @@ public class SpringShellPropertiesTests { .withUserConfiguration(Config1.class) .run((context) -> { SpringShellProperties properties = context.getBean(SpringShellProperties.class); + assertThat(properties.getHistory().isEnabled()).isTrue(); + assertThat(properties.getHistory().getName()).isNull(); + assertThat(properties.getConfig().getLocation()).isNull(); + assertThat(properties.getConfig().getEnv()).isNull(); assertThat(properties.getScript().isEnabled()).isTrue(); assertThat(properties.getInteractive().isEnabled()).isTrue(); assertThat(properties.getNoninteractive().isEnabled()).isTrue(); @@ -63,6 +67,10 @@ public class SpringShellPropertiesTests { @Test public void setProperties() { this.contextRunner + .withPropertyValues("spring.shell.history.enabled=false") + .withPropertyValues("spring.shell.history.name=fakename") + .withPropertyValues("spring.shell.config.location=fakelocation") + .withPropertyValues("spring.shell.config.env=FAKE_ENV") .withPropertyValues("spring.shell.script.enabled=false") .withPropertyValues("spring.shell.interactive.enabled=false") .withPropertyValues("spring.shell.noninteractive.enabled=false") @@ -90,6 +98,10 @@ public class SpringShellPropertiesTests { .withUserConfiguration(Config1.class) .run((context) -> { SpringShellProperties properties = context.getBean(SpringShellProperties.class); + assertThat(properties.getHistory().isEnabled()).isFalse(); + assertThat(properties.getHistory().getName()).isEqualTo("fakename"); + assertThat(properties.getConfig().getLocation()).isEqualTo("fakelocation"); + assertThat(properties.getConfig().getEnv()).isEqualTo("FAKE_ENV"); assertThat(properties.getScript().isEnabled()).isFalse(); assertThat(properties.getInteractive().isEnabled()).isFalse(); assertThat(properties.getNoninteractive().isEnabled()).isFalse(); diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/UserConfigAutoConfigurationTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/UserConfigAutoConfigurationTests.java new file mode 100644 index 00000000..c32b5071 --- /dev/null +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/UserConfigAutoConfigurationTests.java @@ -0,0 +1,61 @@ +/* + * 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.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.shell.config.UserConfigPathProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserConfigAutoConfigurationTests { + + private final static Logger log = LoggerFactory.getLogger(UserConfigAutoConfigurationTests.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(UserConfigAutoConfiguration.class)); + + @Test + public void testDefaults() { + this.contextRunner + .run(context -> { + assertThat(context).hasSingleBean(UserConfigPathProvider.class); + UserConfigPathProvider provider = context.getBean(UserConfigPathProvider.class); + Path path = provider.provide(); + assertThat(path).isNotNull(); + log.info("Path testDefaults: {}", path.toAbsolutePath()); + }); + } + + @Test + public void testUserConfig() { + this.contextRunner + .withPropertyValues("spring.shell.config.location={userconfig}/test") + .run(context -> { + assertThat(context).hasSingleBean(UserConfigPathProvider.class); + UserConfigPathProvider provider = context.getBean(UserConfigPathProvider.class); + Path path = provider.provide(); + assertThat(path).isNotNull(); + log.info("Path testUserConfig: {}", path.toAbsolutePath()); + }); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/config/UserConfigPathProvider.java b/spring-shell-core/src/main/java/org/springframework/shell/config/UserConfigPathProvider.java new file mode 100644 index 00000000..5edfa0cd --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/config/UserConfigPathProvider.java @@ -0,0 +1,35 @@ +/* + * 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.config; + +import java.nio.file.Path; + +/** + * Interface providing a {@link Path} to a location where + * user level runtime configuration files are strored. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface UserConfigPathProvider { + + /** + * Provides a path to a user config location. + * + * @return a path to user config location + */ + Path provide(); +} diff --git a/spring-shell-samples/src/main/resources/application.yml b/spring-shell-samples/src/main/resources/application.yml index ec0c27d2..32854ed7 100644 --- a/spring-shell-samples/src/main/resources/application.yml +++ b/spring-shell-samples/src/main/resources/application.yml @@ -2,6 +2,11 @@ spring: main: banner-mode: off shell: + config: + env: SPRING_SHELL_SAMPLES_USER_HOME + location: "{userconfig}/spring-shell-samples" + history: + name: spring-shell-samples-history.log command: completion: root-command: spring-shell-samples