Make history file path configurable

- UserConfigPathProvider interface to provid "user-level"
  config directory which can be used within a shell.
- New options which can be used to configure behaviour.
  spring.shell.history.enabled
  spring.shell.history.name
  spring.shell.config.location
  spring.shell.config.env
- Fixes #417
This commit is contained in:
Janne Valkealahti
2022-05-17 18:29:58 +01:00
committed by GitHub
parent f2d97ae2de
commit 6a1158dc12
10 changed files with 422 additions and 8 deletions

View File

@@ -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();
}
}

View File

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

View File

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

View File

@@ -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<String, Path> 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");
}
}
}

View File

@@ -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,\

View File

@@ -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");
}
}
}

View File

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

View File

@@ -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());
});
}
}

View File

@@ -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();
}

View File

@@ -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