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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package zio.cli

private[cli] trait ConfigFileResolverPlatformSpecific {

lazy val live: ConfigFileResolver = ConfigFileResolver.none
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
128 changes: 128 additions & 0 deletions zio-cli/jvm/src/test/scala/zio/cli/ConfigFileResolverSpec.scala
Original file line number Diff line number Diff line change
@@ -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")
)
}
)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
63 changes: 46 additions & 17 deletions zio-cli/shared/src/main/scala/zio/cli/CliApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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,
Expand All @@ -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] =
Expand All @@ -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) =>
Expand Down Expand Up @@ -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] =
Expand All @@ -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)
Expand Down
Loading