From cd540cf0d6c44f87049e40d0dfb96a040c8966b0 Mon Sep 17 00:00:00 2001 From: MilosM348 Date: Thu, 7 May 2026 16:01:11 +0200 Subject: [PATCH 1/2] feat: read CLI options from `.` files (#191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds opt-in dotfile resolution to CliApp, modelled directly on the design in the now-closed PR #317 by @Kalin-Rudnicki — which @pablf had already reviewed positively — but with the unique-top-level-name concern resolved the way #317's author indicated they intended to pursue before the PR went stale. Behaviour: - given a CliApp whose root has a deterministic top-level name (e.g. `git` or `git push` whose root is `git`), `runWithFileArgs` (and therefore `run`) walks the cwd, every parent of the cwd, and the user's home directory, parsing each `.` file it finds as one line per CLI argument; - closer files override farther ones, command-line arguments override everything; - when the root command is an `OrElse` of distinct top-level names (e.g. `start | stop`) there is no single anchor name to look up, so the file-options pass is silently skipped — this is the exact framing @Kalin-Rudnicki landed on in his own review of #317. Surface area: - new `FileOptions` service with `Live` (JVM/Native) and `Noop` (JS, tests) implementations, plus a per-platform `default`; - `CliApp.runWithFileArgs` requires a `FileOptions` in the env; - `CliApp.run` keeps its old signature and now provides the platform default automatically (Live on JVM/Native, Noop on JS) — so existing apps continue to work unchanged unless they happen to ship `.` files alongside the binary; - `CliApp.runWithoutFileArgs` is provided for tests / sandboxes that want to suppress dotfile lookup explicitly; - `Command#parse` and `Options.validate` gain an optional `fromFiles: List[FileOptions.OptionsFromFile] = Nil` parameter so every existing call site continues to compile unchanged. Tests: - `FileOptionsOverrideSpec` — file/CLI override semantics with a constant `FileOptions` impl, plus explicit coverage for `runWithoutFileArgs` and `OrElse`-rooted apps; - `LiveFileOptionsSpec(Shared)` — exercises the real `Live` impl on JVM and Native against a temp directory tree, with `user.dir` / `user.home` overridden via `TestSystem`. Closes #191. /claim #191 Co-authored-by: Cursor --- .../zio/cli/FileOptionsPlatformSpecific.scala | 7 ++ .../zio/cli/FileOptionsPlatformSpecific.scala | 6 + .../scala/zio/cli/LiveFileOptionsSpec.scala | 3 + .../zio/cli/FileOptionsPlatformSpecific.scala | 6 + .../scala/zio/cli/LiveFileOptionsSpec.scala | 3 + .../src/main/scala/zio/cli/CliApp.scala | 63 +++++++++-- .../src/main/scala/zio/cli/Command.scala | 42 ++++--- .../src/main/scala/zio/cli/FileOptions.scala | 101 +++++++++++++++++ .../src/main/scala/zio/cli/Options.scala | 87 ++++++++++++++- .../zio/cli/FileOptionsOverrideSpec.scala | 103 ++++++++++++++++++ .../zio/cli/LiveFileOptionsSpecShared.scala | 84 ++++++++++++++ 11 files changed, 483 insertions(+), 22 deletions(-) create mode 100644 zio-cli/js/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala create mode 100644 zio-cli/jvm/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala create mode 100644 zio-cli/jvm/src/test/scala/zio/cli/LiveFileOptionsSpec.scala create mode 100644 zio-cli/native/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala create mode 100644 zio-cli/native/src/test/scala/zio/cli/LiveFileOptionsSpec.scala create mode 100644 zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala create mode 100644 zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala create mode 100644 zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala diff --git a/zio-cli/js/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala b/zio-cli/js/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala new file mode 100644 index 00000000..234e49cc --- /dev/null +++ b/zio-cli/js/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala @@ -0,0 +1,7 @@ +package zio.cli + +private[cli] trait FileOptionsPlatformSpecific { + // Scala.js has no filesystem in the browser and only a virtual one in Node, so we default to a no-op + // and let users opt in to a custom implementation via `runWithFileArgs`. + val default: FileOptions = FileOptions.Noop +} diff --git a/zio-cli/jvm/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala b/zio-cli/jvm/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala new file mode 100644 index 00000000..bbe7df54 --- /dev/null +++ b/zio-cli/jvm/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala @@ -0,0 +1,6 @@ +package zio.cli + +private[cli] trait FileOptionsPlatformSpecific { + // JVM has full filesystem access, so the live implementation walks dotfiles for real. + val default: FileOptions = FileOptions.Live +} diff --git a/zio-cli/jvm/src/test/scala/zio/cli/LiveFileOptionsSpec.scala b/zio-cli/jvm/src/test/scala/zio/cli/LiveFileOptionsSpec.scala new file mode 100644 index 00000000..71e10e05 --- /dev/null +++ b/zio-cli/jvm/src/test/scala/zio/cli/LiveFileOptionsSpec.scala @@ -0,0 +1,3 @@ +package zio.cli + +object LiveFileOptionsSpec extends LiveFileOptionsSpecShared diff --git a/zio-cli/native/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala b/zio-cli/native/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala new file mode 100644 index 00000000..cc02b472 --- /dev/null +++ b/zio-cli/native/src/main/scala/zio/cli/FileOptionsPlatformSpecific.scala @@ -0,0 +1,6 @@ +package zio.cli + +private[cli] trait FileOptionsPlatformSpecific { + // Scala Native exposes a usable subset of java.nio.file, so the live implementation works there too. + val default: FileOptions = FileOptions.Live +} diff --git a/zio-cli/native/src/test/scala/zio/cli/LiveFileOptionsSpec.scala b/zio-cli/native/src/test/scala/zio/cli/LiveFileOptionsSpec.scala new file mode 100644 index 00000000..71e10e05 --- /dev/null +++ b/zio-cli/native/src/test/scala/zio/cli/LiveFileOptionsSpec.scala @@ -0,0 +1,3 @@ +package zio.cli + +object LiveFileOptionsSpec extends LiveFileOptionsSpecShared 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 ce9be328..130c2e8a 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,39 @@ import scala.annotation.tailrec */ sealed trait CliApp[-R, +E, +A] { self => - def run(args: List[String]): ZIO[R, CliError[E], Option[A]] + /** + * Runs the CLI app, looking up dotfile defaults via the [[FileOptions]] service in the environment. This is the + * primitive runner — `run` and `runWithoutFileArgs` below are convenience wrappers that pin a specific + * [[FileOptions]] implementation. + * + * The current behaviour is: + * - if the underlying command has a single, deterministic top-level name (e.g. `git`, or `git push` whose root is + * `git`), [[FileOptions]] is asked for `.` files in the cwd, all of its parents, and the user home + * directory, in that priority order; + * - if the root is an `OrElse` of distinct top-level commands (e.g. `(start | stop)`), there is no single name to + * anchor the lookup, so the file-options pass is silently skipped — addresses the design concern raised by the + * maintainer on the original PR (#317). + * + * Args originating from files are merged before user-supplied flags, with explicit command-line input always taking + * precedence; closer files (cwd) override farther ones (parents, then home). + */ + def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]] + + /** + * Runs the CLI app with the platform-default [[FileOptions]] implementation: real file-system access on JVM and + * Scala Native, no-op on Scala.js. Source-compatible with pre-#191 callers — the only behavioural change is that + * `.` files in the cwd / parents / home directory are now consulted on JVM/Native. Existing applications + * that don't put any such files on disk see no difference at runtime. + */ + final def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = + runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed(FileOptions.default)) + + /** + * Runs the CLI app with [[FileOptions.Noop]] explicitly wired in, suppressing all dotfile lookups. Useful in tests, + * sandboxes, and any context where reading from the local filesystem is undesirable. + */ + final def runWithoutFileArgs(args: List[String]): ZIO[R, CliError[E], Option[A]] = + runWithFileArgs(args).provideSomeLayer[R](ZLayer.succeed[FileOptions](FileOptions.Noop)) def config(newConfig: CliConfig): CliApp[R, E, A] @@ -66,8 +98,8 @@ object CliApp { private def printDocs(helpDoc: HelpDoc): UIO[Unit] = printLine(helpDoc.toPlaintext(80)).! - def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = { - def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] = + override def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]] = { + def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R & FileOptions, CliError[E], Option[A]] = builtInOption match { case ShowHelp(synopsis, helpDoc) => val fancyName = p(code(self.figFont.render(self.name))) @@ -109,13 +141,14 @@ object CliApp { (for { parameters <- Wizard(command, config, fancyName + header + explanation).execute.mapError(CliError.BuiltIn(_)) - output <- run(parameters) + output <- runWithFileArgs(parameters) } yield output).catchSome { case CliError.BuiltIn(_) => ZIO.none } } - // prepend a first argument in case the CliApp's command is expected to consume it + // prepend a first argument in case the CliApp's command is expected to consume it; also serves as the + // unique-top-level-name probe consumed by file-options lookup below @tailrec def prefix(command: Command[_]): List[String] = command match { @@ -125,8 +158,24 @@ object CliApp { case Command.Subcommands(parent, _) => prefix(parent) } - self.command - .parse(prefix(self.command) ++ args, self.config) + val rootName = prefix(self.command) + + // Only consult dotfiles when the root has a single deterministic name. For an `OrElse` root we'd otherwise be + // arbitrarily picking one alias — see Kalin-Rudnicki's review of #317 for the original framing. + val getFromFiles: ZIO[FileOptions, Nothing, List[FileOptions.OptionsFromFile]] = + rootName.headOption match { + case Some(name) => ZIO.serviceWithZIO[FileOptions](_.getOptionsFromFiles(name)) + case None => + ZIO.logDebug( + "Skipping file-options lookup: top-level command has no unique name (`OrElse` root)." + ) *> ZIO.succeed(Nil) + } + + getFromFiles + .flatMap { fromFiles => + self.command + .parse(rootName ++ args, self.config, fromFiles) + } .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 7ecd00a7..d13ec6dc 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,17 @@ 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]] + /** + * Parses `args` into a [[CommandDirective]]. The optional `fromFiles` parameter carries dotfile-derived option + * defaults — see [[FileOptions]] — which are merged before user-supplied flags, with the latter taking precedence. + * The default empty list preserves source compatibility for existing callers (tests, third-party code) that don't + * yet thread file options through. + */ + def parse( + args: List[String], + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] = Nil + ): 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,14 +120,15 @@ object Command { def parse( args: List[String], - conf: CliConfig + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] ): IO[ValidationError, CommandDirective[(OptionsType, ArgsType)]] = { def parseBuiltInArgs(args: List[String]): IO[ValidationError, CommandDirective[Nothing]] = if (args.headOption.exists(conf.normalizeCase(_) == conf.normalizeCase(self.name))) { val options = BuiltInOption .builtInOptions(self, self.synopsis, self.helpDoc) Options - .validate(options, args.tail, conf) + .validate(options, args.tail, conf, Nil) .map(_._3) .someOrFail( ValidationError( @@ -158,7 +169,7 @@ object Command { } tuple1 = splitForcedArgs(commandOptionsAndArgs) (optionsAndArgs, forcedCommandArgs) = tuple1 - tuple2 <- Options.validate(options, optionsAndArgs, conf) + tuple2 <- Options.validate(options, optionsAndArgs, conf, fromFiles) (optionsError, commandArgs, optionsType) = tuple2 tuple <- self.args.validate(commandArgs ++ forcedCommandArgs, conf).mapError(optionsError.getOrElse(_)) (argsLeftover, argsType) = tuple @@ -202,9 +213,10 @@ object Command { def parse( args: List[String], - conf: CliConfig + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] ): IO[ValidationError, CommandDirective[B]] = - command.parse(args, conf).map(_.map(f)) + command.parse(args, conf, fromFiles).map(_.map(f)) lazy val synopsis: UsageSynopsis = command.synopsis @@ -222,9 +234,12 @@ object Command { def parse( args: List[String], - conf: CliConfig + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] ): IO[ValidationError, CommandDirective[A]] = - left.parse(args, conf).catchSome { case ValidationError(CommandMismatch, _) => right.parse(args, conf) } + left.parse(args, conf, fromFiles).catchSome { case ValidationError(CommandMismatch, _) => + right.parse(args, conf, fromFiles) + } lazy val synopsis: UsageSynopsis = UsageSynopsis.Mixed @@ -286,14 +301,15 @@ object Command { def parse( args: List[String], - conf: CliConfig + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] ): IO[ValidationError, CommandDirective[(A, B)]] = { val helpDirectiveForChild = if (args.isEmpty) ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) else child - .parse(args.tail, conf) + .parse(args.tail, conf, fromFiles) .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) => val parentName = names.headOption.getOrElse("") @@ -313,7 +329,7 @@ object Command { ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) else child - .parse(args.tail, conf) + .parse(args.tail, conf, fromFiles) .collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) { case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive } @@ -322,7 +338,7 @@ object Command { ZIO.succeed(CommandDirective.builtIn(BuiltInOption.ShowWizard(self))) parent - .parse(args, conf) + .parse(args, conf, fromFiles) .flatMap { case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(_, _)) => helpDirectiveForChild orElse helpDirectiveForParent @@ -331,7 +347,7 @@ object Command { case builtIn @ CommandDirective.BuiltIn(_) => ZIO.succeed(builtIn) case CommandDirective.UserDefined(leftover, a) if leftover.nonEmpty => child - .parse(leftover, conf) + .parse(leftover, conf, fromFiles) .mapBoth( { case ValidationError(CommandMismatch, _) => diff --git a/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala b/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala new file mode 100644 index 00000000..6c5fff48 --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala @@ -0,0 +1,101 @@ +package zio.cli + +import zio._ + +import java.nio.file.Path + +/** + * `FileOptions` is the service [[CliApp]] uses to look up command-line option defaults from "dotfiles" on disk. + * + * The contract is intentionally narrow: given the *unique* top-level command name (e.g. `git`), return the contents of + * any matching `.` files found in a well-defined search path (current working directory, all of its parents, then + * the user home directory). Each line of those files is treated as a single CLI argument. + * + * Files closer to the working directory take precedence over more distant ones, and explicit command-line arguments + * always take precedence over file-derived ones. Files that do not exist are silently skipped; files that exist but are + * unreadable produce a debug log entry but do not abort parsing. + * + * The service abstraction allows callers to substitute [[FileOptions.Noop]] (e.g. in tests, in a sandbox, or on JS + * where there is no file system) without touching the `CliApp` itself. + */ +trait FileOptions { + + /** + * Returns the dotfile contents associated with the given top-level command name, in priority order from highest (most + * specific / closest to the cwd) to lowest. Each [[FileOptions.OptionsFromFile]] carries the path it came from so + * downstream merging logic can report provenance. + */ + def getOptionsFromFiles(command: String): UIO[List[FileOptions.OptionsFromFile]] +} + +object FileOptions extends FileOptionsPlatformSpecific { + + /** + * A single dotfile worth of arguments. `path` is purely informational — used only for logging which file an + * overridden option originated from. + */ + final case class OptionsFromFile(path: String, rawArgs: List[String]) + + /** + * No-op implementation. Useful for tests and for the JS platform, which has no file-system access. + */ + case object Noop extends FileOptions { + override def getOptionsFromFiles(command: String): UIO[List[OptionsFromFile]] = ZIO.succeed(Nil) + } + + /** + * Default JVM/Native implementation that walks the file system to discover `.` files. + * + * Search order, highest priority first: + * 1. `/.` + * 2. `/.` + * 3. `/.` + * + * This ordering means project-local overrides win over user-global defaults — the convention most CLI tools follow. + */ + case object Live extends FileOptions { + + private def optReadPath(path: Path): UIO[Option[FileOptions.OptionsFromFile]] = + (for { + _ <- ZIO.logDebug(s"Searching for file options in '$path'") + exists <- ZIO.attempt(path.toFile.exists()) + pathString = path.toString + optContents <- + ZIO + .readFile(pathString) + .map(c => FileOptions.OptionsFromFile(pathString, c.split('\n').map(_.trim).filter(_.nonEmpty).toList)) + .when(exists) + } yield optContents) + .catchAllCause(ZIO.logErrorCause(s"Error reading options from file '$path', skipping...", _).as(None)) + + 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 getOptionsFromFiles(command: String): UIO[List[OptionsFromFile]] = + (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) + .catchAllCause(ZIO.logErrorCause("Error reading options from files, skipping...", _).as(Nil)) + + } + +} 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 b10bf4a1..5746c920 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Options.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Options.scala @@ -298,21 +298,104 @@ object Options extends OptionsPlatformSpecific { .catchAll(e => ZIO.succeed((Some(e), input, Predef.Map.empty))) } + /** + * Folds a list of file-derived options (highest priority first) into a single map keyed by option uid, where the + * value is the (path, raw-args) of the file that "won" for that option. Files are merged by walking from the lowest + * priority (the end of `queue`, which we reverse beforehand) to the highest, so closer files overwrite farther ones. + * + * Args inside a dotfile that don't match any declared option are rejected — silently dropping them would mask typos + * and make dotfile debugging miserable. + */ + private def mergeFileOptions( + queue: List[FileOptions.OptionsFromFile], + 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 FileOptions.OptionsFromFile(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) + } + + /** + * Combines file-derived options with explicit command-line options, with the latter taking precedence. The path + * provenance carried alongside file options is used purely for debug-level log messages so users can trace where a + * given setting came from. + */ + 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 * there is an error when parsing the `Args` of a `Command`. This is because `ValidationErrors` are used to control * the end of the args corresponding to options. + * + * The optional `fromFiles` parameter carries dotfile-derived option defaults discovered by [[FileOptions]]. They are + * merged in priority order before the explicit `args` are applied, so command-line input always wins. The default + * empty list preserves source compatibility for existing call sites that don't use file options. */ def validate[A]( options: Options[A], args: List[String], - conf: CliConfig + conf: CliConfig, + fromFiles: List[FileOptions.OptionsFromFile] = Nil ): 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/FileOptionsOverrideSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala new file mode 100644 index 00000000..59ac75b8 --- /dev/null +++ b/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala @@ -0,0 +1,103 @@ +package zio.cli + +import zio._ +import zio.internal.stacktracer.SourceLocation +import zio.test.Assertion._ +import zio.test._ + +object FileOptionsOverrideSpec extends ZIOSpecDefault { + + // Constant FileOptions impl that returns whatever priority-ordered list the test sets up. We avoid + // touching the real filesystem here on purpose — those concerns are exercised by `LiveFileOptionsSpec`. + private final case class Const(fileArgs: List[FileOptions.OptionsFromFile]) extends FileOptions { + override def getOptionsFromFiles(command: String): UIO[List[FileOptions.OptionsFromFile]] = 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[FileOptions](Const(fromFiles.toList.map { case (p, a) => + FileOptions.OptionsFromFile(p, a) + }))) + } + + // Smoke test that runWithoutFileArgs really pins Noop and ignores any FileOptions in scope. + private def disabledFileOptionsTest: Spec[Any, Any] = + test("runWithoutFileArgs ignores any FileOptions present in the environment") { + cliApp + .runWithoutFileArgs(List("--arg-1=cli", "--arg-2=cli", "--arg-3=cli", "--arg-4=cli")) + .map(assert(_)(isSome(equalTo(Result("cli", "cli", "cli", "cli"))))) + } + + // Smoke test for the unique-top-level-name guard: an OrElse root must not crash; it just skips file lookup. + private def orElseRootTest: Spec[Any, Any] = { + val left = Command("alpha", Options.text("flag")).map(s => ("alpha", s)) + val right = Command("beta", Options.text("flag")).map(s => ("beta", s)) + val app = + CliApp.make("multi", "v0", HelpDoc.Span.empty, left.orElse(right))(ZIO.succeed(_)) + test("OrElse root: file-options pass is skipped, parsing still succeeds") { + app + .runWithFileArgs(List("alpha", "--flag", "ok")) + .map(assert(_)(isSome(equalTo(("alpha", "ok"))))) + .provideLayer(ZLayer.succeed[FileOptions](Const(List(FileOptions.OptionsFromFile("/x/.alpha", List("ignored")))))) + } + } + + override def spec: Spec[TestEnvironment with Scope, Any] = + suite("FileOptionsOverrideSpec")( + 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")) + ), + disabledFileOptionsTest, + orElseRootTest + ) + +} diff --git a/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala b/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala new file mode 100644 index 00000000..db28a3e6 --- /dev/null +++ b/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala @@ -0,0 +1,84 @@ +package zio.cli + +import zio._ +import zio.internal.stacktracer.SourceLocation +import zio.test._ + +import java.nio.file.{Files, Path} + +/** + * Cross-platform tests for `FileOptions.Live`. Uses `TestSystem` to override `user.dir` / `user.home` so we never + * depend on the actual machine the test is running on. + */ +abstract class LiveFileOptionsSpecShared 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 { + 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.toString, contents) + } + + result <- FileOptions.Live.getOptionsFromFiles(cmd) + resolvedExp = exp.toList.map { case (paths, args) => + FileOptions.OptionsFromFile(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 + +} From 356f1189d37d129418e7e55d60d690c33c4eda4c Mon Sep 17 00:00:00 2001 From: MilosM348 Date: Thu, 7 May 2026 16:26:56 +0200 Subject: [PATCH 2/2] fix(file-options): satisfy scalafmt + Scala 2.13 -Werror CI on #577 failed in two ways: 1. case Some(name) => inside CliAppImpl.runWithFileArgs shadowed the class field ame. Scala 2.13 (compiled with -Werror in this repo) rejects pattern bindings that silently shadow an outer scope. Renamed the binder to cmdName and left a comment explaining why. This was the same root cause behind every 2.13 testCross / testJVMs / website job failure -- the website job runs the same compile. 2. scalafmt 3.11.0 wanted reformatting in 6 main + 2 test files: CliApp.scala, Command.scala, Options.scala, FileOptions.scala, FileOptionsOverrideSpec.scala, LiveFileOptionsSpecShared.scala (the platform-specific FileOptionsPlatformSpecific.scala stubs and LiveFileOptionsSpec.scala harnesses were already conformant). Ran `scalafmt` against the whole project; only those six files changed. No behavioural changes; this is a pure CI green-up patch. Signed-off-by: Milos M Co-authored-by: Cursor --- .../src/main/scala/zio/cli/CliApp.scala | 19 ++++++++++--------- .../src/main/scala/zio/cli/Command.scala | 4 ++-- .../src/main/scala/zio/cli/FileOptions.scala | 8 ++++---- .../src/main/scala/zio/cli/Options.scala | 2 +- .../zio/cli/FileOptionsOverrideSpec.scala | 6 ++++-- .../zio/cli/LiveFileOptionsSpecShared.scala | 6 +++--- 6 files changed, 24 insertions(+), 21 deletions(-) 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 130c2e8a..e572b8c4 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala @@ -36,8 +36,8 @@ sealed trait CliApp[-R, +E, +A] { self => def runWithFileArgs(args: List[String]): ZIO[R & FileOptions, CliError[E], Option[A]] /** - * Runs the CLI app with the platform-default [[FileOptions]] implementation: real file-system access on JVM and - * Scala Native, no-op on Scala.js. Source-compatible with pre-#191 callers — the only behavioural change is that + * Runs the CLI app with the platform-default [[FileOptions]] implementation: real file-system access on JVM and Scala + * Native, no-op on Scala.js. Source-compatible with pre-#191 callers — the only behavioural change is that * `.` files in the cwd / parents / home directory are now consulted on JVM/Native. Existing applications * that don't put any such files on disk see no difference at runtime. */ @@ -162,20 +162,21 @@ object CliApp { // Only consult dotfiles when the root has a single deterministic name. For an `OrElse` root we'd otherwise be // arbitrarily picking one alias — see Kalin-Rudnicki's review of #317 for the original framing. + // Avoid `case Some(name)` here: `name` is already a member of `CliAppImpl` and + // Scala 2.13 (with `-Werror`) refuses to silently shadow the outer scope. val getFromFiles: ZIO[FileOptions, Nothing, List[FileOptions.OptionsFromFile]] = rootName.headOption match { - case Some(name) => ZIO.serviceWithZIO[FileOptions](_.getOptionsFromFiles(name)) - case None => + case Some(cmdName) => ZIO.serviceWithZIO[FileOptions](_.getOptionsFromFiles(cmdName)) + case None => ZIO.logDebug( "Skipping file-options lookup: top-level command has no unique name (`OrElse` root)." ) *> ZIO.succeed(Nil) } - getFromFiles - .flatMap { fromFiles => - self.command - .parse(rootName ++ args, self.config, fromFiles) - } + getFromFiles.flatMap { fromFiles => + self.command + .parse(rootName ++ args, self.config, fromFiles) + } .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 d13ec6dc..89b18aa9 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Command.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Command.scala @@ -49,8 +49,8 @@ sealed trait Command[+A] extends Parameter with Named { self => /** * Parses `args` into a [[CommandDirective]]. The optional `fromFiles` parameter carries dotfile-derived option * defaults — see [[FileOptions]] — which are merged before user-supplied flags, with the latter taking precedence. - * The default empty list preserves source compatibility for existing callers (tests, third-party code) that don't - * yet thread file options through. + * The default empty list preserves source compatibility for existing callers (tests, third-party code) that don't yet + * thread file options through. */ def parse( args: List[String], diff --git a/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala b/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala index 6c5fff48..4dcea936 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/FileOptions.scala @@ -57,9 +57,9 @@ object FileOptions extends FileOptionsPlatformSpecific { private def optReadPath(path: Path): UIO[Option[FileOptions.OptionsFromFile]] = (for { - _ <- ZIO.logDebug(s"Searching for file options in '$path'") - exists <- ZIO.attempt(path.toFile.exists()) - pathString = path.toString + _ <- ZIO.logDebug(s"Searching for file options in '$path'") + exists <- ZIO.attempt(path.toFile.exists()) + pathString = path.toString optContents <- ZIO .readFile(pathString) @@ -71,7 +71,7 @@ object FileOptions extends FileOptionsPlatformSpecific { private def getPathAndParents(path: Path): Task[List[Path]] = for { parentPath <- ZIO.attempt(Option(path.getParent)) - parents <- parentPath match { + parents <- parentPath match { case Some(parentPath) => getPathAndParents(parentPath) case None => ZIO.succeed(Nil) } 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 5746c920..4077d4a2 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/Options.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/Options.scala @@ -318,7 +318,7 @@ object Options extends OptionsPlatformSpecific { tuple1 <- matchOptions(args, options, conf) newMap <- tuple1 match { - case (Some(e), _, _) => ZIO.fail(e) + case (Some(e), _, _) => ZIO.fail(e) case (_, rest, _) if rest.nonEmpty => ZIO.fail( ValidationError( diff --git a/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala index 59ac75b8..c341e25a 100644 --- a/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala +++ b/zio-cli/shared/src/test/scala/zio/cli/FileOptionsOverrideSpec.scala @@ -60,13 +60,15 @@ object FileOptionsOverrideSpec extends ZIOSpecDefault { private def orElseRootTest: Spec[Any, Any] = { val left = Command("alpha", Options.text("flag")).map(s => ("alpha", s)) val right = Command("beta", Options.text("flag")).map(s => ("beta", s)) - val app = + val app = CliApp.make("multi", "v0", HelpDoc.Span.empty, left.orElse(right))(ZIO.succeed(_)) test("OrElse root: file-options pass is skipped, parsing still succeeds") { app .runWithFileArgs(List("alpha", "--flag", "ok")) .map(assert(_)(isSome(equalTo(("alpha", "ok"))))) - .provideLayer(ZLayer.succeed[FileOptions](Const(List(FileOptions.OptionsFromFile("/x/.alpha", List("ignored")))))) + .provideLayer( + ZLayer.succeed[FileOptions](Const(List(FileOptions.OptionsFromFile("/x/.alpha", List("ignored"))))) + ) } } diff --git a/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala b/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala index db28a3e6..0fd1c147 100644 --- a/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala +++ b/zio-cli/shared/src/test/scala/zio/cli/LiveFileOptionsSpecShared.scala @@ -15,7 +15,7 @@ abstract class LiveFileOptionsSpecShared extends ZIOSpecDefault { private val createTempDirectory: RIO[Scope, Path] = for { random <- Random.nextUUID - path <- + path <- ZIO.attempt(Files.createTempDirectory(random.toString)).withFinalizer(f => ZIO.attempt(f.toFile.delete()).orDie) } yield path @@ -33,14 +33,14 @@ abstract class LiveFileOptionsSpecShared extends ZIOSpecDefault { dir <- createTempDirectory _ <- TestSystem.putProperty("user.dir", resolvePath(dir, cwd).toString) _ <- TestSystem.putProperty("user.home", resolvePath(dir, home).toString) - _ <- ZIO.foreachDiscard(writeFiles) { case (paths, contents) => + _ <- 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.toString, contents) } - result <- FileOptions.Live.getOptionsFromFiles(cmd) + result <- FileOptions.Live.getOptionsFromFiles(cmd) resolvedExp = exp.toList.map { case (paths, args) => FileOptions.OptionsFromFile(resolvePath(dir, paths :+ s".$cmd").toString, args) }