diff --git a/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppConfigReader.scala b/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppConfigReader.scala index f64b07d..2659bfb 100644 --- a/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppConfigReader.scala +++ b/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppConfigReader.scala @@ -14,7 +14,7 @@ object WebAppConfigReader override def appName: String = "java -jar ambient7-webapp.jar" override def fillReader(): Unit = { - fillConfigOptions(requireUniversalConfig = true) + fillConfigOptions(requireConfig = true) fillDbOptions() } } diff --git a/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppLauncher.scala b/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppLauncher.scala index b364687..d5545cf 100644 --- a/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppLauncher.scala +++ b/ambient7-webapp/src/main/scala/ru/maizy/ambient7/webapp/WebAppLauncher.scala @@ -4,6 +4,7 @@ package ru.maizy.ambient7.webapp * Copyright (c) Nikita Kovaliov, maizy.ru, 2016-2017 * See LICENSE.txt for details. */ + object WebAppLauncher extends App { val config = WebAppConfigReader @@ -11,11 +12,16 @@ object WebAppLauncher extends App { val eitherAppConfig = config.readAppConfig(args.toIndexedSeq) eitherAppConfig match { case Left(parsingError) => - // TODO: show usage and error if needed, extract to core function - Console.err.println( - s"\nUnable to launch app.\n\nErrors:\n * ${parsingError.messages.mkString("\n * ")}" + - parsingError.usage.map(u => s"Usage: $u").getOrElse("") - ) + // TODO: extract to core + val sep = "\n * " + val errors = if (parsingError.messages.nonEmpty) { + s"Errors:$sep${parsingError.messages.mkString(sep)}\n" + } else { + "" + } + val usage = parsingError.usage.map(u => s"Usage: $u").getOrElse("") + val userResult = List(errors, usage).filterNot(_ == "").mkString("\n") + Console.err.println(userResult) case Right(opts) => println(s"Success: $opts") } diff --git a/build.sbt b/build.sbt index 969098b..40d34f9 100644 --- a/build.sbt +++ b/build.sbt @@ -35,6 +35,7 @@ lazy val commonDependencies = Seq( "ch.qos.logback" % "logback-classic" % "1.1.7", "com.typesafe.scala-logging" %% "scala-logging" % "3.5.0", "com.typesafe" % "config" % "1.3.1", + "com.github.kxbmap" %% "configs" % "0.4.4", "org.scalatest" %% "scalatest" % "3.0.0" % "test" ) ) diff --git a/config/application.conf b/config/ambient7.conf similarity index 100% rename from config/application.conf rename to config/ambient7.conf diff --git a/core/src/main/scala/ru/maizy/ambient7/core/config/InfluxDbConfigReader.scala b/core/src/main/scala/ru/maizy/ambient7/core/config/InfluxDbConfigReader.scala index 911f392..2547c25 100644 --- a/core/src/main/scala/ru/maizy/ambient7/core/config/InfluxDbConfigReader.scala +++ b/core/src/main/scala/ru/maizy/ambient7/core/config/InfluxDbConfigReader.scala @@ -19,14 +19,6 @@ trait InfluxDbConfigReader extends UniversalConfigReader { } } -// private def appendInfluxDbOptsPostprocessor(postprocessor: InfluxDbOptions => ParseResult): Unit = -// appendPostprocessor { appOpts => -// appOpts.influxDb match { -// case Some(influxDbOptions) => postprocessor(influxDbOptions) -// case _ => Right(appOpts) -// } -// } - def fillInfluxDbOptions(): Unit = { cliParser.opt[String]("influxdb-baseurl") diff --git a/core/src/main/scala/ru/maizy/ambient7/core/config/MainDbConfigReader.scala b/core/src/main/scala/ru/maizy/ambient7/core/config/MainDbConfigReader.scala index 96a5e05..d4a403f 100644 --- a/core/src/main/scala/ru/maizy/ambient7/core/config/MainDbConfigReader.scala +++ b/core/src/main/scala/ru/maizy/ambient7/core/config/MainDbConfigReader.scala @@ -4,6 +4,7 @@ package ru.maizy.ambient7.core.config * Copyright (c) Nikita Kovaliov, maizy.ru, 2016-2017 * See LICENSE.txt for details. */ + trait MainDbConfigReader extends UniversalConfigReader { import UniversalConfigReader._ @@ -30,6 +31,10 @@ trait MainDbConfigReader extends UniversalConfigReader { .action { (value, opts) => mainDbOpts(opts)(_.copy(url = Some(value))) } .text(s"URL for connecting to h2 database") + appendSimpleOptionalConfigRule[String]("db.url") { (value, opts) => + mainDbOpts(opts)(_.copy(url = Some(value))) + } + appendDbOptsCheck{ dbOpts => Either.cond(dbOpts.url.isDefined, (), ParsingError.withMessage("db-url is required")) } @@ -37,11 +42,19 @@ trait MainDbConfigReader extends UniversalConfigReader { .action { (value, opts) => mainDbOpts(opts)(_.copy(user = value)) } .text("database user") + appendSimpleOptionalConfigRule[String]("db.user") { + (value, opts) => mainDbOpts(opts)(_.copy(user = value)) + } + cliParser.opt[String]("db-password") .action { (value, opts) => mainDbOpts(opts)(_.copy(password = value)) } .text("database password") + appendSimpleOptionalConfigRule[String]("db.password") { (value, opts) => + mainDbOpts(opts)(_.copy(password = value)) + } + () } } diff --git a/core/src/main/scala/ru/maizy/ambient7/core/config/ParsingError.scala b/core/src/main/scala/ru/maizy/ambient7/core/config/ParsingError.scala index efd7f95..1afcb29 100644 --- a/core/src/main/scala/ru/maizy/ambient7/core/config/ParsingError.scala +++ b/core/src/main/scala/ru/maizy/ambient7/core/config/ParsingError.scala @@ -26,7 +26,7 @@ object ParsingError { def withMessage(message: String): ParsingError = ParsingError(IndexedSeq(message)) - def withMessages(messages: String*): ParsingError = + def withMessages(messages: Seq[String]): ParsingError = ParsingError(messages.toIndexedSeq) } diff --git a/core/src/main/scala/ru/maizy/ambient7/core/config/UniversalConfigReader.scala b/core/src/main/scala/ru/maizy/ambient7/core/config/UniversalConfigReader.scala index e178274..8c099ab 100644 --- a/core/src/main/scala/ru/maizy/ambient7/core/config/UniversalConfigReader.scala +++ b/core/src/main/scala/ru/maizy/ambient7/core/config/UniversalConfigReader.scala @@ -6,15 +6,20 @@ package ru.maizy.ambient7.core.config */ import java.io.File -import java.nio.file.Files +import java.nio.file.{ Files, Path } import scala.annotation.tailrec +import scala.util.{ Failure, Try, Success } +import com.typesafe.config.{ Config, ConfigFactory } +import configs.Configs import scopt.OptionParser +import ru.maizy.ambient7.core.config.helper.ConfigRuleOps.IfSuccessOp object UniversalConfigReader { type CheckResult = Either[ParsingError, Unit] type Check = Ambient7Options => CheckResult type ParseResult = Either[ParsingError, Ambient7Options] type Postprocessor = Ambient7Options => ParseResult + type ConfigRule = (Config, Ambient7Options) => ParseResult } trait UniversalConfigReader { @@ -24,7 +29,8 @@ trait UniversalConfigReader { private var postprocessors_ = List[Postprocessor]() private var isConfigEnabled_ = false - // private var isConfigRequired_ = false + private var isConfigRequired_ = false + private var configRules_ = List[ConfigRule]() private val isCliOptionsEnabled_ = true @@ -75,9 +81,9 @@ trait UniversalConfigReader { .action((_, opts) => opts.copy(showHelp = true)) } - protected def fillConfigOptions(requireUniversalConfig: Boolean = false): Unit = { + protected def fillConfigOptions(requireConfig: Boolean = false): Unit = { isConfigEnabled_ = true - // isConfigRequired_ = requireUniversalConfig + isConfigRequired_ = requireConfig def addConfigOption(parser: OptionParser[Ambient7Options], required: Boolean = false): Unit = { val opt = parser.opt[File]("config") @@ -96,11 +102,13 @@ trait UniversalConfigReader { } addConfigOption(configPathCliParser) - addConfigOption(cliParser, required = requireUniversalConfig) + addConfigOption(cliParser, required = requireConfig) } def isConfigEnabled: Boolean = isConfigEnabled_ + def isConfigRequired: Boolean = isConfigRequired_ + def isCliOptionsEnabled: Boolean = isCliOptionsEnabled_ def appName: String @@ -116,6 +124,30 @@ trait UniversalConfigReader { protected def appendPostprocessor(postprocessor: Postprocessor): Unit = postprocessors_ = postprocessor :: postprocessors_ + protected def appendConfigRule(rule: ConfigRule): Unit = + configRules_ = rule :: configRules_ + + protected def appendSimpleConfigRule[T](configPath: String)( + saveValue: (T, Ambient7Options) => Ambient7Options)(implicit reader: Configs[T]): Unit = + { + appendConfigRule { (config, opts) => + Configs.apply[T].get(config, configPath).ifSuccess(value => saveValue(value, opts)) + } + } + + protected def appendSimpleOptionalConfigRule[T](configPath: String)( + save: (T, Ambient7Options) => Ambient7Options)(implicit reader: Configs[Option[T]]): Unit = + { + appendSimpleConfigRule[Option[T]](configPath) { (mayBeValue, opts) => + mayBeValue match { + case Some(value) => save(value, opts) + case None => opts + } + } + } + + def configRules: List[ConfigRule] = configRules_.reverse + private def processHelpOption(result: ParseResult): ParseResult = { result match { // show usage if --help option exists @@ -124,6 +156,13 @@ trait UniversalConfigReader { } } + private def safeLoadConfig(universalConfigPath: Path): Either[IndexedSeq[String], Config] = { + Try(ConfigFactory.parseFile(universalConfigPath.toFile)) match { + case Failure(exception) => Left(IndexedSeq(s"reading config error ${exception.getMessage}")) + case Success(config) => Right(config) + } + } + private def parseConfig(opts: Ambient7Options, args: IndexedSeq[String]): ParseResult = { configLogger.debug("config enabled") val optsWithConfigPath = configPathCliParser.parse(args, opts).getOrElse(opts) @@ -131,19 +170,23 @@ trait UniversalConfigReader { parseResult = processHelpOption(parseResult) parseResult.right.flatMap { optsWithConfigPath => optsWithConfigPath.universalConfigPath match { - case Some(universalConfigPath) => - if (Files.isReadable(universalConfigPath)) { - configLogger.info(s"parse config from $universalConfigPath") - - // FIXME: implements - - Right(optsWithConfigPath) + case Some(configPath) => + if (Files.isReadable(configPath)) { + configLogger.info(s"parse config from $configPath") + safeLoadConfig(configPath) + .left.map(ParsingError.withMessages(_)) + .right.flatMap { config => + val configRulesApplingResult = configRules.foldLeft[ParseResult](Right(optsWithConfigPath)) { + case (error@Left(_), _) => error + case (res@Right(_), rule) => res.right.flatMap(opts => rule(config, opts)) + } + configRulesApplingResult + } } else { - val message = s"unable to read config from $universalConfigPath" + val message = s"unable to read config from $configPath" configLogger.error(message) Left(ParsingError.withMessage(message)) } - case _ => configLogger.info("universal config path not defined") Right(opts) @@ -158,6 +201,15 @@ trait UniversalConfigReader { Right(cliParser.parse(args, opts).getOrElse(opts)) } + private def applyPostprocessors(result: ParseResult): ParseResult = { + result.right.flatMap { opts => + postprocessors.foldLeft[ParseResult](Right(opts)) { + case (error@Left(_), _) => error + case (res@Right(_), processor) => res.right.flatMap(processor) + } + } + } + private def checkConfig(opts: Ambient7Options): ParseResult = { @tailrec @@ -187,9 +239,10 @@ trait UniversalConfigReader { eitherAppConfig = eitherAppConfig.right.flatMap(opts => parseCliOptions(args, opts)) } eitherAppConfig = processHelpOption(eitherAppConfig) + eitherAppConfig = applyPostprocessors(eitherAppConfig) eitherAppConfig = eitherAppConfig.right.flatMap(opts => checkConfig(opts)) eitherAppConfig match { - case config@Right(_) => config + case success@Right(_) => success case Left(parsingError) if isCliOptionsEnabled => Left(cliParser.appendUsageToParserError(parsingError)) case error@Left(_) => error } diff --git a/core/src/main/scala/ru/maizy/ambient7/core/config/helper/ConfigRuleOps.scala b/core/src/main/scala/ru/maizy/ambient7/core/config/helper/ConfigRuleOps.scala new file mode 100644 index 0000000..d7e3756 --- /dev/null +++ b/core/src/main/scala/ru/maizy/ambient7/core/config/helper/ConfigRuleOps.scala @@ -0,0 +1,23 @@ +package ru.maizy.ambient7.core.config.helper + +/** + * Copyright (c) Nikita Kovaliov, maizy.ru, 2017 + * See LICENSE.txt for details. + */ + +import ru.maizy.ambient7.core.config.{ Ambient7Options, ParsingError, UniversalConfigReader } + +object ConfigRuleOps { + + import UniversalConfigReader._ + + implicit class IfSuccessOp[T](configRes: configs.Result[T]) { + + def ifSuccess(saveValue: T => Ambient7Options): ParseResult = { + configRes match { + case configs.Result.Failure(error) => Left(ParsingError.withMessages(error.messages)) + case configs.Result.Success(value) => Right(saveValue(value)) + } + } + } +} diff --git a/core/src/test/resources/sample-config.conf b/core/src/test/resources/sample-config.conf new file mode 100644 index 0000000..cfb444e --- /dev/null +++ b/core/src/test/resources/sample-config.conf @@ -0,0 +1,4 @@ +sample { + string = "abcd" + array = ["a", "b"] +} diff --git a/core/src/test/scala/ru/maizy/ambient7/core/tests/BaseSpec.scala b/core/src/test/scala/ru/maizy/ambient7/core/tests/BaseSpec.scala index 5cd24cb..5cbeb3a 100644 --- a/core/src/test/scala/ru/maizy/ambient7/core/tests/BaseSpec.scala +++ b/core/src/test/scala/ru/maizy/ambient7/core/tests/BaseSpec.scala @@ -5,6 +5,13 @@ package ru.maizy.ambient7.core.tests * See LICENSE.txt for details. */ +import java.nio.file.Paths import org.scalatest.{ FlatSpec, Matchers } -abstract class BaseSpec extends FlatSpec with Matchers +abstract class BaseSpec extends FlatSpec with Matchers { + + def getResourcePathString(relPath: String): String = { + val normPath = if (!relPath.startsWith("/")) "/" + relPath else relPath + Paths.get(this.getClass.getResource(normPath).toURI).toString + } +} diff --git a/core/src/test/scala/ru/maizy/ambient7/core/tests/config/SampleReaders.scala b/core/src/test/scala/ru/maizy/ambient7/core/tests/config/SampleReaders.scala new file mode 100644 index 0000000..1a761dc --- /dev/null +++ b/core/src/test/scala/ru/maizy/ambient7/core/tests/config/SampleReaders.scala @@ -0,0 +1,94 @@ +package ru.maizy.ambient7.core.tests.config + +/** + * Copyright (c) Nikita Kovaliov, maizy.ru, 2017 + * See LICENSE.txt for details. + */ + +import ru.maizy.ambient7.core.config.{ DbOptions, UniversalConfigReader } + +trait SampleReaders { + + class ReaderWithSimpleOpts extends UniversalConfigReader { + override def appName: String = "test app" + + override def fillReader(): Unit = { + cliParser.opt[String]("option") + .abbr("o") + .action { (value, opts) => opts.copy(mainDb = Some(DbOptions(url = Some(value)))) } + + () + } + } + + class ReaderWithSimpleOptsAndPostprocessors extends ReaderWithSimpleOpts { + override def fillReader(): Unit = { + super.fillReader() + + appendPostprocessor { opts => + val mainDbOpts = opts.mainDb.getOrElse(DbOptions()) + Right(opts.copy(mainDb = Some(mainDbOpts.copy(password = mainDbOpts.url.getOrElse(":/"))))) + } + + appendPostprocessor { opts => + val mainDbOpts = opts.mainDb.getOrElse(DbOptions()) + Right(opts.copy(mainDb = Some(mainDbOpts.copy(user = mainDbOpts.password)))) + } + } + } + + class ReaderWithConfig(requireConfig: Boolean) extends UniversalConfigReader { + override def appName: String = "test app" + + override def fillReader(): Unit = { + fillConfigOptions(requireConfig) + cliParser.opt[String]("option") + .abbr("o") + .action { (value, opts) => opts.copy(mainDb = Some(DbOptions(url = Some(value)))) } + + () + } + } + + class ReaderWithConfigRule(requireConfig: Boolean) extends UniversalConfigReader { + override def appName: String = "test app" + override def fillReader(): Unit = { + fillConfigOptions(requireConfig) + appendConfigRule { (config, opts) => + Right(opts.copy(mainDb = Some(DbOptions(url=Some(config.getString("sample.string")))))) + } + () + } + } + + class ReaderWithSimpleConfigRule(path: String) extends UniversalConfigReader { + override def appName: String = "test app" + override def fillReader(): Unit = { + fillConfigOptions(requireConfig = true) + appendSimpleConfigRule[String](path) { (value, opts) => + opts.copy(mainDb = Some(DbOptions(url = Some(value)))) + } + () + } + } + + class ReaderWithSimpleOptionalConfigRule(path: String) extends UniversalConfigReader { + override def appName: String = "test app" + override def fillReader(): Unit = { + fillConfigOptions(requireConfig = true) + appendSimpleOptionalConfigRule[String](path) { (value, opts) => + opts.copy(mainDb = Some(DbOptions(url = Some(value)))) + } + () + } + } + + class ReaderConfigRuleAndCliOption extends ReaderWithConfigRule(requireConfig = true) { + override def fillReader(): Unit = { + super.fillReader() + cliParser.opt[String]("some") + .action { (value, opts) => opts.copy(mainDb = Some(DbOptions(url = Some(value)))) } + () + } + } +} diff --git a/core/src/test/scala/ru/maizy/ambient7/core/tests/config/UniversalConfigReaderSpec.scala b/core/src/test/scala/ru/maizy/ambient7/core/tests/config/UniversalConfigReaderSpec.scala index a07d815..cf55929 100644 --- a/core/src/test/scala/ru/maizy/ambient7/core/tests/config/UniversalConfigReaderSpec.scala +++ b/core/src/test/scala/ru/maizy/ambient7/core/tests/config/UniversalConfigReaderSpec.scala @@ -5,35 +5,13 @@ package ru.maizy.ambient7.core.tests.config * See LICENSE.txt for details. */ +import java.nio.file.Paths import ru.maizy.ambient7.core.config.{ Ambient7Options, DbOptions, ParsingError, UniversalConfigReader } import ru.maizy.ambient7.core.tests.BaseSpec -class UniversalConfigReaderSpec extends BaseSpec { +class UniversalConfigReaderSpec extends BaseSpec with SampleReaders { - class ReaderWithSimpleOpts extends UniversalConfigReader { - override def appName: String = "test app" - - override def fillReader(): Unit = { - cliParser.opt[String]("option") - .abbr("o") - .action { (value, opts) => opts.copy(mainDb = Some(DbOptions(url = Some(value)))) } - - () - } - } - - class ParserWithConfig(required: Boolean) extends UniversalConfigReader { - override def appName: String = "test app" - - override def fillReader(): Unit = { - fillConfigOptions(required) - cliParser.opt[String]("option") - .abbr("o") - .action { (value, opts) => opts.copy(mainDb = Some(DbOptions(url = Some(value)))) } - - () - } - } + val sampleConfigPath = getResourcePathString("sample-config.conf") "UniversalConfigReader" should "allow to add cli opts" in { val reader = new ReaderWithSimpleOpts() @@ -46,6 +24,16 @@ class UniversalConfigReaderSpec extends BaseSpec { reader.readAppConfig(IndexedSeq("--option=value")) shouldBe expected } + it should "apply postprocessors" in { + val reader = new ReaderWithSimpleOptsAndPostprocessors() + reader.fillReader() + + val expected = Right(Ambient7Options( + mainDb = Some(DbOptions(url = Some("value"), user = "value", password = "value")) + )) + reader.readAppConfig(IndexedSeq("-o", "value")) shouldBe expected + } + it should "return some itself properties" in { val reader = new ReaderWithSimpleOpts() reader.fillReader() @@ -75,7 +63,7 @@ class UniversalConfigReaderSpec extends BaseSpec { } it should "required config if needed" in { - val configReader = new ParserWithConfig(required = true) + val configReader = new ReaderWithConfig(requireConfig = true) configReader.fillReader() configReader.isConfigEnabled shouldBe true @@ -86,7 +74,7 @@ class UniversalConfigReaderSpec extends BaseSpec { } it should "not required config if it's options" in { - val configReader = new ParserWithConfig(required = false) + val configReader = new ReaderWithConfig(requireConfig = false) configReader.fillReader() configReader.isConfigEnabled shouldBe true @@ -106,13 +94,96 @@ class UniversalConfigReaderSpec extends BaseSpec { } it should "show usage if config required" in { - val reader = new ParserWithConfig(required = true) + val reader = new ReaderWithConfig(requireConfig = true) reader.fillReader() reader.readAppConfig(IndexedSeq("-h")) shouldHaveOnlyUsageWithoutErrors() reader.readAppConfig(IndexedSeq("-h", "--config=bad.config")) shouldHaveOnlyUsageWithoutErrors() } + it should "allow add config rule" in { + + val readerRequired = new ReaderWithConfigRule(requireConfig = true) + val readerOptional = new ReaderWithConfigRule(requireConfig = false) + + for (reader <- Seq(readerOptional, readerRequired)) { + reader.fillReader() + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath)) shouldBe + Right( + Ambient7Options( + universalConfigPath = Some(Paths.get(sampleConfigPath)), + mainDb = Some(DbOptions(url = Some("abcd"))) + ) + ) + } + } + + it should "allow add simple config rule" in { + + val reader = new ReaderWithSimpleConfigRule("sample.string") + reader.fillReader() + + val readerWithNonExistingConfig = new ReaderWithSimpleConfigRule("sample.array") + readerWithNonExistingConfig.fillReader() + + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath)) shouldBe + Right( + Ambient7Options( + universalConfigPath = Some(Paths.get(sampleConfigPath)), + mainDb = Some(DbOptions(url = Some("abcd"))) + ) + ) + + readerWithNonExistingConfig.readAppConfig(IndexedSeq("--config", sampleConfigPath)) should be ('left) + } + + it should "allow add simple optional config rule" in { + + val reader = new ReaderWithSimpleOptionalConfigRule("sample.string") + reader.fillReader() + + val readerWithNonExistingConfig = new ReaderWithSimpleOptionalConfigRule("unknown.string") + readerWithNonExistingConfig.fillReader() + + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath)) should be ('right) + readerWithNonExistingConfig.readAppConfig(IndexedSeq("--config", sampleConfigPath)) should be ('right) + } + + it should "cli opt should overwrite config rule" in { + val reader = new ReaderConfigRuleAndCliOption + reader.fillReader() + + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath)) shouldBe + Right( + Ambient7Options( + universalConfigPath = Some(Paths.get(sampleConfigPath)), + mainDb = Some(DbOptions(url = Some("abcd"))) + ) + ) + + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath, "--some=zxy")) shouldBe + Right( + Ambient7Options( + universalConfigPath = Some(Paths.get(sampleConfigPath)), + mainDb = Some(DbOptions(url = Some("zxy"))) + ) + ) + } + + it should "return error if config type missmatched" in { + val readers = Seq( + new ReaderWithSimpleOptionalConfigRule("sample.array"), + new ReaderWithSimpleConfigRule("sample.array") + ) + + for (reader <- readers) { + reader.fillReader() + reader.readAppConfig(IndexedSeq("--config", sampleConfigPath)) shouldHaveUsageAndErrorMessageContains + "sample.array has type LIST rather than STRING" + } + + } + implicit class ShouldHaveUsage(result: UniversalConfigReader.ParseResult) { def shouldHaveUsage(): Unit = { result should be ('left) @@ -134,6 +205,18 @@ class UniversalConfigReaderSpec extends BaseSpec { } } + implicit class ShouldHaveUsageAndErrorMessageContains(result: UniversalConfigReader.ParseResult) + extends ShouldHaveUsage(result) + { + def shouldHaveUsageAndErrorMessageContains(messageSubstring: String): Unit = { + result should be ('left) + shouldHaveUsage() + result.left.get.messages should have length 1 + result.left.get.messages(0) should include(messageSubstring) + () + } + } + implicit class ShouldHaveOnlyUsageWithoutErrors(result: UniversalConfigReader.ParseResult) extends ShouldHaveUsage(result) {