Skip to content

Commit

Permalink
Prompt for plugin specific config during 'plugin initialize' (no proj…
Browse files Browse the repository at this point in the history
…ect specific prompts yet)
  • Loading branch information
martinvisser committed Sep 11, 2023
1 parent c0b7169 commit 9f5fe34
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 301 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ import io.mockk.mockk
import io.mockk.verify
import io.rabobank.ret.RetConsole
import io.rabobank.ret.autocompletion.zsh.ZshAutocompletionGenerator
import io.rabobank.ret.configuration.Answer
import io.rabobank.ret.configuration.Config
import io.rabobank.ret.configuration.ConfigurationProperty
import io.rabobank.ret.configuration.Question
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import picocli.CommandLine.Model.CommandSpec
import java.nio.file.Path
import java.util.Properties

internal class ConfigureCommandTest {

Expand Down Expand Up @@ -48,4 +54,28 @@ internal class ConfigureCommandTest {
command.printZshAutocompletionScript()
verify { retConsole.out("mocked zsh autocompletion file") }
}

class TestConfig : Config {
private val configProps = listOf(
ConfigurationProperty("project", "Enter your Rabobank project"),
ConfigurationProperty("organisation", "Enter your Rabobank organisation"),
)
private val properties = Properties()

override fun get(key: String) = properties[key] as String?

override fun set(key: String, value: String) {
properties[key] = value
}

override fun configure(function: (ConfigurationProperty) -> Unit) {
configProps.forEach(function)
}

override fun prompt(function: (Question) -> Answer): List<Answer> {
TODO("Not yet implemented")
}

override fun configFile(): Path = Path.of("test-configuration")
}
}
2 changes: 1 addition & 1 deletion ret-core/src/main/kotlin/io/rabobank/ret/RetConsole.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class RetConsole(parseResult: ParseResult) {
*/
fun prompt(message: String, currentValue: String?): String {
val messageWithDefault = if (currentValue.isNullOrEmpty()) message else "$message [$currentValue]"
out(messageWithDefault)
out("$messageWithDefault:")
return readln()
}
}
18 changes: 14 additions & 4 deletions ret-core/src/main/kotlin/io/rabobank/ret/configuration/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,20 @@ package io.rabobank.ret.configuration
import java.nio.file.Path

interface Config {
operator fun get(key: String): Any?
operator fun set(key: String, value: Any?)
fun load(): PluginConfig
operator fun get(key: String): String?
operator fun set(key: String, value: String)
fun configure(function: (ConfigurationProperty) -> Unit)
fun configFile(): Path
fun pluginConfigDirectory(): Path
fun prompt(function: (Question) -> Answer): List<Answer>
}

data class Question(
val key: String,
val prompt: String,
val required: Boolean = false,
)

data class Answer(
val key: String,
val answer: String,
)
130 changes: 5 additions & 125 deletions ret-core/src/main/kotlin/io/rabobank/ret/configuration/Configurable.kt
Original file line number Diff line number Diff line change
@@ -1,132 +1,12 @@
package io.rabobank.ret.configuration

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.convertValue
import com.fasterxml.jackson.module.kotlin.readValue
import io.quarkus.logging.Log
import io.rabobank.ret.RetConsole
import io.rabobank.ret.util.OsUtils
import jakarta.inject.Inject
import org.eclipse.microprofile.config.inject.ConfigProperty

/**
* Implement this class in an ApplicationScoped class if you need custom configurations in your plugin.
* Upon configuring the plugin, the user will be prompted for all provided configuration properties,
* Implement this interface in an ApplicationScoped class if you need custom configurations in your plugin.
* Upon initializing the plugin, the user will be prompted for all provided configuration properties,
* and inputs are saved in the RET configuration file.
*
* This class will also take care of migrating old "generic" configuration to plugin specific configuration.
*/
open class Configurable {
@ConfigProperty(name = "plugin.name", defaultValue = "ret")
lateinit var pluginName: String

@Inject
lateinit var osUtils: OsUtils

@Inject
lateinit var objectMapper: ObjectMapper

@Inject
lateinit var retConfig: RetConfig

@Inject
lateinit var retConsole: RetConsole

private val configLoader by lazy { PluginConfigLoader(pluginName, objectMapper, osUtils) }

val pluginConfig by lazy { initialize() }

private fun initialize(): PluginConfig {
var configMigrated = false
val configCopy = configLoader.load()
val keysToMigrate = keysToMigrate()

if (keysToMigrate.isNotEmpty()) {
keysToMigrate.forEach { (oldKey, newKey) ->
val oldValue = retConfig[oldKey]
if (oldValue != null) {
configCopy[newKey] = oldValue

retConfig.remove(oldKey)
configMigrated = true
}
}
}

val oldConfigToMigrate = properties().map { it.key }
if (oldConfigToMigrate.isNotEmpty()) {
oldConfigToMigrate.forEach {
val oldValue = retConfig[it]
if (oldValue != null) {
configCopy[it] = oldValue

retConfig.remove(it)
configMigrated = true
}
}
}

return if (configMigrated) {
Log.debug("Migrated old configuration to plugin specific configuration")
retConfig.save()
configLoader.saveAndReload(configCopy)
} else {
configCopy
}
}

fun load() = pluginConfig

/**
* An optional list of configuration properties that will be used to prompt the user to answer questions.
*/
open fun properties() = emptyList<ConfigurationProperty>()

/**
* You can provide a map of keys which RET can automatically migrate to the new plugin configuration.
*
* The left should be the name of the old key and the right the name of the new key
*/
open fun keysToMigrate() = emptyList<Pair<String, String>>()

/**
* Helper method to convert a simple Map to type-safe plugin specifc configuration.
*/
inline fun <reified T> convertTo() = objectMapper.convertValue<T>(pluginConfig.config)
}

class PluginConfig(val config: MutableMap<String, Any?>) {
inline operator fun <reified T> get(key: String): T? {
val value = config[key]
return if (value is T?) {
value
} else {
error(
"The config value '$key' cannot be cast to ${T::class.java}, because it's of type ${value?.javaClass}",
)
}
}

operator fun set(key: String, value: Any?) {
config[key] = value
}
}

class PluginConfigLoader(pluginName: String, private val objectMapper: ObjectMapper, osUtils: OsUtils) {
private val pluginFile = osUtils.getPluginConfig(pluginName).toFile()

fun load() = PluginConfig(
runCatching { objectMapper.readValue<Map<String, Any?>>(pluginFile) }
.getOrDefault(emptyMap())
.toMutableMap(),
)

fun saveAndReload(pluginConfig: PluginConfig): PluginConfig {
if (!pluginFile.parentFile.exists() && !pluginFile.parentFile.mkdirs()) {
error("Unable to create directory ${pluginFile.parentFile}")
}
objectMapper.writerWithDefaultPrettyPrinter().writeValue(pluginFile, pluginConfig.config)
interface Configurable {
fun properties(): List<ConfigurationProperty> = emptyList()

return load()
}
fun prompts(): List<Question> = emptyList()
}
67 changes: 14 additions & 53 deletions ret-core/src/main/kotlin/io/rabobank/ret/configuration/RetConfig.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
package io.rabobank.ret.configuration

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.convertValue
import com.fasterxml.jackson.module.kotlin.readValue
import io.rabobank.ret.RetConsole
import io.rabobank.ret.util.OsUtils
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Instance
Expand All @@ -20,89 +16,54 @@ private const val RET_VERSION = "ret_config_version"
*/
@ApplicationScoped
class RetConfig(
private val osUtils: OsUtils,
private val retConsole: RetConsole,
private val objectMapper: ObjectMapper,
osUtils: OsUtils,
private val configurables: Instance<Configurable>,
@ConfigProperty(name = "ret.version") private val retVersion: String,
@ConfigProperty(name = "quarkus.application.version") private val retVersion: String,
) : Config {
private val oldConfigFile = osUtils.getRetHomeDirectory().resolve("ret.config").toFile()
private val oldConfigFileBackup = osUtils.getRetHomeDirectory().resolve("ret.config.bak").toFile()
private val configFile = osUtils.getRetHomeDirectory().resolve("ret.json").toFile()
private var properties = mutableMapOf<String, Any?>()
private val configFile = Path.of(osUtils.getHomeDirectory(), ".ret", "ret.config").toFile()
private val properties = Properties()

init {
loadExistingProperties()
}

private fun loadExistingProperties() {
if (oldConfigFile.exists() && !configFile.exists()) {
migrateConfig()
}

if (configFile.exists()) {
properties = objectMapper.readValue<MutableMap<String, Any?>>(configFile)
if (properties[RET_VERSION] != retVersion) {
properties[RET_VERSION] = retVersion
save()
}
}
}

private fun migrateConfig() {
val oldProperties = Properties()
.apply { load(oldConfigFile.inputStream()) }
properties = objectMapper.convertValue<MutableMap<String, Any?>>(oldProperties)

if (!oldConfigFile.renameTo(oldConfigFileBackup)) {
retConsole.errorOut("Unable to rename $oldConfigFile to $oldConfigFileBackup")
properties.load(configFile.inputStream())
}
save()
}

/**
* Get the [key] property from the user configurations.
*
* @return The configured value for [key], or null if not configured.
*/
override operator fun get(key: String) = properties[key]
override operator fun get(key: String) = properties[key] as String?

/**
* Set the [value] to property [key] in the user configurations.
* This is automatically called when initializing a plugin, so you normally do not call this yourself.
*/
override operator fun set(key: String, value: Any?) {
override operator fun set(key: String, value: String) {
properties[key] = value
}

/**
* Delete a property
*/
fun remove(key: String) = properties.remove(key)

/**
* Configure all defined configuration properties, based on the provided function,
* and saves to the configuration file.
* Configure all defined configuration properties, based on the provided function and saves to the configuration file.
* This is automatically called when initializing a plugin, so you normally do not call this yourself.
*/
override fun configure(function: (ConfigurationProperty) -> Unit) {
configurables.flatMap(Configurable::properties).forEach(function)
save()
}

fun save() {
objectMapper.writerWithDefaultPrettyPrinter().writeValue(configFile, properties)
override fun prompt(function: (Question) -> Answer): List<Answer> =
configurables.flatMap(Configurable::prompts).map(function)

private fun save() {
properties[RET_VERSION] = retVersion
properties.store(configFile.outputStream(), "")
}

override fun configFile(): Path = Path.of(configFile.toURI())

override fun pluginConfigDirectory(): Path = osUtils.getRetPluginsDirectory()

override fun load(): PluginConfig {
val pluginConfig = PluginConfig(mutableMapOf())
return configurables.fold(pluginConfig) { acc, configurable ->
acc.config.putAll(configurable.load().config)
acc
}
}
}
38 changes: 15 additions & 23 deletions ret-plugin/src/main/java/io/rabobank/ret/RetPlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,44 @@

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.logging.Log;
import io.quarkus.picocli.runtime.PicocliRunner;
import io.quarkus.runtime.Quarkus;
import io.rabobank.ret.jni.JClass;
import io.rabobank.ret.jni.JNIEnvironment;
import io.rabobank.ret.jni.JNINativeInterface;
import io.rabobank.ret.jni.JString;
import org.graalvm.nativeimage.IsolateThread;
import org.graalvm.nativeimage.c.function.CEntryPoint;
import org.graalvm.nativeimage.c.type.CCharPointer;
import org.graalvm.nativeimage.c.type.CTypeConversion;

@SuppressWarnings("unused")
public final class RetPlugin {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

private RetPlugin() {

}

@CEntryPoint(name = "Java_io_rabobank_ret_plugins_RetPlugin_invoke")
public static void invoke(final JNIEnvironment jniEnv,
final JClass clazz,
@CEntryPoint.IsolateThreadContext final long isolateId,
final JString dataCharStr) {
final var fn = jniEnv.getFunctions();
final var charptr = fn.getGetStringUTFChars().call(jniEnv, dataCharStr, (byte) 0);
final var resultString = CTypeConversion.toJavaString(charptr);
public static void invoke(JNIEnvironment jniEnv, JClass clazz, @CEntryPoint.IsolateThreadContext long isolateId, JString dataCharStr) {
JNINativeInterface fn = jniEnv.getFunctions();
CCharPointer charptr = fn.getGetStringUTFChars().call(jniEnv, dataCharStr, (byte) 0);
final String resultString = CTypeConversion.toJavaString(charptr);

try {
var retContext = OBJECT_MAPPER.readValue(resultString, RetContext.class);
RetContextConfiguration.RET_CONTEXT_INSTANCE = retContext;
RetContext retContext = new ObjectMapper().readValue(resultString, RetContext.class);
PluginConfiguration.RET_CONTEXT_INSTANCE = retContext;
Quarkus.run(PicocliRunner.class, retContext.getCommand().toArray(new String[0]));
} catch (JsonProcessingException e) {
Log.error(e.getMessage(), e);
e.printStackTrace();
}
}

@CEntryPoint(name = "Java_io_rabobank_ret_plugins_RetPlugin_initialize")
public static void initialize(final JNIEnvironment jniEnv,
final JClass clazz,
@CEntryPoint.IsolateThreadContext final long isolateId,
final JString dataCharStr) {
final var fn = jniEnv.getFunctions();
final var charptr = fn.getGetStringUTFChars().call(jniEnv, dataCharStr, (byte) 0);
final var args = CTypeConversion.toJavaString(charptr);

Quarkus.run(PicocliRunner.class, "initialize", args);
public static void initialize(JNIEnvironment jniEnv, JClass clazz, @CEntryPoint.IsolateThreadContext long isolateId, JString dataCharStr) {
JNINativeInterface fn = jniEnv.getFunctions();
CCharPointer charptr = fn.getGetStringUTFChars().call(jniEnv, dataCharStr, (byte) 0);
final String resultString = CTypeConversion.toJavaString(charptr);

Quarkus.run(PicocliRunner.class, "initialize", resultString);
}

@CEntryPoint(name = "Java_io_rabobank_ret_plugins_RetPlugin_createIsolate", builtin = CEntryPoint.Builtin.CREATE_ISOLATE)
Expand Down
Loading

0 comments on commit 9f5fe34

Please sign in to comment.