From e7f5c964cd11dfaa39363fbdcb0b6ab52a5f1b0c Mon Sep 17 00:00:00 2001 From: Kalin-Rudnicki Date: Sat, 8 Jun 2024 12:02:08 -0600 Subject: [PATCH] Add support for reading command-line options from file(s) (#191) --- .../src/main/scala/zio/cli/CliAssertion.scala | 4 +- .../zio/cli/FileArgsPlatformSpecific.scala | 5 ++ .../zio/cli/FileArgsPlatformSpecific.scala | 5 ++ .../test/scala/zio/cli/LiveFileArgsSpec.scala | 82 +++++++++++++++++++ .../zio/cli/FileArgsPlatformSpecific.scala | 5 ++ .../src/main/scala/zio/cli/CliApp.scala | 13 ++- .../src/main/scala/zio/cli/Command.scala | 28 +++++-- .../src/main/scala/zio/cli/FileArgs.scala | 59 +++++++++++++ .../src/main/scala/zio/cli/Options.scala | 68 ++++++++++++++- .../src/test/scala/zio/cli/CommandSpec.scala | 72 ++++++++-------- .../scala/zio/cli/FileArgsOverrideSpec.scala | 75 +++++++++++++++++ .../src/test/scala/zio/cli/OptionsSpec.scala | 2 +- 12 files changed, 368 insertions(+), 50 deletions(-) create mode 100644 zio-cli/js/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala create mode 100644 zio-cli/jvm/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala create mode 100644 zio-cli/jvm/src/test/scala/zio/cli/LiveFileArgsSpec.scala create mode 100644 zio-cli/native/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala create mode 100644 zio-cli/shared/src/main/scala/zio/cli/FileArgs.scala create mode 100644 zio-cli/shared/src/test/scala/zio/cli/FileArgsOverrideSpec.scala diff --git a/zio-cli-testkit/shared/src/main/scala/zio/cli/CliAssertion.scala b/zio-cli-testkit/shared/src/main/scala/zio/cli/CliAssertion.scala index f0776303..e143fa30 100644 --- a/zio-cli-testkit/shared/src/main/scala/zio/cli/CliAssertion.scala +++ b/zio-cli-testkit/shared/src/main/scala/zio/cli/CliAssertion.scala @@ -87,7 +87,7 @@ object CliAssertion { )(implicit cliConfig: CliConfig): ZIO[R, Throwable, TestResult] = check(pairs) { case CliRepr(params, assertion) => command - .parse(params, cliConfig) + .parse(params, Nil, cliConfig) .map(Right(_)) .catchAll { case e: ValidationError => ZIO.succeed(Left(e)) @@ -110,7 +110,7 @@ object CliAssertion { )(implicit cliConfig: CliConfig): ZIO[R, Throwable, TestResult] = check(pairs) { case CliRepr(params, assertion) => command - .parse(params, cliConfig) + .parse(params, Nil, cliConfig) .map(TestReturn.convert) .map(Right(_)) .catchSome { case e: ValidationError => diff --git a/zio-cli/js/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala b/zio-cli/js/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala new file mode 100644 index 00000000..a2ef4d8a --- /dev/null +++ b/zio-cli/js/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala @@ -0,0 +1,5 @@ +package zio.cli + +trait FileArgsPlatformSpecific { + val default: FileArgs = FileArgs.Noop +} diff --git a/zio-cli/jvm/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala b/zio-cli/jvm/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala new file mode 100644 index 00000000..31f2d148 --- /dev/null +++ b/zio-cli/jvm/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala @@ -0,0 +1,5 @@ +package zio.cli + +trait FileArgsPlatformSpecific { + val default: FileArgs = FileArgs.Live +} diff --git a/zio-cli/jvm/src/test/scala/zio/cli/LiveFileArgsSpec.scala b/zio-cli/jvm/src/test/scala/zio/cli/LiveFileArgsSpec.scala new file mode 100644 index 00000000..f52b163c --- /dev/null +++ b/zio-cli/jvm/src/test/scala/zio/cli/LiveFileArgsSpec.scala @@ -0,0 +1,82 @@ +package zio.cli + +import zio._ +import zio.internal.stacktracer.SourceLocation +import zio.test._ + +import java.nio.file.{Files, Path} + +object LiveFileArgsSpec extends ZIOSpecDefault { + + private val createTempDirectory: RIO[Scope, Path] = + for { + random <- Random.nextUUID + path <- + ZIO.attempt(Files.createTempDirectory(random.toString)).withFinalizer(f => ZIO.attempt(f.toFile.delete()).orDie) + } yield path + + private def resolvePath(path: Path, paths: List[String]): Path = + if (paths.nonEmpty) path.resolve(paths.mkString("/")) + else path + + private def makeTest(name: String)(cwd: List[String], home: List[String])( + writeFiles: (List[String], String)* + )( + exp: (List[String], List[String])* + )(implicit loc: SourceLocation): Spec[Scope, Throwable] = + test(name) { + for { + // setup + dir <- createTempDirectory + _ <- TestSystem.putProperty("user.dir", resolvePath(dir, cwd).toString) + _ <- TestSystem.putProperty("user.home", resolvePath(dir, home).toString) + _ <- ZIO.foreachDiscard(writeFiles) { case (paths, contents) => + val writePath = resolvePath(dir, paths :+ s".$cmd") + val parentFile = writePath.getParent.toFile + ZIO.attempt(parentFile.mkdirs()).unlessZIO(ZIO.attempt(parentFile.exists())) *> + ZIO.writeFile(writePath, contents) + } + + // test + result <- FileArgs.Live.getArgsFromFile(cmd) + resolvedExp = exp.toList.map { case (paths, args) => + FileArgs.ArgsFromFile(resolvePath(dir, paths :+ s".$cmd").toString, args) + } + + } yield assertTrue(result == resolvedExp) + } + + private val cmd: String = "command" + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("FileArgsSpec")( + makeTest("empty")(List("abc", "home"), List("abc", "home"))()(), + makeTest("home in cwd parent path")(List("abc", "home", "d", "e", "f"), List("abc", "home"))( + List("abc", "home", "d") -> "d\nd\n\n", + List("abc", "home", "d", "e") -> "e\ne\n\n", + List("abc", "home", "d", "e", "f") -> "f\nf\n\n", + List("abc", "home") -> "_home_" + )( + List("abc", "home", "d", "e", "f") -> List("f", "f"), + List("abc", "home", "d", "e") -> List("e", "e"), + List("abc", "home", "d") -> List("d", "d"), + List("abc", "home") -> List("_home_") // only appears once + ), + makeTest("home not in cwd parent path")(List("abc", "cwd", "d", "e", "f"), List("abc", "home"))( + List("abc", "cwd", "d") -> "d\nd\n\n", + List("abc", "cwd", "d", "e") -> "e\ne\n\n", + List("abc", "cwd", "d", "e", "f") -> "f\nf\n\n", + List("abc", "home") -> "_home_" + )( + List("abc", "cwd", "d", "e", "f") -> List("f", "f"), + List("abc", "cwd", "d", "e") -> List("e", "e"), + List("abc", "cwd", "d") -> List("d", "d"), + List("abc", "home") -> List("_home_") + ), + makeTest("parent dirs of home are not searched")(Nil, List("abc", "home"))( + List("abc") -> "a\nb" + )( + ) + ) @@ TestAspect.withLiveRandom + +} diff --git a/zio-cli/native/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala b/zio-cli/native/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala new file mode 100644 index 00000000..31f2d148 --- /dev/null +++ b/zio-cli/native/src/main/scala/zio/cli/FileArgsPlatformSpecific.scala @@ -0,0 +1,5 @@ +package zio.cli + +trait FileArgsPlatformSpecific { + val default: FileArgs = FileArgs.Live +} diff --git a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala index 161838a1..7d3b0303 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala @@ -17,7 +17,10 @@ import scala.annotation.tailrec */ sealed trait CliApp[-R, +E, +A] { self => - def run(args: List[String]): ZIO[R, CliError[E], Option[A]] + def runWithFileArgs(args: List[String]): ZIO[R & FileArgs, CliError[E], Option[A]] + + final def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = + runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed(FileArgs.default)) def config(newConfig: CliConfig): CliApp[R, E, A] @@ -66,7 +69,7 @@ object CliApp { private def printDocs(helpDoc: HelpDoc): UIO[Unit] = printLine(helpDoc.toPlaintext(80)).! - def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = { + override def runWithFileArgs(args: List[String]): ZIO[R & FileArgs, CliError[E], Option[A]] = { def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] = builtInOption match { case ShowHelp(synopsis, helpDoc) => @@ -125,8 +128,10 @@ object CliApp { case Command.Subcommands(parent, _) => prefix(parent) } - self.command - .parse(prefix(self.command) ++ args, self.config) + ZIO + .serviceWithZIO[FileArgs](_.getArgsFromFile("???")) // TODO (KR) : + .orDie // TODO (KR) : + .flatMap(self.command.parse(prefix(self.command) ++ args, _, self.config)) .foldZIO( e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)), { diff --git a/zio-cli/shared/src/main/scala/zio/cli/Command.scala b/zio-cli/shared/src/main/scala/zio/cli/Command.scala index f1521250..353c4499 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Command.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Command.scala @@ -46,7 +46,11 @@ sealed trait Command[+A] extends Parameter with Named { self => final def orElseEither[B](that: Command[B]): Command[Either[A, B]] = map(Left(_)) | that.map(Right(_)) - def parse(args: List[String], conf: CliConfig): IO[ValidationError, CommandDirective[A]] + def parse( + args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], + conf: CliConfig + ): IO[ValidationError, CommandDirective[A]] final def subcommands[B](that: Command[B])(implicit ev: Reducable[A, B]): Command[ev.Out] = Command.Subcommands(self, that).map(ev.fromTuple2(_)) @@ -110,6 +114,7 @@ object Command { def parse( args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], conf: CliConfig ): IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = { def parseBuiltInArgs(args: List[String]): IO[ValidationError, CommandDirective[Nothing]] = @@ -117,7 +122,7 @@ object Command { val options = BuiltInOption .builtInOptions(self, self.synopsis, self.helpDoc) Options - .validate(options, args.tail, conf) + .validate(options, args.tail, Nil, conf) .map(_._3) .someOrFail( ValidationError( @@ -158,7 +163,7 @@ object Command { } tuple1 = splitForcedArgs(commandOptionsAndArgs) (optionsAndArgs, forcedCommandArgs) = tuple1 - tuple2 <- Options.validate(options, optionsAndArgs, conf) + tuple2 <- Options.validate(options, optionsAndArgs, fromFiles, conf) (optionsError, commandArgs, optionsType) = tuple2 tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_)) (argsLeftover, argsType) = tuple @@ -202,9 +207,10 @@ object Command { def parse( args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], conf: CliConfig ): IO[ValidationError, CommandDirective[B]] = - command.parse(args, conf).map(_.map(f)) + command.parse(args, fromFiles, conf).map(_.map(f)) lazy val synopsis: UsageSynopsis = command.synopsis @@ -222,9 +228,12 @@ object Command { def parse( args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], conf: CliConfig ): IO[ValidationError, CommandDirective[A]] = - left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => right.parse(args, conf) } + left.parse(args, fromFiles, conf).catchSome { case ValidationError(CommandMismatch, _) => + right.parse(args, fromFiles, conf) + } lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed @@ -286,6 +295,7 @@ object Command { def parse( args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], conf: CliConfig ): IO[ValidationError, CommandDirective[(A, B)]] = { val helpDirectiveForChild = { @@ -294,7 +304,7 @@ object Command { case _ :: tail => tail } child - .parse(safeTail, conf) + .parse(safeTail, fromFiles, conf) .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) => val parentName = names.headOption.getOrElse("") @@ -316,7 +326,7 @@ object Command { case _ :: tail => tail } child - .parse(safeTail, conf) + .parse(safeTail, fromFiles, conf) .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive } @@ -326,7 +336,7 @@ object Command { ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowWizard(self))) parent - .parse(args, conf) + .parse(args, fromFiles, conf) .flatMap { case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) => helpDirectiveForChild orElse helpDirectiveForParent @@ -335,7 +345,7 @@ object Command { case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn) case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => child - .parse(leftover, conf) + .parse(leftover, fromFiles, conf) .mapBoth( { case ValidationError(CommandMismatch, _) => diff --git a/zio-cli/shared/src/main/scala/zio/cli/FileArgs.scala b/zio-cli/shared/src/main/scala/zio/cli/FileArgs.scala new file mode 100644 index 00000000..e97c0339 --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/FileArgs.scala @@ -0,0 +1,59 @@ +package zio.cli + +import zio._ +import java.nio.file.Path + +trait FileArgs { + def getArgsFromFile(command: String): Task[List[FileArgs.ArgsFromFile]] +} +object FileArgs extends FileArgsPlatformSpecific { + + final case class ArgsFromFile(path: String, args: List[String]) + + case object Noop extends FileArgs { + override def getArgsFromFile(command: String): Task[List[ArgsFromFile]] = ZIO.succeed(Nil) + } + + case object Live extends FileArgs { + + private def optReadPath(path: Path): Task[Option[FileArgs.ArgsFromFile]] = + for { + exists <- ZIO.attempt(path.toFile.exists()) + pathString = path.toString + optContents <- + ZIO + .readFile(pathString) + .map(c => FileArgs.ArgsFromFile(pathString, c.split('\n').map(_.trim).filter(_.nonEmpty).toList)) + .when(exists) + } yield optContents + + private def getPathAndParents(path: Path): Task[List[Path]] = + for { + parentPath <- ZIO.attempt(Option(path.getParent)) + parents <- parentPath match { + case Some(parentPath) => getPathAndParents(parentPath) + case None => ZIO.succeed(Nil) + } + } yield path :: parents + + override def getArgsFromFile(command: String): Task[List[ArgsFromFile]] = + for { + cwd <- System.property("user.dir") + home <- System.property("user.home") + commandFile = s".$command" + + pathsFromCWD <- cwd match { + case Some(cwd) => ZIO.attempt(Path.of(cwd)).flatMap(getPathAndParents) + case None => ZIO.succeed(Nil) + } + homePath <- ZIO.foreach(home)(p => ZIO.attempt(Path.of(p))) + allPaths = (pathsFromCWD ::: homePath.toList).distinct + + argsFromFiles <- ZIO.foreach(allPaths) { path => + ZIO.attempt(path.resolve(commandFile)).flatMap(optReadPath) + } + } yield argsFromFiles.flatten + + } + +} diff --git a/zio-cli/shared/src/main/scala/zio/cli/Options.scala b/zio-cli/shared/src/main/scala/zio/cli/Options.scala index 9d024e52..a05ab653 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Options.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Options.scala @@ -297,6 +297,69 @@ object Options extends OptionsPlatformSpecific { .catchAll(e => ZIO.succeed((Some(e), input, Predef.Map.empty))) } + private def mergeFileOptions( + queue: List[FileArgs.ArgsFromFile], + acc: Predef.Map[String, (String, List[String])], + options: List[Options[_] with Input], + conf: CliConfig + ): IO[ValidationError, Predef.Map[String, (String, List[String])]] = + queue match { + case FileArgs.ArgsFromFile(path, args) :: tail => + for { + tuple1 <- matchOptions(args, options, conf) + newMap <- + tuple1 match { + case (Some(e), _, _) => ZIO.fail(e) + case (_, rest, _) if rest.nonEmpty => + ZIO.fail( + ValidationError( + ValidationErrorType.InvalidArgument, + HelpDoc.p("Files can only contain options, not Args") + + HelpDoc.p(s"Found args: ${rest.mkString(", ")}") + ) + ) + case (_, _, map) => ZIO.succeed(map.map { case (k, v) => (k, (path, v)) }) + } + mergedPairs <- + ZIO.foreach((acc.keySet | newMap.keySet).toList.sorted.map(k => (k, acc.get(k), newMap.get(k)))) { + case (k, Some((accPath, _)), Some(newRes @ (newPath, _))) => + ZIO.logDebug(s"option '$k' : options from path '$accPath' overridden by path '$newPath'") *> + ZIO.some(k -> newRes) + case (k, Some(accRes), None) => + ZIO.some(k -> accRes) + case (k, None, Some(newRes)) => + ZIO.some(k -> newRes) + case (_, None, None) => + ZIO.none + } + mergedMap = mergedPairs.flatten.toMap + res <- mergeFileOptions(tail, mergedMap, options, conf) + } yield res + case Nil => + ZIO.succeed(acc) + } + + private def mergeFileAndInputOptions( + inputOptions: Predef.Map[String, List[String]], + fileOptions: Predef.Map[String, (String, List[String])] + ): UIO[Predef.Map[String, List[String]]] = + ZIO + .foreach( + (fileOptions.keySet | inputOptions.keySet).toList.sorted.map(k => (k, fileOptions.get(k), inputOptions.get(k))) + ) { + case (k, Some((accPath, _)), Some(inputRes)) => + ZIO.logDebug(s"option '$k' : options from path '$accPath' overridden by command line") *> + ZIO.some(k -> inputRes) + case (k, Some((accPath, accArgs)), None) => + ZIO.logInfo(s"option '$k' : using options from path '$accPath' - ${accArgs.mkString(", ")}") *> + ZIO.some(k -> accArgs) + case (k, None, Some(newRes)) => + ZIO.some(k -> newRes) + case (_, None, None) => + ZIO.none + } + .map(_.flatten.toMap) + /** * `Options.validate` parses `args` for `options and returns an `Option[ValidationError]`, the leftover arguments and * the constructed value of type `A`. The possible error inside `Option[ValidationError]` would only be triggered if @@ -306,12 +369,15 @@ object Options extends OptionsPlatformSpecific { def validate[A]( options: Options[A], args: List[String], + fromFiles: List[FileArgs.ArgsFromFile], conf: CliConfig ): IO[ValidationError, (Option[ValidationError], List[String], A)] = for { matched <- matchOptions(args, options.flatten, conf) (error, commandArgs, matchedOptions) = matched - a <- options.validate(matchedOptions, conf).mapError(error.getOrElse(_)) + mapFromFiles <- mergeFileOptions(fromFiles.reverse, Predef.Map.empty, options.flatten, conf) + mergedOptions <- mergeFileAndInputOptions(matchedOptions, mapFromFiles) + a <- options.validate(mergedOptions, conf).mapError(error.getOrElse(_)) } yield (error, commandArgs, a) case object Empty extends Options[Unit] with Pipeline { diff --git a/zio-cli/shared/src/test/scala/zio/cli/CommandSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/CommandSpec.scala index 5181ff9f..b2826ca6 100644 --- a/zio-cli/shared/src/test/scala/zio/cli/CommandSpec.scala +++ b/zio-cli/shared/src/test/scala/zio/cli/CommandSpec.scala @@ -27,17 +27,19 @@ object CommandSpec extends ZIOSpecDefault { suite("Toplevel Command Spec")( suite("Command with options followed by args")( test("Should validate successfully") { - assertZIO(Tail.command.parse(List("tail", "-n", "100", "foo.log"), CliConfig.default))( + assertZIO(Tail.command.parse(List("tail", "-n", "100", "foo.log"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(List.empty[String], (BigInt(100), "foo.log"))) ) *> - assertZIO(Ag.command.parse(List("grep", "--after", "2", "--before", "3", "fooBar"), CliConfig.default))( + assertZIO( + Ag.command.parse(List("grep", "--after", "2", "--before", "3", "fooBar"), Nil, CliConfig.default) + )( equalTo(CommandDirective.UserDefined(List.empty[String], ((BigInt(2), BigInt(3)), "fooBar"))) ) }, test("Should provide auto correct suggestions for misspelled options") { assertZIO( Ag.command - .parse(List("grep", "--afte", "2", "--before", "3", "fooBar"), CliConfig.default) + .parse(List("grep", "--afte", "2", "--before", "3", "fooBar"), Nil, CliConfig.default) .either .map(_.left.map(_.error)) )( @@ -45,7 +47,7 @@ object CommandSpec extends ZIOSpecDefault { ) *> assertZIO( Ag.command - .parse(List("grep", "--after", "2", "--efore", "3", "fooBar"), CliConfig.default) + .parse(List("grep", "--after", "2", "--efore", "3", "fooBar"), Nil, CliConfig.default) .either .map(_.left.map(_.error)) )( @@ -53,7 +55,7 @@ object CommandSpec extends ZIOSpecDefault { ) *> assertZIO( Ag.command - .parse(List("grep", "--afte", "2", "--efore", "3", "fooBar"), CliConfig.default) + .parse(List("grep", "--afte", "2", "--efore", "3", "fooBar"), Nil, CliConfig.default) .either .map(_.left.map(_.error)) )( @@ -67,7 +69,7 @@ object CommandSpec extends ZIOSpecDefault { test("Shows an error if an option is missing") { assertZIO( Ag.command - .parse(List("grep", "--a", "2", "--before", "3", "fooBar"), CliConfig.default) + .parse(List("grep", "--a", "2", "--before", "3", "fooBar"), Nil, CliConfig.default) .either .map(_.left.map(_.error)) )( @@ -81,7 +83,7 @@ object CommandSpec extends ZIOSpecDefault { val orElseCommand = Command("remote", Options.Empty, Args.none) | Command("log", Options.Empty, Args.none) - assertZIO(orElseCommand.parse(List("log"), CliConfig.default))( + assertZIO(orElseCommand.parse(List("log"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, ())) ) } @@ -90,11 +92,11 @@ object CommandSpec extends ZIOSpecDefault { test("Clustered boolean options are equal to un-clustered options") { val clustered = WC.command - .parse(List("wc", "-clw", "filename"), CliConfig.default) + .parse(List("wc", "-clw", "filename"), Nil, CliConfig.default) val unClustered = WC.command - .parse(List("wc", "-c", "-l", "-w", "filename"), CliConfig.default) + .parse(List("wc", "-c", "-l", "-w", "filename"), Nil, CliConfig.default) val commandDirective = CommandDirective.UserDefined(Nil, ((true, true, true, true), List("filename"))) @@ -104,7 +106,7 @@ object CommandSpec extends ZIOSpecDefault { test("Not uncluster wrong clusters") { val wrongCluster = WC.command - .parse(List("wc", "-clk"), CliConfig.default) + .parse(List("wc", "-clk"), Nil, CliConfig.default) val commandDirective = CommandDirective.UserDefined(Nil, ((false, false, false, true), List("-clk"))) @@ -113,7 +115,7 @@ object CommandSpec extends ZIOSpecDefault { test(""""-" unaltered """) { val wrongCluster = WC.command - .parse(List("wc", "-"), CliConfig.default) + .parse(List("wc", "-"), Nil, CliConfig.default) val commandDirective = CommandDirective.UserDefined(Nil, ((false, false, false, true), List("-"))) @@ -131,22 +133,22 @@ object CommandSpec extends ZIOSpecDefault { Vector( test("match first sub command without any surplus arguments") { - assertZIO(git.parse(List("git", "remote"), CliConfig.default))( + assertZIO(git.parse(List("git", "remote"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, ())) ) }, test("match first sub command with a surplus options") { - assertZIO(git.parse(List("git", "remote", "-v"), CliConfig.default))( + assertZIO(git.parse(List("git", "remote", "-v"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(List("-v"), ())) ) }, test("match second sub command without any surplus arguments") { - assertZIO(git.parse(List("git", "log"), CliConfig.default))( + assertZIO(git.parse(List("git", "log"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, ())) ) }, test("test unknown sub command error message") { - assertZIO(git.parse(List("git", "abc"), CliConfig.default).flip.map { e => + assertZIO(git.parse(List("git", "abc"), Nil, CliConfig.default).flip.map { e => e.error })( equalTo(HelpDoc.p("Invalid subcommand for git. Use one of 'remote', 'log'")) @@ -167,31 +169,35 @@ object CommandSpec extends ZIOSpecDefault { Vector( test("test sub command with required options and arguments") { - assertZIO(git.parse(List("git", "rebase", "-i", "upstream", "branch"), CliConfig.default))( + assertZIO(git.parse(List("git", "rebase", "-i", "upstream", "branch"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, ((true, "drop"), ("upstream", "branch")))) ) }, test("test sub command with required and optional options and arguments") { assertZIO( - git.parse(List("git", "rebase", "-i", "--empty", "ask", "upstream", "branch"), CliConfig.default) + git.parse( + List("git", "rebase", "-i", "--empty", "ask", "upstream", "branch"), + Nil, + CliConfig.default + ) )( equalTo(CommandDirective.UserDefined(Nil, ((true, "ask"), ("upstream", "branch")))) ) }, test("test unknown sub command") { - assertZIO(git.parse(List("git", "abc"), CliConfig.default).flip.map(_.validationErrorType))( + assertZIO(git.parse(List("git", "abc"), Nil, CliConfig.default).flip.map(_.validationErrorType))( equalTo(ValidationErrorType.CommandMismatch) ) }, test("test unknown sub command error message") { - assertZIO(git.parse(List("git", "abc"), CliConfig.default).flip.map { e => + assertZIO(git.parse(List("git", "abc"), Nil, CliConfig.default).flip.map { e => e.error })( equalTo(HelpDoc.p("Invalid subcommand for git. Use 'rebase'")) ) }, test("test without sub command") { - git.parse(List("git"), CliConfig.default).map { result => + git.parse(List("git"), Nil, CliConfig.default).map { result => assertTrue { result match { case CommandDirective.BuiltIn(ShowHelp(_, _)) => true @@ -212,7 +218,7 @@ object CommandSpec extends ZIOSpecDefault { ) test("sub sub command with option and argument")( - assertZIO(command.parse(List("command", "sub", "subsub", "-i", "text"), CliConfig.default))( + assertZIO(command.parse(List("command", "sub", "subsub", "-i", "text"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, (true, "text"))) ) ) @@ -222,7 +228,7 @@ object CommandSpec extends ZIOSpecDefault { suite("test adding helpdoc to commands")( test("add text helpdoc to Single") { val command = Command("tldr").withHelp("this is some help") - assertZIO(command.parse(List("tldr"), CliConfig.default))( + assertZIO(command.parse(List("tldr"), Nil, CliConfig.default))( equalTo(CommandDirective.UserDefined(Nil, ())) ) }, @@ -305,20 +311,20 @@ object CommandSpec extends ZIOSpecDefault { Vector( test("trigger built-in options that are alone")( - assertZIO(command.parse(params1, CliConfig.default).map(directiveType _))(equalTo("help")) && - assertZIO(command.parse(params2, CliConfig.default).map(directiveType _))(equalTo("help")) && - assertZIO(command.parse(params3, CliConfig.default).map(directiveType _))(equalTo("wizard")) && - assertZIO(command.parse(params4, CliConfig.default).map(directiveType _))(equalTo("completions")) + assertZIO(command.parse(params1, Nil, CliConfig.default).map(directiveType _))(equalTo("help")) && + assertZIO(command.parse(params2, Nil, CliConfig.default).map(directiveType _))(equalTo("help")) && + assertZIO(command.parse(params3, Nil, CliConfig.default).map(directiveType _))(equalTo("wizard")) && + assertZIO(command.parse(params4, Nil, CliConfig.default).map(directiveType _))(equalTo("completions")) ), test("not trigger help if matches")( - assertZIO(command.parse(params5, CliConfig.default).map(directiveType _))(equalTo("user")) + assertZIO(command.parse(params5, Nil, CliConfig.default).map(directiveType _))(equalTo("user")) ), test("trigger help not alone")( - assertZIO(command.parse(params6, CliConfig.default).map(directiveType _))(equalTo("help")) && - assertZIO(command.parse(params7, CliConfig.default).map(directiveType _))(equalTo("help")) + assertZIO(command.parse(params6, Nil, CliConfig.default).map(directiveType _))(equalTo("help")) && + assertZIO(command.parse(params7, Nil, CliConfig.default).map(directiveType _))(equalTo("help")) ), test("triggering wizard not alone")( - assertZIO(command.parse(params8, CliConfig.default).map(directiveType _))(equalTo("wizard")) + assertZIO(command.parse(params8, Nil, CliConfig.default).map(directiveType _))(equalTo("wizard")) ) ) }, @@ -327,9 +333,9 @@ object CommandSpec extends ZIOSpecDefault { Command("cmd", Options.text("something").optional ++ Options.boolean("verbose").alias("v"), Args.text.*) for { - r1 <- command.parse(List("cmd", "-v", "--something", "abc", "something"), CliConfig.default) - r2 <- command.parse(List("cmd", "-v", "--", "--something", "abc", "something"), CliConfig.default) - r3 <- command.parse(List("cmd", "--", "-v", "--something", "abc", "something"), CliConfig.default) + r1 <- command.parse(List("cmd", "-v", "--something", "abc", "something"), Nil, CliConfig.default) + r2 <- command.parse(List("cmd", "-v", "--", "--something", "abc", "something"), Nil, CliConfig.default) + r3 <- command.parse(List("cmd", "--", "-v", "--something", "abc", "something"), Nil, CliConfig.default) } yield assertTrue( r1 == CommandDirective.UserDefined(Nil, ((Some("abc"), true), List("something"))), r2 == CommandDirective.UserDefined(Nil, ((None, true), List("--something", "abc", "something"))), diff --git a/zio-cli/shared/src/test/scala/zio/cli/FileArgsOverrideSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/FileArgsOverrideSpec.scala new file mode 100644 index 00000000..c6e2b5e3 --- /dev/null +++ b/zio-cli/shared/src/test/scala/zio/cli/FileArgsOverrideSpec.scala @@ -0,0 +1,75 @@ +package zio.cli + +import zio._ +import zio.internal.stacktracer.SourceLocation +import zio.test._ +import zio.test.Assertion._ + +object FileArgsOverrideSpec extends ZIOSpecDefault { + + private final case class Const(fileArgs: List[FileArgs.ArgsFromFile]) extends FileArgs { + override def getArgsFromFile(command: String): Task[List[FileArgs.ArgsFromFile]] = ZIO.succeed(fileArgs) + } + + private final case class Result( + arg1: String, + arg2: String, + arg3: String, + arg4: String + ) + + private val options: Options[Result] = + ( + Options.text("arg-1") ++ + Options.text("arg-2") ++ + Options.text("arg-3") ++ + Options.text("arg-4") + ).map((Result.apply _).tupled) + + private val cliApp: CliApp[Any, Nothing, Result] = + CliApp.make( + "test-app", + "v0", + HelpDoc.Span.empty, + Command("cmd", options) + )(ZIO.succeed(_)) + + private def makeTest( + name: String + )(cmdLine: String*)(fromFiles: (String, List[String])*)(exp: Result)(implicit loc: SourceLocation): Spec[Any, Any] = + test(name) { + cliApp + .runWithFileArgs(cmdLine.toList) + .map(assert(_)(isSome(equalTo(exp)))) + .provideLayer(ZLayer.succeed(Const(fromFiles.toList.map { case (p, a) => FileArgs.ArgsFromFile(p, a) }))) + } + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("FileArgsOverrideSpec")( + suite("option+param together")( + makeTest("all file args overridden")("--arg-1=arg", "--arg-2=arg", "--arg-3=arg", "--arg-4=arg")( + "/a/b/c/.cmd" -> List("--arg-1=/a/b/c.cmd"), + "/a/b/.cmd" -> List("--arg-2=/a/b.cmd"), + "/a/.cmd" -> List("--arg-3=/a.cmd") + )(Result("arg", "arg", "arg", "arg")), + makeTest("inheritance hierarchy")("--arg-1=arg")( + "/a/b/c/.cmd" -> List("--arg-1=/a/b/c.cmd", "--arg-2=/a/b/c.cmd"), + "/a/b/.cmd" -> List("--arg-1=/a/b.cmd", "--arg-2=/a/b.cmd", "--arg-3=/a/b.cmd"), + "/a/.cmd" -> List("--arg-1=/a.cmd", "--arg-2=/a.cmd", "--arg-3=/a.cmd", "--arg-4=/a.cmd") + )(Result("arg", "/a/b/c.cmd", "/a/b.cmd", "/a.cmd")) + ), + suite("option+param separate")( + makeTest("all file args overridden")("--arg-1", "arg", "--arg-2", "arg", "--arg-3", "arg", "--arg-4", "arg")( + "/a/b/c/.cmd" -> List("--arg-1", "/a/b/c.cmd"), + "/a/b/.cmd" -> List("--arg-2", "/a/b.cmd"), + "/a/.cmd" -> List("--arg-3", "/a.cmd") + )(Result("arg", "arg", "arg", "arg")), + makeTest("inheritance hierarchy")("--arg-1", "arg")( + "/a/b/c/.cmd" -> List("--arg-1", "/a/b/c.cmd", "--arg-2", "/a/b/c.cmd"), + "/a/b/.cmd" -> List("--arg-1", "/a/b.cmd", "--arg-2", "/a/b.cmd", "--arg-3", "/a/b.cmd"), + "/a/.cmd" -> List("--arg-1", "/a.cmd", "--arg-2", "/a.cmd", "--arg-3", "/a.cmd", "--arg-4", "/a.cmd") + )(Result("arg", "/a/b/c.cmd", "/a/b.cmd", "/a.cmd")) + ) + ) + +} diff --git a/zio-cli/shared/src/test/scala/zio/cli/OptionsSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/OptionsSpec.scala index ed2ea282..137061ba 100644 --- a/zio-cli/shared/src/test/scala/zio/cli/OptionsSpec.scala +++ b/zio-cli/shared/src/test/scala/zio/cli/OptionsSpec.scala @@ -23,7 +23,7 @@ object OptionsSpec extends ZIOSpecDefault { val options: Options[(String, String, BigInt)] = f ++ l ++ a def validation[A](options: Options[A], args: List[String], conf: CliConfig): IO[ValidationError, (List[String], A)] = - Options.validate(options, args, conf).flatMap { case (err, rest, a) => + Options.validate(options, args, Nil, conf).flatMap { case (err, rest, a) => err match { case None => ZIO.succeed((rest, a)) case Some(e) => ZIO.fail(e)