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
29 changes: 20 additions & 9 deletions zio-cli/shared/src/main/scala/zio/cli/Command.scala
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,24 @@ object Command {
.builtInOptions(self, self.synopsis, self.helpDoc)
Options
.validate(options, args.tail, conf)
.map(_._3)
.someOrFail(
ValidationError(
ValidationErrorType.NoBuiltInMatch,
HelpDoc.p(s"No built-in option was matched")
)
)
.flatMap { case (_, leftover, value) =>
if (leftover.nonEmpty)
ZIO.fail(
ValidationError(
ValidationErrorType.NoBuiltInMatch,
HelpDoc.p(s"No built-in option was matched")
)
)
else
ZIO
.fromOption(value)
.mapError(_ =>
ValidationError(
ValidationErrorType.NoBuiltInMatch,
HelpDoc.p(s"No built-in option was matched")
)
)
Comment on lines +121 to +137
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

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

In parseBuiltInArgs, the same ValidationError(NoBuiltInMatch, HelpDoc.p("No built-in option was matched")) is constructed in multiple branches. Consider extracting it into a local val and reusing it (and you can also drop the unnecessary s interpolator) to avoid duplication and reduce the chance of the branches drifting over time.

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

Choose a reason for hiding this comment

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

@copilot fix this using a single match, like:

value match {
  case Some(value) if leftover.isEmpty => Exit.succeed(value)
  case _ => ZIO.fail(...)

}
.map(CommandDirective.BuiltIn)
} else
ZIO.fail(
Expand Down Expand Up @@ -293,7 +304,7 @@ object Command {
ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty))
else
child
.parse(args.tail, conf)
.parse(args.tail, conf.copy(finalCheckBuiltIn = false))
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case CommandDirective.BuiltIn(BuiltInOption.ShowHelp(synopsis, helpDoc)) =>
val parentName = names.headOption.getOrElse("")
Expand All @@ -313,7 +324,7 @@ object Command {
ZIO.fail(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty))
else
child
.parse(args.tail, conf)
.parse(args.tail, conf.copy(finalCheckBuiltIn = false))
.collect(ValidationError(ValidationErrorType.InvalidArgument, HelpDoc.empty)) {
case directive @ CommandDirective.BuiltIn(BuiltInOption.ShowWizard(_)) => directive
}
Expand Down
42 changes: 42 additions & 0 deletions zio-cli/shared/src/test/scala/zio/cli/CommandSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,48 @@ object CommandSpec extends ZIOSpecDefault {
}
)
},
suite("root-level --help with subcommands (issue #448)") {
val git =
Command("git", Options.Empty, Args.none).subcommands(
Command("add", Options.Empty, Args.none).subcommands(
Command("subsubCommand", Options.Empty, Args.none)
),
Command("clone", Options.Empty, Args.none)
)

def helpText(result: CommandDirective[Any]): String = result match {
case CommandDirective.BuiltIn(ShowHelp(_, helpDoc)) => helpDoc.toPlaintext()
case _ => ""
}

Vector(
test("--help at root shows parent help, not child help") {
assertZIO(git.parse(List("git", "--help"), CliConfig.default).map(helpText))(
containsString("add") && containsString("clone")
)
},
test("-h at root shows parent help") {
assertZIO(git.parse(List("git", "-h"), CliConfig.default).map(helpText))(
containsString("add") && containsString("clone")
)
},
test("subcommand --help shows subcommand help") {
assertZIO(git.parse(List("git", "add", "--help"), CliConfig.default).map(helpText))(
containsString("subsubCommand")
)
},
test("empty args shows parent help") {
assertZIO(git.parse(List("git"), CliConfig.default).map(helpText))(
containsString("add") && containsString("clone")
)
},
test("--wizard at root shows parent wizard, not child wizard") {
assertZIO(git.parse(List("git", "--wizard"), CliConfig.default).map(directiveType))(
equalTo("wizard")
)
}
)
},
test("cmd opts -- args") {
val command =
Command("cmd", Options.text("something").optional ++ Options.boolean("verbose").alias("v"), Args.text.*)
Expand Down