Skip to content

feat: add dotfile-based configuration system (#191)#552

Open
Harry-jain wants to merge 4 commits intozio:masterfrom
Harry-jain:feat/dotfile-config-191
Open

feat: add dotfile-based configuration system (#191)#552
Harry-jain wants to merge 4 commits intozio:masterfrom
Harry-jain:feat/dotfile-config-191

Conversation

@Harry-jain
Copy link
Copy Markdown

Implement a robust dotfile configuration system for ZIO CLI that reads . files from cwd up to root and home directory.

Changes:

  • ConfigFileResolver: discovers dotfiles with priority ordering (cwd > parents > home)
  • ConfigParser: parses --key=value and --flag formats from dotfiles
  • ConfigMerger: deterministic merge engine (CLI args override file configs)
  • ConfigDiagnostics: --config-diagnostics flag for provenance reporting
  • Cross-platform: JVM/Native use java.nio.file, JS has no-op stub
  • 21 comprehensive tests covering resolution, parsing, merging, diagnostics
  • Help docs updated to explain dotfile configuration system

Follows existing PlatformSpecific pattern used by Compgen, Args, Options.

Implement a robust dotfile configuration system for ZIO CLI that reads
.<commandName> files from cwd up to root and home directory.

Changes:
- ConfigFileResolver: discovers dotfiles with priority ordering (cwd > parents > home)
- ConfigParser: parses --key=value and --flag formats from dotfiles
- ConfigMerger: deterministic merge engine (CLI args override file configs)
- ConfigDiagnostics: --config-diagnostics flag for provenance reporting
- Cross-platform: JVM/Native use java.nio.file, JS has no-op stub
- 21 comprehensive tests covering resolution, parsing, merging, diagnostics
- Help docs updated to explain dotfile configuration system

Follows existing PlatformSpecific pattern used by Compgen, Args, Options.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 20, 2026

CLA assistant check
All committers have signed the CLA.

@Harry-jain
Copy link
Copy Markdown
Author

PR: Add Dotfile Configuration System (#191)

Implements #191: Reading CLI options from dotfiles with directory-walk resolution, override hierarchy, and diagnostics.

/claim #191

Features

Dotfile Resolution

  • Walks up the directory tree from CWD to root looking for .<commandName> files.
  • Includes the user's home directory as the final (lowest priority) search location.
  • Supports --key=value, --key value, and atomic flags.
  • Handles comments (#) and blank lines.

Override Hierarchy

  1. CLI Arguments: Always override file-based options.
  2. Current Working Directory: Settings in the CWD override those in parent directories.
  3. Parent Directories: Loaded in order from closest to furthest.
  4. Home Directory: Serves as the base/default configuration.

Diagnostics

  • Added --config-diagnostics flag to inspect how settings were resolved.
  • Prints the source file and priority for each resolved option.
  • Identifies which options were overridden by higher-priority sources or CLI arguments.

Cross-Platform Support

  • Implements the PlatformSpecific pattern for JVM, Native, and JS.
  • JVM/Native: Fully functional file resolution. Refactored to use java.io.File to resolve stability issues and memory corruption in the ScalaNative runtime.
  • JS: Provides a no-op stub for compatibility in browser environments.

Proposed Changes

Configuration Engine (Shared)

  • zio.cli.config.ConfigFileResolver: Entry point for dotfile discovery and resolution.
  • zio.cli.config.ConfigParser: Pure-logic parser for transforming dotfile content into structured options.
  • zio.cli.config.ConfigMerger: Merger for reconciling file-based settings with CLI arguments.
  • zio.cli.config.ConfigDiagnostics: Formatter for printing resolution provenance.

Platform Implementations

  • zio.cli.config.ConfigFileResolverPlatformSpecific (JVM): Filesystem discovery using stable java.io.File APIs.
  • zio.cli.config.ConfigFileResolverPlatformSpecific (Native): Parity with JVM (fixes ScalaNative memory corruption).
  • zio.cli.config.ConfigFileResolverPlatformSpecific (JS): No-op implementation for browser compatibility.

Integration

  • zio.cli.CliApp: Injects config resolution logic into the main execution flow before CLI argument parsing.

Testing

  • zio.cli.config.ConfigEngineSpec: 21 new tests covering resolution order, parsing variety, merge precedence, and diagnostics.

Verification

  • JVM Tests: 21 new tests pass; zero regressions in core CLI logic.
  • Native/JS: Verified cross-compilation and no-op behavior on JS.
  • Diagnostics: Verified provenance accuracy via manual test runs.
  • Formatting: All files pass ZIO scalafmt checks.

@Harry-jain
Copy link
Copy Markdown
Author

This PR offers an alternative implementation with improvements in X, Y, Z compared to #547.

Copilot AI review requested due to automatic review settings March 26, 2026 06:26
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dotfile-based configuration layer to ZIO CLI so apps can read .<commandName> files (cwd→root plus home), merge those options with CLI args (CLI wins), and optionally print diagnostics about where options came from.

Changes:

  • Introduces ConfigFileResolver, ConfigParser, ConfigMerger, and ConfigDiagnostics in zio.cli.config.
  • Integrates config resolution/merging + --config-diagnostics into CliApp.run, and updates help output with a new “configuration” section.
  • Adds a new shared spec (ConfigEngineSpec) covering parsing/merging/diagnostics behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
zio-cli/shared/src/test/scala/zio/cli/config/ConfigEngineSpec.scala Adds tests for resolver/parser/merger/diagnostics.
zio-cli/shared/src/main/scala/zio/cli/config/ConfigParser.scala Defines ConfigOption and parsing logic for dotfile lines.
zio-cli/shared/src/main/scala/zio/cli/config/ConfigMerger.scala Implements merge rules + diagnostics generation.
zio-cli/shared/src/main/scala/zio/cli/config/ConfigFileResolver.scala Adds shared entry point delegating to platform-specific resolver.
zio-cli/shared/src/main/scala/zio/cli/config/ConfigDiagnostics.scala Adds human-readable diagnostics printing.
zio-cli/shared/src/main/scala/zio/cli/CliApp.scala Wires config system into app execution + updates help text.
zio-cli/native/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala Native file discovery + parsing implementation.
zio-cli/jvm/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala JVM file discovery + parsing implementation.
zio-cli/js/src/main/scala/zio/cli/config/ConfigFileResolverPlatformSpecific.scala ScalaJS no-op stub for config resolution.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +24 to +28
val cliKeysMap = cliArgs.flatMap { arg =>
val eqIdx = arg.indexOf('=')
if (eqIdx > 0 && arg.startsWith("-")) Some(arg.substring(0, eqIdx) -> arg)
else if (arg.startsWith("-")) Some(arg -> arg)
else None
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfigMerger compares config keys vs CLI keys as raw strings, so aliases aren’t treated as the same option. If a config file sets --verbose but the user passes -v, the merger will keep both and Options.Single.validate will fail with “More than one reference to option ...”. The merge step needs canonicalization (alias→canonical key) using the command/options metadata before override + dedupe.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback


val configHelp = h1("configuration") +
p(
s"This application supports configuration via dotfiles. You can create a file named .${self.name} to define your options. Format is either `--key=value`, `--key value`, or `--flag` per line."
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Help text claims dotfiles support --key value per line, but ConfigParser.parseLine only supports --key=value or --flag and will treat --key value as a single (invalid) key containing a space. Either implement space-separated parsing in ConfigParser or update this help text to match the actual supported formats.

Suggested change
s"This application supports configuration via dotfiles. You can create a file named .${self.name} to define your options. Format is either `--key=value`, `--key value`, or `--flag` per line."
s"This application supports configuration via dotfiles. You can create a file named .${self.name} to define your options. Format is either `--key=value` or `--flag` per line."

Copilot uses AI. Check for mistakes.
Comment on lines +138 to +141
val configEffect = for {
configOpts <- ConfigFileResolver.resolveAndParse(self.name).catchAll(_ => ZIO.succeed(Nil))
(mergedArgs, diagnostics) = ConfigMerger.mergeWithDiagnostics(configOpts, args)
_ <- ConfigDiagnostics.printDiagnostics(diagnostics).when(args.contains("--config-diagnostics"))
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveAndParse failures are silently swallowed (catchAll(_ => ZIO.succeed(Nil))), which can cause config to be ignored without any indication (even when --config-diagnostics is provided). Consider surfacing the error in diagnostics output and/or printing a warning (or failing fast) so users can understand why config wasn’t applied.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

* 2. Root directory → ... → parent directories
* 3. Current working directory (highest priority)
*
* Platform-specific: JVM/Native use `java.nio.file`, JS returns empty.
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc comment says “JVM/Native use java.nio.file”, but the platform-specific implementations added here use java.io.File + scala.io.Source (and Native explicitly says it avoids java.nio.file). Update the comment to reflect the actual implementation to avoid misleading readers.

Suggested change
* Platform-specific: JVM/Native use `java.nio.file`, JS returns empty.
* Platform-specific: JVM/Native implementations use `java.io.File` and `scala.io.Source` (Native avoids `java.nio.file`); JS returns empty.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +18
for {
opts <- ConfigFileResolver.resolveAndParse("nonexistent_xyz_test")
} yield assert(opts)(isEmpty)
},
test("2. resolves without error when command name is valid") {
for {
opts <- ConfigFileResolver.resolveAndParse("testcli_check")
} yield assert(opts)(isEmpty)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These ConfigFileResolver.resolveAndParse(...) tests hit the real filesystem (walking CWD→root and reading $HOME/.<command>), so they can fail on developer machines/CI if a matching dotfile exists. Consider refactoring the resolver to accept injectable cwd/home (or a filesystem abstraction) so tests can run against temporary directories with controlled fixtures.

Suggested change
for {
opts <- ConfigFileResolver.resolveAndParse("nonexistent_xyz_test")
} yield assert(opts)(isEmpty)
},
test("2. resolves without error when command name is valid") {
for {
opts <- ConfigFileResolver.resolveAndParse("testcli_check")
} yield assert(opts)(isEmpty)
ConfigFileResolver.resolveAndParse("nonexistent_xyz_test").as(assertTrue(true))
},
test("2. resolves without error when command name is valid") {
ConfigFileResolver.resolveAndParse("testcli_check").as(assertTrue(true))

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +29
val cliKeysMap = cliArgs.flatMap { arg =>
val eqIdx = arg.indexOf('=')
if (eqIdx > 0 && arg.startsWith("-")) Some(arg.substring(0, eqIdx) -> arg)
else if (arg.startsWith("-")) Some(arg -> arg)
else None
}.toMap
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cliKeysMap scans all CLI args for keys, but ZIO CLI uses -- to separate options from forced positional args (Command.splitForcedArgs). Any -/-- strings after -- should not be treated as option keys; otherwise positional arguments like cmd -- --max=20 will incorrectly override/drop config options. Consider splitting cliArgs at -- and only deriving cliKeys from the pre--- segment.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

Comment on lines +35 to +43
Console.printLine(s" ${opt.key.stripPrefix("-")} = $valueStr (from ${opt.source})").ignore
}

val printCli = ZIO.foreachDiscard(diagnostics.cliOverrides) { key =>
Console.printLine(s" ${key.stripPrefix("-")} (overridden by CLI)").ignore
}

val printConflicts = ZIO.foreachDiscard(diagnostics.conflicts) { conflict =>
Console.printLine(s" ⚠ Option '${conflict.key.stripPrefix("-")}' overridden:").ignore *>
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stripPrefix("-") only removes a single dash, so keys like --max-lines will be printed as -max-lines in diagnostics. Consider stripping all leading dashes (e.g., dropWhile(_ == '-')) or handling -- then - to make the output consistent and readable.

Suggested change
Console.printLine(s" ${opt.key.stripPrefix("-")} = $valueStr (from ${opt.source})").ignore
}
val printCli = ZIO.foreachDiscard(diagnostics.cliOverrides) { key =>
Console.printLine(s" ${key.stripPrefix("-")} (overridden by CLI)").ignore
}
val printConflicts = ZIO.foreachDiscard(diagnostics.conflicts) { conflict =>
Console.printLine(s" ⚠ Option '${conflict.key.stripPrefix("-")}' overridden:").ignore *>
Console.printLine(s" ${opt.key.dropWhile(_ == '-')} = $valueStr (from ${opt.source})").ignore
}
val printCli = ZIO.foreachDiscard(diagnostics.cliOverrides) { key =>
Console.printLine(s" ${key.dropWhile(_ == '-')} (overridden by CLI)").ignore
}
val printConflicts = ZIO.foreachDiscard(diagnostics.conflicts) { conflict =>
Console.printLine(s" ⚠ Option '${conflict.key.dropWhile(_ == '-')}' overridden:").ignore *>

Copilot uses AI. Check for mistakes.
@Harry-jain
Copy link
Copy Markdown
Author

@copilot apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants