Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added demo-videos/pr-558-demo-fullscreen-terminal.mp4
Binary file not shown.
31 changes: 31 additions & 0 deletions docs/config-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
id: config-files
title: "Configuration Files"
---

ZIO CLI can load default option values from dotfiles named `.<app-name>`.

For an app named `wc`, ZIO CLI will look for `.wc` in:

1. The user's home directory.
2. The current directory's parent chain (from root down to the current working directory).

Priority is:

1. Command-line arguments (highest)
2. Current working directory dotfile
3. Parent directory dotfiles
4. Home directory dotfile (lowest)

If the same option appears in multiple files, the highest-priority source wins.
If the same option appears on the CLI, the CLI value wins.

Supported line formats in config files:

- `--key=value`
- `--key value`
- `--flag`

Blank lines and lines starting with `#` are ignored.

When configuration is applied, ZIO CLI prints the resolved values and their source files.
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const sidebars = {
"helpdoc",
"built-in-commands",
"cli-config",
"config-files",
"auth",
"bash-and-zsh-completion",
"sbt-plugin",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package zio.cli.config

import zio._

private[cli] trait ConfigFileResolverPlatformSpecific {
def resolveAndParse(commandName: String): Task[List[ConfigOption]] = {
val _ = commandName
ZIO.succeed(Nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package zio.cli.config

import zio._

import java.io.File
import scala.annotation.tailrec
import scala.io.Source

private[cli] trait ConfigFileResolverPlatformSpecific {

def resolveAndParse(commandName: String): Task[List[ConfigOption]] =
ZIO.attempt {
val fileName = s".$commandName"
val homeFile = Option(java.lang.System.getProperty("user.home")).map(home => new File(home, fileName)).toList
val cwd = new File(Option(java.lang.System.getProperty("user.dir")).getOrElse(".")).getAbsoluteFile

val candidates = distinctByPath(homeFile ++ directoriesFromRootTo(cwd).map(new File(_, fileName)))
.filter(file => file.exists() && file.isFile)

candidates.zipWithIndex.flatMap { case (file, priority) =>
val source = Source.fromFile(file, "UTF-8")
val lines =
try source.getLines().toList
finally source.close()
ConfigParser.parseLines(lines, file.getAbsolutePath, priority)
}
}

private def distinctByPath(files: List[File]): List[File] = {
val seen = scala.collection.mutable.HashSet.empty[String]
files.filter { file =>
val path = file.getAbsolutePath
if (seen.contains(path)) false
else {
seen += path
true
}
}
}

private def directoriesFromRootTo(cwd: File): List[File] = {
@tailrec
def loop(current: File, acc: List[File]): List[File] =
if (current == null) acc
else loop(current.getParentFile, current :: acc)

loop(cwd, Nil)
}
}
104 changes: 104 additions & 0 deletions zio-cli/jvm/src/test/scala/zio/cli/config/ConfigFileResolverSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package zio.cli.config

import zio._
import zio.cli._
import zio.test.Assertion._
import zio.test._

import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Path}

object ConfigFileResolverSpec extends ZIOSpecDefault {

private def writeFile(path: Path, lines: List[String]): Task[Unit] =
ZIO.attempt {
Files.createDirectories(path.getParent)
Files.write(path, lines.mkString("\n").getBytes(StandardCharsets.UTF_8))
()
}

override def spec: Spec[TestEnvironment with Scope, Any] =
suite("ConfigFileResolverSpec")(
test("resolve from home + parent chain, with CLI args taking highest priority") {
val commandName = "dotcfg_test_app"

for {
base <- ZIO.attempt(Files.createTempDirectory("zio-cli-config-test"))
home <- ZIO.attempt(Files.createDirectory(base.resolve("home")))
root <- ZIO.attempt(Files.createDirectory(base.resolve("workspace")))
parent <- ZIO.attempt(Files.createDirectory(root.resolve("parent")))
cwd <- ZIO.attempt(Files.createDirectory(parent.resolve("cwd")))

_ <- writeFile(home.resolve(s".$commandName"), List("--line-ending=LF", "--color=blue"))
_ <- writeFile(root.resolve(s".$commandName"), List("--line-ending=CRLF"))
_ <- writeFile(parent.resolve(s".$commandName"), List("--verbosity=debug"))
_ <- writeFile(cwd.resolve(s".$commandName"), List("--line-ending=POSIX"))

oldHome <- ZIO.attempt(Option(java.lang.System.getProperty("user.home")))
oldDir <- ZIO.attempt(Option(java.lang.System.getProperty("user.dir")))

result <- (
for {
_ <- ZIO.attempt(java.lang.System.setProperty("user.home", home.toString))
_ <- ZIO.attempt(java.lang.System.setProperty("user.dir", cwd.toString))
parsed <- ConfigFileResolver.resolveAndParse(commandName)
merged = ConfigMerger.merge(parsed, List("--line-ending", "CLI"))
} yield (parsed, merged)
).ensuring(
ZIO.attempt {
oldHome match {
case Some(value) => java.lang.System.setProperty("user.home", value)
case None => java.lang.System.clearProperty("user.home")
}
oldDir match {
case Some(value) => java.lang.System.setProperty("user.dir", value)
case None => java.lang.System.clearProperty("user.dir")
}
}.orDie
)

(parsed, merged) = result
} yield assert(parsed.map(_.key).toSet)(equalTo(Set("--line-ending", "--color", "--verbosity"))) &&
assert(merged.toSet)(equalTo(Set("--color=blue", "--verbosity=debug", "--line-ending", "CLI")))
},
test("CliApp consumes config values when option is not passed on CLI") {
val appName = "configdriven"

val command = Command("configdriven", Options.text("line-ending"))

val app = CliApp.make(
name = appName,
version = "0.0.1",
summary = HelpDoc.Span.text("config test"),
command = command
)(lineEnding => ZIO.succeed(lineEnding))

for {
base <- ZIO.attempt(Files.createTempDirectory("zio-cli-config-app"))
_ <- writeFile(base.resolve(s".$appName"), List("--line-ending=LF"))

oldHome <- ZIO.attempt(Option(java.lang.System.getProperty("user.home")))
oldDir <- ZIO.attempt(Option(java.lang.System.getProperty("user.dir")))

result <- (
for {
_ <- ZIO.attempt(java.lang.System.setProperty("user.home", base.toString))
_ <- ZIO.attempt(java.lang.System.setProperty("user.dir", base.toString))
output <- app.run(Nil)
} yield output
).ensuring(
ZIO.attempt {
oldHome match {
case Some(value) => java.lang.System.setProperty("user.home", value)
case None => java.lang.System.clearProperty("user.home")
}
oldDir match {
case Some(value) => java.lang.System.setProperty("user.dir", value)
case None => java.lang.System.clearProperty("user.dir")
}
}.orDie
)
} yield assert(result)(isSome(equalTo("LF")))
}
) @@ TestAspect.sequential
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package zio.cli.config

import zio._

import java.io.File
import scala.annotation.tailrec
import scala.io.Source

private[cli] trait ConfigFileResolverPlatformSpecific {

def resolveAndParse(commandName: String): Task[List[ConfigOption]] =
ZIO.attempt {
val fileName = s".$commandName"
val homeFile = Option(java.lang.System.getProperty("user.home")).map(home => new File(home, fileName)).toList
val cwd = new File(Option(java.lang.System.getProperty("user.dir")).getOrElse(".")).getAbsoluteFile

val candidates = distinctByPath(homeFile ++ directoriesFromRootTo(cwd).map(new File(_, fileName)))
.filter(file => file.exists() && file.isFile)

candidates.zipWithIndex.flatMap { case (file, priority) =>
val source = Source.fromFile(file, "UTF-8")
val lines =
try source.getLines().toList
finally source.close()
ConfigParser.parseLines(lines, file.getAbsolutePath, priority)
}
}

private def distinctByPath(files: List[File]): List[File] = {
val seen = scala.collection.mutable.HashSet.empty[String]
files.filter { file =>
val path = file.getAbsolutePath
if (seen.contains(path)) false
else {
seen += path
true
}
}
}

private def directoriesFromRootTo(cwd: File): List[File] = {
@tailrec
def loop(current: File, acc: List[File]): List[File] =
if (current == null) acc
else loop(current.getParentFile, current :: acc)

loop(cwd, Nil)
}
}
57 changes: 44 additions & 13 deletions zio-cli/shared/src/main/scala/zio/cli/CliApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import zio.cli.BuiltInOption._
import zio.cli.HelpDoc.Span.{code, text}
import zio.cli.HelpDoc.{h1, p}
import zio.cli.completion.{Completion, CompletionScript}
import zio.cli.config.{ConfigDiagnostics, ConfigFileResolver, ConfigMerger}
import zio.cli.figlet.FigFont

import scala.annotation.tailrec
Expand Down Expand Up @@ -66,6 +67,12 @@ object CliApp {
private def printDocs(helpDoc: HelpDoc): UIO[Unit] =
printLine(helpDoc.toPlaintext(80)).!

private def usesBuiltInOnly(args: List[String]): Boolean =
args.exists {
case "-h" | "--help" | "--wizard" | "--compgen" | "--generate-completion" => true
case _ => false
}

def run(args: List[String]): ZIO[R, CliError[E], Option[A]] = {
def executeBuiltIn(builtInOption: BuiltInOption): ZIO[R, CliError[E], Option[A]] =
builtInOption match {
Expand All @@ -80,9 +87,18 @@ object CliApp {
.map(HelpDoc.p)
.foldRight(HelpDoc.empty)(_ + _)

val configHelpDoc =
h1("configuration") +
p(
s"Defaults may be loaded from .$name files found in the home directory and in the current working directory hierarchy."
) +
p("Priority is: CLI arguments > current directory > parent directories > home directory.") +
p("Applied configuration values are printed with their source file.")

// TODO add rendering of built-in options such as help
printLine(
(fancyName + header + synopsisHelpDoc + helpDoc + self.footer).toPlaintext(columnWidth = 300)
(fancyName + header + synopsisHelpDoc + helpDoc + configHelpDoc + self.footer)
.toPlaintext(columnWidth = 300)
).mapBoth(CliError.IO(_), _ => None)
case ShowCompletionScript(path, shellType) =>
printLine(
Expand Down Expand Up @@ -125,19 +141,34 @@ 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)
val effectiveArgs: UIO[List[String]] =
if (usesBuiltInOnly(args)) ZIO.succeed(args)
else
ConfigFileResolver
.resolveAndParse(self.name)
.foldZIO(
_ => ZIO.succeed(args),
options => {
val (mergedArgs, diagnostics) = ConfigMerger.mergeWithDiagnostics(options, args)
ConfigDiagnostics.printDiagnostics(diagnostics) *> ZIO.succeed(mergedArgs)
}
}
)
)

effectiveArgs.flatMap { finalArgs =>
self.command
.parse(prefix(self.command) ++ finalArgs, 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)
}
}
)
}
}

override def flatMap[R1 <: R, E1 >: E, B](f: A => ZIO[R1, E1, B]): CliApp[R1, E1, B] =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package zio.cli.config

import zio._

final case class ConfigOption(
key: String,
arguments: List[String],
source: String,
priority: Int
)

final case class ConfigConflict(
key: String,
overridden: List[ConfigOption],
winner: Option[ConfigOption],
cliOverride: Boolean
)

final case class ConfigDiagnostics(
resolvedOptions: List[ConfigOption],
conflicts: List[ConfigConflict],
cliOverrides: List[String]
) {
def nonEmpty: Boolean = resolvedOptions.nonEmpty || conflicts.nonEmpty || cliOverrides.nonEmpty
}

object ConfigDiagnostics {

def printDiagnostics(diagnostics: ConfigDiagnostics): UIO[Unit] = {
val printResolved = ZIO.foreachDiscard(diagnostics.resolvedOptions) { option =>
val rendered = option.arguments.mkString(" ")
Console.printLine(s"config: $rendered (from ${option.source})").ignore
}

val printOverrides = ZIO.foreachDiscard(diagnostics.cliOverrides.distinct.sorted) { key =>
Console.printLine(s"config: CLI overrides $key").ignore
}

val printConflicts = ZIO.foreachDiscard(diagnostics.conflicts.filterNot(_.cliOverride)) { conflict =>
val winnerSource = conflict.winner.map(_.source).getOrElse("unknown")
Console.printLine(s"config: resolved ${conflict.key} from $winnerSource").ignore
}

(printResolved *> printOverrides *> printConflicts).when(diagnostics.nonEmpty).unit
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package zio.cli.config

object ConfigFileResolver extends ConfigFileResolverPlatformSpecific
Loading