diff --git a/zio-cli/js/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala b/zio-cli/js/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..8e7ae1f2 --- /dev/null +++ b/zio-cli/js/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,6 @@ +package zio.cli + +private[cli] trait ConfigFileResolverPlatformSpecific { + + lazy val live: ConfigFileResolver = ConfigFileResolver.none +} diff --git a/zio-cli/jvm/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala b/zio-cli/jvm/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..78a16fc5 --- /dev/null +++ b/zio-cli/jvm/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,42 @@ +package zio.cli + +import zio._ + +import java.nio.file.{Files => JFiles, Path => JPath, Paths => JPaths} + +private[cli] trait ConfigFileResolverPlatformSpecific { + + lazy val live: ConfigFileResolver = new ConfigFileResolver { + + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.attempt { + val dotFileName = s".$appName" + val cwd = + JPaths.get(java.lang.System.getProperty("user.dir")).toAbsolutePath.normalize() + + val files = collectDotFiles(cwd, dotFileName) + val orderedFiles = files.reverse + + orderedFiles.foldLeft((List.empty[String], List.empty[SettingSource])) { case ((accArgs, accSources), file) => + val content = new String(JFiles.readAllBytes(file), "UTF-8") + val filePath = file.toString + val (fileArgs, _) = ConfigFileResolver.parseDotFile(content, filePath) + ConfigFileResolver.mergeArgs(accArgs, accSources, fileArgs) + } + } + .catchAll(_ => ZIO.succeed((Nil, Nil))) + + private def collectDotFiles(startDir: JPath, dotFileName: String): List[JPath] = { + var result = List.empty[JPath] + var dir: JPath = startDir + while (dir != null) { + val dotFile = dir.resolve(dotFileName) + if (JFiles.isRegularFile(dotFile) && JFiles.isReadable(dotFile)) { + result = dotFile :: result + } + dir = dir.getParent + } + result.reverse + } + } +} diff --git a/zio-cli/jvm/src/test/scala/zio/cli/ConfigFileResolverSpec.scala b/zio-cli/jvm/src/test/scala/zio/cli/ConfigFileResolverSpec.scala new file mode 100644 index 00000000..96f929b9 --- /dev/null +++ b/zio-cli/jvm/src/test/scala/zio/cli/ConfigFileResolverSpec.scala @@ -0,0 +1,128 @@ +package zio.cli + +import zio._ +import zio.test._ + +import java.nio.file.{Files => JFiles, Path => JPath} + +object ConfigFileResolverSpec extends ZIOSpecDefault { + + private def withTempDirTree( + structure: List[(String, String)] + )(testFn: JPath => ZIO[Any, Any, TestResult]): ZIO[Any, Any, TestResult] = + ZIO.acquireReleaseWith( + ZIO.attempt { + val baseDir = JFiles.createTempDirectory("zio-cli-test") + structure.foreach { case (relativePath, content) => + val file = baseDir.resolve(relativePath) + JFiles.createDirectories(file.getParent) + JFiles.write(file, content.getBytes("UTF-8")) + } + baseDir + } + )(dir => + ZIO.attempt { + def deleteRecursive(path: JPath): Unit = { + if (JFiles.isDirectory(path)) { + val stream = JFiles.list(path) + try stream.forEach(p => deleteRecursive(p)) + finally stream.close() + } + val _ = JFiles.deleteIfExists(path) + } + deleteRecursive(dir) + }.orDie + )(testFn) + + def spec = suite("ConfigFileResolver JVM Suite")( + test("reads a single dotfile and returns correct args") { + withTempDirTree( + List(".testapp" -> "--name test-value\n--verbose") + ) { dir => + val filePath = dir.resolve(".testapp").toString + for { + result <- ZIO.attempt { + val content = new String(JFiles.readAllBytes(dir.resolve(".testapp")), "UTF-8") + ConfigFileResolver.parseDotFile(content, filePath) + } + (args, sources) = result + } yield assertTrue( + args == List("--name", "test-value", "--verbose"), + sources.length == 2, + sources.exists(s => s.name == "--name" && s.value == "test-value"), + sources.exists(s => s.name == "--verbose" && s.value == "") + ) + } + }, + test("nested directories: closer file overrides parent file") { + withTempDirTree( + List( + ".testapp" -> "--name root-name\n--output root-output", + "sub/.testapp" -> "--name sub-name", + "sub/deep/.testapp" -> "--output deep-output" + ) + ) { dir => + for { + result <- ZIO.attempt { + // Simulate the JVM resolver's foldLeft merge (same as collectDotFiles + fold) + // Files ordered root to CWD (reversed): root, sub, deep + val files = List( + dir.resolve(".testapp"), + dir.resolve("sub/.testapp"), + dir.resolve("sub/deep/.testapp") + ) + + files.foldLeft((List.empty[String], List.empty[SettingSource])) { + case ((accArgs, accSources), file) => + val content = new String(JFiles.readAllBytes(file), "UTF-8") + val filePath = file.toString + val (fileArgs, _) = ConfigFileResolver.parseDotFile(content, filePath) + ConfigFileResolver.mergeArgs(accArgs, accSources, fileArgs) + } + } + (mergedArgs, mergedSources) = result + } yield assertTrue( + mergedArgs.contains("--name"), + mergedArgs.contains("sub-name"), + mergedArgs.contains("--output"), + mergedArgs.contains("deep-output"), + !mergedArgs.contains("root-name"), + !mergedArgs.contains("root-output") + ) + } + }, + test("live resolver walks directory tree from CWD") { + ConfigFileResolver.live.resolve("__nonexistent_app_name__").map { case (args, sources) => + assertTrue(args.isEmpty, sources.isEmpty) + } + }, + test("end-to-end CliApp with mock resolver") { + val nameOpt = Options.text("name") + val command = Command("myapp", nameOpt, Args.none) + val resolver = new ConfigFileResolver { + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.succeed( + (List("--name", "from-dotfile"), List(SettingSource("--name", "from-dotfile", "/project/.myapp"))) + ) + } + + val app = CliApp.make[Any, Nothing, String, String]( + name = "myapp", + version = "1.0", + summary = HelpDoc.Span.text("test"), + command = command, + configFileResolver = resolver + ) { name => + ZIO.succeed(name) + } + + for { + r1 <- app.run(Nil) + r2 <- app.run(List("--name", "from-cli")) + } yield assertTrue( + r1 == Some("from-dotfile"), + r2 == Some("from-cli") + ) + } + ) +} diff --git a/zio-cli/native/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala b/zio-cli/native/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala new file mode 100644 index 00000000..78a16fc5 --- /dev/null +++ b/zio-cli/native/src/main/scala/zio/cli/ConfigFileResolverPlatformSpecific.scala @@ -0,0 +1,42 @@ +package zio.cli + +import zio._ + +import java.nio.file.{Files => JFiles, Path => JPath, Paths => JPaths} + +private[cli] trait ConfigFileResolverPlatformSpecific { + + lazy val live: ConfigFileResolver = new ConfigFileResolver { + + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.attempt { + val dotFileName = s".$appName" + val cwd = + JPaths.get(java.lang.System.getProperty("user.dir")).toAbsolutePath.normalize() + + val files = collectDotFiles(cwd, dotFileName) + val orderedFiles = files.reverse + + orderedFiles.foldLeft((List.empty[String], List.empty[SettingSource])) { case ((accArgs, accSources), file) => + val content = new String(JFiles.readAllBytes(file), "UTF-8") + val filePath = file.toString + val (fileArgs, _) = ConfigFileResolver.parseDotFile(content, filePath) + ConfigFileResolver.mergeArgs(accArgs, accSources, fileArgs) + } + } + .catchAll(_ => ZIO.succeed((Nil, Nil))) + + private def collectDotFiles(startDir: JPath, dotFileName: String): List[JPath] = { + var result = List.empty[JPath] + var dir: JPath = startDir + while (dir != null) { + val dotFile = dir.resolve(dotFileName) + if (JFiles.isRegularFile(dotFile) && JFiles.isReadable(dotFile)) { + result = dotFile :: result + } + dir = dir.getParent + } + result.reverse + } + } +} 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..458e2fdc 100644 --- a/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala +++ b/zio-cli/shared/src/main/scala/zio/cli/CliApp.scala @@ -23,8 +23,10 @@ sealed trait CliApp[-R, +E, +A] { self => final def map[B](f: A => B): CliApp[R, E, B] = self match { - case CliApp.CliAppImpl(name, version, summary, command, execute, footer, config, figFont) => - CliApp.CliAppImpl(name, version, summary, command, execute.andThen(_.map(f)), footer, config, figFont) + case impl @ CliApp.CliAppImpl(name, version, summary, command, execute, footer, config, figFont) => + CliApp + .CliAppImpl(name, version, summary, command, execute.andThen(_.map(f)), footer, config, figFont) + .withConfigFileResolver(impl.configFileResolver) } def flatMap[R1 <: R, E1 >: E, B](f: A => ZIO[R1, E1, B]): CliApp[R1, E1, B] @@ -44,9 +46,11 @@ object CliApp { command: Command[Model], footer: HelpDoc = HelpDoc.Empty, config: CliConfig = CliConfig.default, - figFont: FigFont = FigFont.Default + figFont: FigFont = FigFont.Default, + configFileResolver: ConfigFileResolver = ConfigFileResolver.live )(execute: Model => ZIO[R, E, A]): CliApp[R, E, A] = CliAppImpl(name, version, summary, command, execute, footer, config, figFont) + .withConfigFileResolver(configFileResolver) private[cli] case class CliAppImpl[-R, +E, Model, +A]( name: String, @@ -58,6 +62,17 @@ object CliApp { config: CliConfig = CliConfig.default, figFont: FigFont = FigFont.Default ) extends CliApp[R, E, A] { self => + + private var _configFileResolver: ConfigFileResolver = ConfigFileResolver.live + + def configFileResolver: ConfigFileResolver = _configFileResolver + + def withConfigFileResolver(resolver: ConfigFileResolver): CliAppImpl[R, E, Model, A] = { + val c = copy() + c._configFileResolver = resolver + c + } + def config(newConfig: CliConfig): CliApp[R, E, A] = copy(config = newConfig) def footer(newFooter: HelpDoc): CliApp[R, E, A] = @@ -67,6 +82,9 @@ object CliApp { printLine(helpDoc.toPlaintext(80)).! def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = { + val showDiagnostics = args.contains("--config-diagnostics") + val filteredArgs = args.filterNot(_ == "--config-diagnostics") + def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] = builtInOption match { case ShowHelp(synopsis, helpDoc) => @@ -125,19 +143,30 @@ object CliApp { case Command.Subcommands(parent, _) => prefix(parent) } - self.command - .parse(prefix(self.command) ++ args, self.config) - .foldZIO( - e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)), - { - case CommandDirective.UserDefined(_, value) => - self.execute(value).mapBoth(CliError.Execution(_), Some(_)) - case CommandDirective.BuiltIn(x) => - executeBuiltIn(x).catchSome { case err @ CliError.Parsing(e) => - printDocs(e.error) *> ZIO.fail(err) - } - } - ) + def runWithArgs(mergedArgs: List[String]): ZIO[R, CliError[E], Option[A]] = + self.command + .parse(prefix(self.command) ++ mergedArgs, self.config) + .foldZIO( + e => printDocs(e.error) *> ZIO.fail(CliError.Parsing(e)), + { + case CommandDirective.UserDefined(_, value) => + self.execute(value).mapBoth(CliError.Execution(_), Some(_)) + case CommandDirective.BuiltIn(x) => + executeBuiltIn(x).catchSome { case err @ CliError.Parsing(e) => + printDocs(e.error) *> ZIO.fail(err) + } + } + ) + + _configFileResolver.resolve(self.name).flatMap { case (dotfileArgs, dotfileSources) => + val (mergedArgs, allSources) = + ConfigFileResolver.mergeArgs(dotfileArgs, dotfileSources, filteredArgs) + val diagnosticsEffect = + if (showDiagnostics && allSources.nonEmpty) + printLine(ConfigFileResolver.formatDiagnostics(allSources)).! + else ZIO.unit + diagnosticsEffect *> runWithArgs(mergedArgs) + } } override def flatMap[R1 <: R, E1 >: E, B](f: A => ZIO[R1, E1, B]): CliApp[R1, E1, B] = @@ -150,7 +179,7 @@ object CliApp { footer, config, figFont - ) + ).withConfigFileResolver(_configFileResolver) override def summary(s: HelpDoc.Span): CliApp[R, E, A] = copy(summary = self.summary + s) diff --git a/zio-cli/shared/src/main/scala/zio/cli/ConfigFileResolver.scala b/zio-cli/shared/src/main/scala/zio/cli/ConfigFileResolver.scala new file mode 100644 index 00000000..9e23edb9 --- /dev/null +++ b/zio-cli/shared/src/main/scala/zio/cli/ConfigFileResolver.scala @@ -0,0 +1,219 @@ +package zio.cli + +import zio._ + +/** + * Represents a setting value along with where it came from. + */ +final case class SettingSource(name: String, value: String, source: String) + +/** + * Resolves CLI options from dotfiles by walking the directory tree from CWD upward, looking for `.` files. + * Files closer to CWD override settings from parent directories. CLI arguments override all file settings. + * + * Dotfile format: one option per line, using the same syntax as CLI arguments. Lines starting with `#` are treated as + * comments. Empty lines are ignored. + */ +trait ConfigFileResolver { + + /** + * Resolves configuration from dotfiles for the given app name. Returns a list of args (in CLI format) from dotfiles, + * ordered so that closer files override parent files, along with provenance information for diagnostics. + */ + def resolve(appName: String): UIO[(List[String], List[SettingSource])] +} + +object ConfigFileResolver extends ConfigFileResolverPlatformSpecific { + + /** + * A resolver that returns no configuration (used for JS or when dotfile support is disabled). + */ + val none: ConfigFileResolver = new ConfigFileResolver { + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.succeed((Nil, Nil)) + } + + /** + * Parses dotfile content into a list of CLI-style arguments and provenance info. + */ + private[cli] def parseDotFile( + content: String, + filePath: String + ): (List[String], List[SettingSource]) = { + val lines = content.split('\n').toList + + val (args, sources) = + lines.foldLeft((List.empty[String], List.empty[SettingSource])) { case ((accArgs, accSources), line) => + val trimmed = line.trim + if (trimmed.isEmpty || trimmed.startsWith("#")) + (accArgs, accSources) + else { + val tokens = tokenizeLine(trimmed) + val newSources = tokens match { + case name :: value :: _ if name.startsWith("-") => + List(SettingSource(name, value, filePath)) + case name :: Nil if name.startsWith("-") => + List(SettingSource(name, "", filePath)) + case _ => + Nil + } + (accArgs ++ tokens, accSources ++ newSources) + } + } + (args, sources) + } + + /** + * Tokenizes a single line into CLI argument tokens. Handles quoted values. + */ + private[cli] def tokenizeLine(line: String): List[String] = { + val tokens = scala.collection.mutable.ListBuffer.empty[String] + val current = new StringBuilder + var inQuote: Option[Char] = None + var i = 0 + + while (i < line.length) { + val c = line.charAt(i) + inQuote match { + case Some(q) => + if (c == q) inQuote = None + else current.append(c) + case None => + if (c == '"' || c == '\'') inQuote = Some(c) + else if (c == ' ' || c == '\t') { + if (current.nonEmpty) { + tokens += current.toString() + current.clear() + } + } else current.append(c) + } + i += 1 + } + if (current.nonEmpty) tokens += current.toString() + tokens.toList + } + + /** + * Merges dotfile args with CLI args. CLI args take precedence. For each option found in CLI args, remove it from the + * dotfile args. Returns the merged args and updated provenance info. + */ + private[cli] def mergeArgs( + dotfileArgs: List[String], + dotfileSources: List[SettingSource], + cliArgs: List[String] + ): (List[String], List[SettingSource]) = { + val cliOptionNames = extractOptionNames(cliArgs) + + val filteredDotfileArgs = filterOverriddenArgs(dotfileArgs, cliOptionNames) + val filteredSources = + dotfileSources.filterNot(s => cliOptionNames.contains(normalizeOptionName(s.name))) + + val cliSources = extractCliSources(cliArgs) + + (filteredDotfileArgs ++ cliArgs, filteredSources ++ cliSources) + } + + private[cli] def extractOptionNamesFromArgs(args: List[String]): Set[String] = + args.filter(_.startsWith("-")).map(normalizeOptionName).toSet + + private def extractOptionNames(args: List[String]): Set[String] = + extractOptionNamesFromArgs(args) + + private[cli] def normalizeOptionName(name: String): String = + name.takeWhile(_ != '=').toLowerCase + + private[cli] def filterOverriddenFromArgs( + args: List[String], + overriddenNames: Set[String] + ): List[String] = filterOverriddenArgs(args, overriddenNames) + + private def filterOverriddenArgs( + args: List[String], + overriddenNames: Set[String] + ): List[String] = { + var result = List.empty[String] + var remaining = args + while (remaining.nonEmpty) { + remaining match { + case name :: value :: rest if name.startsWith("-") && !value.startsWith("-") => + if (!overriddenNames.contains(normalizeOptionName(name))) + result = result :+ name :+ value + remaining = rest + case name :: rest if name.startsWith("-") => + if (!overriddenNames.contains(normalizeOptionName(name))) + result = result :+ name + remaining = rest + case other :: rest => + result = result :+ other + remaining = rest + case Nil => + remaining = Nil + } + } + result + } + + private def extractCliSources(args: List[String]): List[SettingSource] = { + var result = List.empty[SettingSource] + var remaining = args + while (remaining.nonEmpty) { + remaining match { + case name :: value :: rest if name.startsWith("-") && !value.startsWith("-") => + result = result :+ SettingSource(name, value, "") + remaining = rest + case name :: rest if name.startsWith("-") => + result = result :+ SettingSource(name, "", "") + remaining = rest + case _ :: rest => + remaining = rest + case Nil => + remaining = Nil + } + } + result + } + + /** + * Convert a flat list of CLI arg tokens into a map of option-name -> values for use with Options.validate. + */ + private[cli] def argsToOptionMap(args: List[String]): Predef.Map[String, List[String]] = { + @scala.annotation.tailrec + def loop( + remaining: List[String], + acc: Predef.Map[String, List[String]] + ): Predef.Map[String, List[String]] = + remaining match { + case Nil => acc + case head :: tail if head.startsWith("-") => + if (head.contains("=")) { + val splitAt = head.indexOf('=') + val key = head.substring(0, splitAt) + val value = head.substring(splitAt + 1) + loop(tail, acc.updated(key, acc.getOrElse(key, Nil) :+ value)) + } else if (tail.nonEmpty && !tail.head.startsWith("-")) { + loop(tail.tail, acc.updated(head, acc.getOrElse(head, Nil) :+ tail.head)) + } else { + loop(tail, acc.updated(head, Nil)) + } + case _ :: tail => + loop(tail, acc) + } + + loop(args, Predef.Map.empty) + } + + /** + * Format diagnostics about where settings came from. + */ + def formatDiagnostics(sources: List[SettingSource]): String = + if (sources.isEmpty) "No settings loaded." + else { + val maxNameLen = sources.map(_.name.length).max + sources.map { s => + val valueStr = if (s.value.nonEmpty) s" = ${s.value}" else "" + val padding = " " * (maxNameLen - s.name.length) + s" ${s.name}$padding$valueStr (from ${s.source})" + } + .mkString("Settings:\n", "\n", "") + } +} diff --git a/zio-cli/shared/src/test/scala/zio/cli/ConfigFilesSpec.scala b/zio-cli/shared/src/test/scala/zio/cli/ConfigFilesSpec.scala new file mode 100644 index 00000000..e8e46445 --- /dev/null +++ b/zio-cli/shared/src/test/scala/zio/cli/ConfigFilesSpec.scala @@ -0,0 +1,218 @@ +package zio.cli + +import zio._ +import zio.test._ +object ConfigFilesSpec extends ZIOSpecDefault { + + def spec = suite("ConfigFiles Suite")( + suite("parseDotFile")( + test("parse simple options") { + val (args, sources) = ConfigFileResolver.parseDotFile("--verbose\n--output result.txt\n--count 42", "/f") + assertTrue( + args == List("--verbose", "--output", "result.txt", "--count", "42"), + sources.length == 3 + ) + }, + test("parse key=value format") { + val (args, _) = ConfigFileResolver.parseDotFile("--output=result.txt\n--count=42", "/f") + assertTrue(args == List("--output=result.txt", "--count=42")) + }, + test("skip empty lines and comments") { + val content = + """# This is a comment + |--verbose + | + |# Another comment + |--output result.txt + |""".stripMargin + val (args, _) = ConfigFileResolver.parseDotFile(content, "/f") + assertTrue(args == List("--verbose", "--output", "result.txt")) + }, + test("parse short options") { + val (args, _) = ConfigFileResolver.parseDotFile("-v\n-o result.txt", "/f") + assertTrue(args == List("-v", "-o", "result.txt")) + }, + test("handle empty content") { + val (args, sources) = ConfigFileResolver.parseDotFile("", "/f") + assertTrue(args.isEmpty, sources.isEmpty) + }, + test("handle quoted values") { + val (args, _) = ConfigFileResolver.parseDotFile("--name \"John Doe\"", "/f") + assertTrue(args == List("--name", "John Doe")) + } + ), + suite("tokenizeLine")( + test("simple tokens") { + assertTrue(ConfigFileResolver.tokenizeLine("--name value") == List("--name", "value")) + }, + test("double-quoted value") { + assertTrue(ConfigFileResolver.tokenizeLine("--name \"hello world\"") == List("--name", "hello world")) + }, + test("single-quoted value") { + assertTrue(ConfigFileResolver.tokenizeLine("--name 'hello world'") == List("--name", "hello world")) + }, + test("flag only") { + assertTrue(ConfigFileResolver.tokenizeLine("--verbose") == List("--verbose")) + }, + test("key=value") { + assertTrue(ConfigFileResolver.tokenizeLine("--key=value") == List("--key=value")) + } + ), + suite("mergeArgs")( + test("CLI args override dotfile args") { + val dotfileArgs = List("--name", "from-dotfile", "--verbose") + val dotfileSources = List(SettingSource("--name", "from-dotfile", "/f"), SettingSource("--verbose", "", "/f")) + val cliArgs = List("--name", "from-cli") + + val (merged, sources) = ConfigFileResolver.mergeArgs(dotfileArgs, dotfileSources, cliArgs) + assertTrue( + merged == List("--verbose", "--name", "from-cli"), + sources.exists(s => s.name == "--name" && s.source == ""), + sources.exists(s => s.name == "--verbose" && s.source == "/f") + ) + }, + test("no dotfile args") { + val (merged, sources) = + ConfigFileResolver.mergeArgs(Nil, Nil, List("--name", "cli-value")) + assertTrue( + merged == List("--name", "cli-value"), + sources.length == 1, + sources.head.source == "" + ) + }, + test("no CLI args - dotfile args pass through") { + val dotfileArgs = List("--name", "from-dotfile") + val dotfileSources = List(SettingSource("--name", "from-dotfile", "/f")) + + val (merged, sources) = ConfigFileResolver.mergeArgs(dotfileArgs, dotfileSources, Nil) + assertTrue( + merged == List("--name", "from-dotfile"), + sources.length == 1, + sources.head.source == "/f" + ) + }, + test("partial override: some from CLI, some from dotfile") { + val dotfileArgs = List("--name", "dot-name", "--output", "dot-out") + val dotfileSources = List( + SettingSource("--name", "dot-name", "/f"), + SettingSource("--output", "dot-out", "/f") + ) + val cliArgs = List("--name", "cli-name") + + val (merged, sources) = ConfigFileResolver.mergeArgs(dotfileArgs, dotfileSources, cliArgs) + assertTrue( + merged.contains("--output"), + merged.contains("dot-out"), + sources.exists(s => s.name == "--name" && s.source == ""), + sources.exists(s => s.name == "--output" && s.source == "/f") + ) + } + ), + suite("argsToOptionMap")( + test("parse flag without value") { + val result = ConfigFileResolver.argsToOptionMap(List("--verbose")) + assertTrue(result == Map("--verbose" -> Nil)) + }, + test("parse option with value") { + val result = ConfigFileResolver.argsToOptionMap(List("--output", "result.txt")) + assertTrue(result == Map("--output" -> List("result.txt"))) + }, + test("parse multiple options") { + val result = ConfigFileResolver.argsToOptionMap(List("--verbose", "--output", "result.txt")) + assertTrue( + result == Map("--verbose" -> Nil, "--output" -> List("result.txt")) + ) + }, + test("handle empty list") { + assertTrue(ConfigFileResolver.argsToOptionMap(Nil) == Map.empty[String, List[String]]) + } + ), + suite("formatDiagnostics")( + test("formats sources correctly") { + val sources = List( + SettingSource("--name", "value", "/home/.myapp"), + SettingSource("--verbose", "", "") + ) + val result = ConfigFileResolver.formatDiagnostics(sources) + assertTrue( + result.contains("--name"), + result.contains("/home/.myapp"), + result.contains("--verbose"), + result.contains("") + ) + }, + test("empty sources") { + assertTrue(ConfigFileResolver.formatDiagnostics(Nil) == "No settings loaded.") + } + ), + suite("end-to-end integration with Options and Command")( + test("dotfile args merged into command parsing") { + val command = Command("test", Options.text("name"), Args.none) + val resolver = new ConfigFileResolver { + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.succeed( + (List("--name", "from-dotfile"), List(SettingSource("--name", "from-dotfile", "/test/.testrc"))) + ) + } + + val app = CliApp.make[Any, Nothing, String, String]( + name = "test", + version = "1.0", + summary = HelpDoc.Span.text("test app"), + command = command, + configFileResolver = resolver + ) { name => + ZIO.succeed(name) + } + + for { + // Without CLI override - uses dotfile value + r1 <- app.run(Nil) + // With CLI override + r2 <- app.run(List("--name", "from-cli")) + } yield assertTrue( + r1 == Some("from-dotfile"), + r2 == Some("from-cli") + ) + }, + test("boolean flag from dotfile") { + val command = Command("test", Options.boolean("verbose", true) ++ Options.text("name"), Args.none) + val resolver = new ConfigFileResolver { + def resolve(appName: String): UIO[(List[String], List[SettingSource])] = + ZIO.succeed( + (List("--verbose"), List(SettingSource("--verbose", "", "/test/.testrc"))) + ) + } + + val app = CliApp.make[Any, Nothing, (Boolean, String), (Boolean, String)]( + name = "test", + version = "1.0", + summary = HelpDoc.Span.text("test"), + command = command, + configFileResolver = resolver + ) { case (verbose, name) => + ZIO.succeed((verbose, name)) + } + + app.run(List("--name", "hello")).map { result => + assertTrue(result == Some((true, "hello"))) + } + }, + test("no resolver returns defaults") { + val command = Command("test", Options.text("name").withDefault("default-val"), Args.none) + + val app = CliApp.make[Any, Nothing, String, String]( + name = "test", + version = "1.0", + summary = HelpDoc.Span.text("test"), + command = command, + configFileResolver = ConfigFileResolver.none + ) { name => + ZIO.succeed(name) + } + + app.run(Nil).map(r => assertTrue(r == Some("default-val"))) + } + ) + ) +}