샘플
args 2.7.0: Build and test a package:args CommandRunner subcommand CLI, telling apart which node reports a UsageException and pinning the option rules that differ from argparse, getopt and clap
검증된 샘플 — pub args 2.7.0: Build and test a package:args CommandRunner subcommand CLI, telling apart which node reports a UsageException and pinning the option…
sha256:d5e47381de03ab961e2135483018d410dd3e1365963e163f552519d3a16f7875
이 네트워크가 제공하는 것은 하나입니다. 빌드되는 샘플. 샌드박스에서 돌리고 서명된 영수증을 보관합니다. 등급을 매기지 않고 무엇도 보증하지 않습니다 — 같은 코드가 당신 환경에서 빌드되는지는 측정한 적이 없습니다.
통과한 계약 영수증을 낸 서로 다른 서명 키의 수입니다. 하나면 작성자 혼자이고, 둘 이상이면 다른 사람도 빌드했다는 뜻입니다. 키는 스스로 만드는 것이고 뒤에 등록된 신원이 없으므로, 세는 것은 사람이 아니라 키입니다.
MIT-0
실행 증거
선언된 환경과 서명된 실행을 분리해 두었습니다. 이 샘플이 무엇을 어디서 실행했는지 그대로 볼 수 있습니다.
- 증거 기준
- 서명된 컨트랙트 통과
- 검증 영수증
- 1
- 빌드한 서명 키
- 1
선언된 환경
dart 3 linux x64 dart 3 dart pub
검증 실행 환경
| 환경 | 컨트랙트 | 단계 | 실행일 |
|---|---|---|---|
| dart 3 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 | PASS | compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS CONTAINER_RUN · pub@1 |
2026-08-14 |
케이스
HOW- 목표
- Build and test a package:args CommandRunner subcommand CLI, telling apart which node reports a UsageException and pinning the option rules that differ from argparse, getopt and clap
- 패키지
- 심벌
-
- CommandRunner
- CommandRunner.run
- Command.run
- Command.argResults
- Command.usageException
- UsageException
- ArgParser.addFlag
- ArgParser.addOption
- ArgParserException
- ArgResults.flag
- ArgResults.option
- ArgResults.rest
- 환경
- dart 3
- 생성일
- 2026-08-14T19:59:28Z
컨트랙트
- assert a subcommand runs and the value its run returns comes back out of CommandRunner<int>.run, so the exit code travels as a return value and nothing is printed
- assert no arguments, --help and <command> --help all complete with null after printing usage, so null is a success value and Command.run was never reached
- assert an unknown command is a UsageException whose usage is the runner's, with Global options and Available commands, whose toString is message and usage joined, and whose message gains an edit-distance suggestion block only within the default limit of 2
- assert a parse error inside a subcommand is the same UsageException type carrying that subcommand's usage instead of the command list, while a stray word before the command name is reported by the top-level parser and carries the runner's usage again
- assert a unique prefix of a long option is simply an unknown option, because long names are resolved by exact name or alias and no switch adds prefix matching, while the single-letter abbr and an exact alias both work and the alias stays out of the usage block
- assert a collapsed run of single letters takes its value on the FIRST letter, so -tprod sets the option and -vtprod and -vt prod both fail because a later letter must be a flag, and that an unknown letter has one message alone and a different one inside a run
- assert mandatory: true is enforced during parsing only when the option also declares a callback, and that without one the parse succeeds and leaves the option out of the results entirely
- assert the callback-less mandatory option therefore fails as an ArgumentError from ArgResults.option after the command body has already started, while the same declaration with a callback fails as a UsageException during parsing before run is reached
- assert the required option that behaves the same either way is a null check plus an explicit usageException call, throwing UsageException with that command's usage
- assert addFlag creates --no-x for free, including for a flag that already defaults to false, that negatable: false turns --no-x into an error naming the spelling that was typed rather than the declared flag, and that the usage block renders the difference as --[no-]x
- assert everything after -- lands in rest unparsed so a flag past it does not take effect, and that only the first -- is consumed
- assert a -- before the command name stops the top-level parser before it ever looks the command up, so the command that was asked for is reported as unknown while being suggested back
파일
- csx.json
- lib/cli.dart
- pubspec.lock
- pubspec.yaml
- test/contract_test.dart
소스
{"case":{"caseId":"case:sha256:609791c8ae4df2adda724814786b13451a7858c92c82404bbc9940459c7e3c6a","constraints":{"runtime":"dart"},"contract":["assert a subcommand runs and the value its run returns comes back out of CommandRunner\u003cint\u003e.run, so the exit code travels as a return value and nothing is printed","assert no arguments, --help and \u003ccommand\u003e --help all complete with null after printing usage, so null is a success value and Command.run was never reached","assert an unknown command is a UsageException whose usage is the runner's, with Global options and Available commands, whose toString is message and usage joined, and whose message gains an edit-distance suggestion block only within the default limit of 2","assert a parse error inside a subcommand is the same UsageException type carrying that subcommand's usage instead of the command list, while a stray word before the command name is reported by the top-level parser and carries the runner's usage again","assert a unique prefix of a long option is simply an unknown option, because long names are resolved by exact name or alias and no switch adds prefix matching, while the single-letter abbr and an exact alias both work and the alias stays out of the usage block","assert a collapsed run of single letters takes its value on the FIRST letter, so -tprod sets the option and -vtprod and -vt prod both fail because a later letter must be a flag, and that an unknown letter has one message alone and a different one inside a run","assert mandatory: true is enforced during parsing only when the option also declares a callback, and that without one the parse succeeds and leaves the option out of the results entirely","assert the callback-less mandatory option therefore fails as an ArgumentError from ArgResults.option after the command body has already started, while the same declaration with a callback fails as a UsageException during parsing before run is reached","assert the required option that behaves the same either way is a null check plus an explicit usageException call, throwing UsageException with that command's usage","assert addFlag creates --no-x for free, including for a flag that already defaults to false, that negatable: false turns --no-x into an error naming the spelling that was typed rather than the declared flag, and that the usage block renders the difference as --[no-]x","assert everything after -- lands in rest unparsed so a flag past it does not take effect, and that only the first -- is consumed","assert a -- before the command name stops the top-level parser before it ever looks the command up, so the command that was asked for is reported as unknown while being suggested back"],"goal":"Build and test a package:args CommandRunner subcommand CLI, telling apart which node reports a UsageException and pinning the option rules that differ from argparse, getopt and clap","kind":"HOW","packages":["pkg:pub/args@2.7.0"],"schemaVersion":1,"symbols":["CommandRunner","CommandRunner.run","Command.run","Command.argResults","Command.usageException","UsageException","ArgParser.addFlag","ArgParser.addOption","ArgParserException","ArgResults.flag","ArgResults.option","ArgResults.rest"]},"contractCommand":["dart","test","--reporter=expanded"],"environment":{"arch":"x64","ecosystem":"pub","executionContext":"dart","language":"dart","os":"linux","packageManager":"pub","runtime":"dart","runtimeVersion":"3","schemaVersion":1},"license":"MIT-0","packages":["pkg:pub/args@2.7.0"],"schemaVersion":1,"symbols":["CommandRunner","CommandRunner.run","Command.run","Command.argResults","Command.usageException","UsageException","ArgParser.addFlag","ArgParser.addOption","ArgParserException","ArgResults.flag","ArgResults.option","ArgResults.rest"],"verifierAdapter":"pub@1"}
import 'dart:async';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
/// Building a subcommand CLI with package:args CommandRunner, and the rules that
/// are not what their names suggest.
///
/// CommandRunner<T> is the Dart counterpart of cobra's command tree and clap's
/// derive API. Its error surface is small and sharp: nearly every user-facing
/// mistake becomes a UsageException carrying a message plus the usage text of
/// whichever node reported it, and UsageException.toString() is already those
/// two joined. Catch that one type at the top of main, print it, exit non-zero.
///
/// What is left is knowing which node reports what, and five measured rules:
///
/// 1. A null return is a success. CommandRunner<T>.run completes with T? because
/// three ordinary paths return null without ever calling your Command.run: no
/// arguments at all, --help, and `<command> --help`. Null cannot be read as
/// "nothing matched". The value a command does return is what run() completes
/// with, which is how the exit code travels without a global.
///
/// 2. There is no long-option abbreviation and no switch that would add one.
/// argparse accepts any unambiguous prefix of a long name unless you pass
/// allow_abbrev=False. package:args never does: _handleLongOption looks the
/// name up with findByNameOrAlias, which is exact-match only. `abbr:` is not
/// that feature either — it is the single-letter form. The exact-name
/// substitute for a prefix is `aliases:`, a second full spelling that is
/// deliberately left out of the usage block.
///
/// 3. What args calls an abbreviation is a collapsed run of those single letters,
/// and the letter that may take a value is the FIRST, not the last. -tprod
/// sets target. -vtprod does not mean -v -t prod the way getopt would: once
/// the first letter is a flag, every remaining letter must also be a flag, so
/// the run fails on -t. Nothing about the message says "put it first".
///
/// 4. `mandatory: true` fires at parse time only if the option also declares a
/// `callback`. The check lives inside Parser.parse's post-parse loop over the
/// options, below an early `if (callback == null) return;`, so a mandatory
/// option with no callback is skipped entirely. Without a callback the miss
/// is caught later, by ArgResults.option, as an ArgumentError — a different
/// type, thrown after your command body has already started running. With a
/// callback it is an ArgParserException during parsing, which CommandRunner
/// converts into the UsageException people expect. Depending on that coupling
/// is not worth it: a null check plus an explicit usageException call says
/// what it means in every version. (The unreleased 2.8.0 changelog entry
/// moves this check into parsing proper; 2.7.0 is the current release on
/// pub.dev and is what is measured here.)
///
/// 5. Every flag is negatable unless you opt out, so addFlag('verify') also
/// creates --no-verify, including for a flag that already defaults to false.
/// negatable: false removes it and gives --no-color its own error, distinct
/// from an unknown option. The usage block renders the difference as
/// --[no-]verify.
///
/// And one thing with no equivalent: cobra's SetOut. CommandRunner prints usage
/// with the top-level `print`, so capturing it means overriding the Zone's print
/// handler — runCaptured below.
/// Whatever a Command.run returned, plus the lines CommandRunner printed.
class Outcome<T> {
Outcome(this.value, this.printed);
final T? value;
final List<String> printed;
}
/// CommandRunner has no output sink to inject, so capturing its usage output
/// means replacing print for the duration of the call.
Future<Outcome<T>> runCaptured<T>(
CommandRunner<T> runner,
List<String> args,
) async {
final printed = <String>[];
final value = await runZoned(
() => runner.run(args),
zoneSpecification: ZoneSpecification(
print: (_, __, ___, line) => printed.add(line),
),
);
return Outcome<T>(value, printed);
}
/// The command under test. Every flag and option here pins one rule.
class DeployCommand extends Command<int> {
DeployCommand(this.calls) {
argParser
// abbr is the single letter, so -t and -tprod work. aliases adds a second
// exact spelling; it is not prefix matching, and it is hidden from usage.
..addOption(
'target',
abbr: 't',
aliases: ['destination'],
help: 'Environment to deploy to.',
)
// Negatable by default, so --no-verify exists without being declared.
..addFlag('verify', defaultsTo: true, help: 'Verify the build first.')
// negatable: false is the opt-out. Measured: the error it produces names
// the spelling that was typed, --no-color, and not this flag, so matching
// on the declared name misses.
..addFlag('color', negatable: false, help: 'Colorize the output.')
// Two single-letter flags, so a collapsed -vq can be measured.
..addFlag('verbose', abbr: 'v', help: 'Log every step.')
..addFlag('quiet', abbr: 'q', help: 'Log nothing.');
}
/// Every ArgResults this command actually ran with. Empty means run() was
/// never reached, which separates --help and parse errors from a command that
/// started and then failed.
final List<ArgResults> calls;
@override
String get name => 'deploy';
@override
String get description => 'Deploy the current build.';
@override
int run() {
final results = argResults!;
calls.add(results);
// This value is what CommandRunner<int>.run() completes with: the exit code
// comes back as a return value rather than through dart:io.
return results.flag('verify') ? 0 : 42;
}
}
/// `mandatory: true` with no callback, which reads like clap's required and is
/// not enforced at parse time at all.
class ReleaseCommand extends Command<int> {
ReleaseCommand(this.calls) {
argParser.addOption('tag', mandatory: true, help: 'Release tag.');
}
final List<ArgResults> calls;
@override
String get name => 'release';
@override
String get description => 'Cut a release.';
@override
int run() {
// Recorded before the option is read, on purpose. With --tag missing this
// line still executes: parsing succeeded and the command started. Only the
// read below fails, and it fails with ArgumentError, which CommandRunner
// does not convert into a UsageException.
calls.add(argResults!);
final tag = argResults!.option('tag');
return tag!.length;
}
}
/// The same declaration plus a callback, which is what actually switches the
/// parse-time check on. The callback body is irrelevant; its existence is not.
class PromoteCommand extends Command<int> {
PromoteCommand(this.calls) {
argParser.addOption(
'tag',
mandatory: true,
callback: (_) {},
help: 'Release tag.',
);
}
final List<ArgResults> calls;
@override
String get name => 'promote';
@override
String get description => 'Promote a release.';
@override
int run() {
calls.add(argResults!);
return argResults!.option('tag')!.length;
}
}
/// Required, written so it does not depend on any of that.
class PublishCommand extends Command<int> {
PublishCommand(this.calls) {
argParser.addOption('tag', help: 'Release tag.');
}
final List<ArgResults> calls;
@override
String get name => 'publish';
@override
String get description => 'Publish a cut release.';
@override
int run() {
final tag = argResults!.option('tag');
// usageException returns Never and throws UsageException carrying this
// command's usage — the behaviour mandatory: true is mistaken for.
if (tag == null) usageException('Option tag is required.');
calls.add(argResults!);
return tag.length;
}
}
/// The whole tree, plus the lists the test reads instead of stdout.
class Cli {
Cli()
: deployCalls = <ArgResults>[],
releaseCalls = <ArgResults>[],
promoteCalls = <ArgResults>[],
publishCalls = <ArgResults>[] {
runner = CommandRunner<int>('csx', 'A deployment tool.')
..addCommand(DeployCommand(deployCalls))
..addCommand(ReleaseCommand(releaseCalls))
..addCommand(PromoteCommand(promoteCalls))
..addCommand(PublishCommand(publishCalls));
}
late final CommandRunner<int> runner;
final List<ArgResults> deployCalls;
final List<ArgResults> releaseCalls;
final List<ArgResults> promoteCalls;
final List<ArgResults> publishCalls;
}
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "9a3386eea899815698dd55995277cf7cb8572ee52b399a6edfb7ae2b50e5fc19"
url: "https://pub.dev"
source: hosted
version: "105.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "62993bed6eadbe9596c5c20d5c167e7bc563c5fe266657a04ddeb93bdb84f4c9"
url: "https://pub.dev"
source: hosted
version: "14.1.0"
args:
dependency: "direct main"
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d"
url: "https://pub.dev"
source: hosted
version: "1.15.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: "direct dev"
description:
name: test
sha256: "0d5ba5602ec3baa28c8ce365e1efc5575969c765f45c554a3e167dc7945b9c30"
url: "https://pub.dev"
source: hosted
version: "1.31.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "475610b2aa23c19687cce2961e44b0cc57cafe220f67c2b80201231b2a07fbe7"
url: "https://pub.dev"
source: hosted
version: "0.7.13"
test_core:
dependency: transitive
description:
name: test_core
sha256: a39c204a4fc7a7ccb04a2b985e359fda3cc37e45e0b8ac61c3fb1a05aa832132
url: "https://pub.dev"
source: hosted
version: "0.6.19"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
name: csx_args_runner
description: A package:args CommandRunner CLI, its two UsageException sources, and the option rules that are not what their names say.
publish_to: none
environment:
sdk: ^3.5.0
dependencies:
args: 2.7.0
dev_dependencies:
test: 1.31.2
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:csx_args_runner/cli.dart';
import 'package:test/test.dart';
/// Returns whatever the call threw, so the exception can be inspected and not
/// merely matched. CommandRunner.run wraps everything in a Future, parse errors
/// included, so every failure here is an awaited one.
Future<Object> errorFrom(Future<void> Function() body) async {
try {
await body();
} catch (error) {
return error;
}
fail('expected the call to throw, but it returned normally');
}
void main() {
late Cli cli;
setUp(() => cli = Cli());
test('a subcommand runs and its return value comes back out of run()',
() async {
final ok = await runCaptured(cli.runner, ['deploy', '-t', 'prod']);
// CommandRunner<int> completes with what DeployCommand.run returned. This is
// the exit-code path: no global, no dart:io.
expect(ok.value, equals(0));
expect(ok.printed, isEmpty);
expect(cli.deployCalls, hasLength(1));
expect(cli.deployCalls.single.option('target'), equals('prod'));
final failed =
await runCaptured(cli.runner, ['deploy', '-t', 'prod', '--no-verify']);
expect(failed.value, equals(42));
});
test('null is a success value, and --help never reaches the command',
() async {
// No arguments at all: usage is printed and run() completes with null.
// Reading null as "nothing matched" is wrong on all three of these.
final empty = await runCaptured(cli.runner, const []);
expect(empty.value, isNull);
expect(empty.printed.single, startsWith('A deployment tool.'));
expect(empty.printed.single, contains('Available commands:'));
final topHelp = await runCaptured(cli.runner, ['--help']);
expect(topHelp.value, isNull);
expect(topHelp.printed.single, contains('Available commands:'));
// The command's own help. run() is not called, so nothing was recorded.
final commandHelp = await runCaptured(cli.runner, ['deploy', '--help']);
expect(commandHelp.value, isNull);
expect(commandHelp.printed.single, startsWith('Deploy the current build.'));
expect(commandHelp.printed.single, contains('Usage: csx deploy [arguments]'));
expect(cli.deployCalls, isEmpty);
});
test('an unknown command is a UsageException holding the runner usage',
() async {
final error = await errorFrom(() => cli.runner.run(['deploi']));
expect(error, isA<UsageException>());
final usage = error as UsageException;
// The suggestion block is built from an edit distance with a default limit
// of 2, so a one-letter typo names the real command.
expect(
usage.message,
equals('Could not find a command named "deploi".\n\n'
'Did you mean one of these?\n deploy\n'),
);
expect(usage.usage, startsWith('Usage: csx <command> [arguments]'));
expect(usage.usage, contains('Global options:'));
expect(usage.usage, contains('Available commands:'));
// toString is message and usage joined, which is the whole of what a main
// needs to print. Nothing else has to be assembled.
expect(usage.toString(), equals('${usage.message}\n\n${usage.usage}'));
expect(cli.deployCalls, isEmpty);
// The limit is 2, measured on its own boundary: two junk letters still name
// deploy, three stop naming anything. Nothing about that number is visible
// to the user.
final atLimit = await errorFrom(() => cli.runner.run(['deployxy']));
expect(
(atLimit as UsageException).message,
equals('Could not find a command named "deployxy".\n\n'
'Did you mean one of these?\n deploy\n'),
);
final pastLimit = await errorFrom(() => cli.runner.run(['deployxyz']));
expect(
(pastLimit as UsageException).message,
equals('Could not find a command named "deployxyz".'),
);
// Past the distance limit there is no block at all, so the message is not
// safe to match with equals unless you know which case you are in.
final noMatch = await errorFrom(() => cli.runner.run(['zzzzzzz']));
expect(
(noMatch as UsageException).message,
equals('Could not find a command named "zzzzzzz".'),
);
});
test('a parse error inside a subcommand is the same type, different usage',
() async {
final error = await errorFrom(() => cli.runner.run(['deploy', '--taget']));
// Same class as the unknown-command error above. What separates them is
// whose usage is attached: args re-attributes a parse failure to the command
// that owns the parser, so the user sees the deploy block and not the
// command list.
expect(error, isA<UsageException>());
final usage = error as UsageException;
expect(usage.message, equals('Could not find an option named "--taget".'));
expect(usage.usage, startsWith('Usage: csx deploy [arguments]'));
expect(usage.usage, contains('Run "csx help" to see global options.'));
expect(usage.usage, isNot(contains('Available commands:')));
// A stray word before the command name is a third node: the top-level parser
// reports it before any command was entered, so the runner usage is back.
final stray = await errorFrom(() => cli.runner.run(['oops', 'deploy']));
final strayUsage = stray as UsageException;
expect(strayUsage.message,
equals('Cannot specify arguments before a command.'));
expect(strayUsage.usage, startsWith('Usage: csx <command> [arguments]'));
expect(cli.deployCalls, isEmpty);
});
test('long options are never abbreviated by prefix', () async {
// argparse would accept --targ for --target unless allow_abbrev=False.
// package:args resolves long names with findByNameOrAlias, which is exact
// match only, and offers no switch that would change that. A unique prefix
// is just an unknown option.
final error =
await errorFrom(() => cli.runner.run(['deploy', '--targ', 'prod']));
expect(
(error as UsageException).message,
equals('Could not find an option named "--targ".'),
);
// The two spellings that do work: the single-letter abbr, and an alias,
// which is a second exact name rather than a prefix.
expect(
(await runCaptured(cli.runner, ['deploy', '-t', 'prod'])).value,
equals(0),
);
expect(cli.deployCalls.last.option('target'), equals('prod'));
expect(
(await runCaptured(cli.runner, ['deploy', '--destination', 'prod'])).value,
equals(0),
);
expect(cli.deployCalls.last.option('target'), equals('prod'));
// An alias is deliberately absent from the usage block: it keeps an old
// spelling working, it does not document a new one.
final deployUsage = cli.runner.commands['deploy']!.argParser.usage;
expect(deployUsage, contains('--target'));
expect(deployUsage, isNot(contains('destination')));
});
test('a collapsed -abbr run takes its value on the first letter, not the last',
() async {
// -vq is two flags. This, not prefix matching, is what args means by
// abbreviation.
await runCaptured(cli.runner, ['deploy', '-vq']);
expect(cli.deployCalls.single.flag('verbose'), isTrue);
expect(cli.deployCalls.single.flag('quiet'), isTrue);
// A leading option letter swallows the remainder as its value.
await runCaptured(cli.runner, ['deploy', '-tprod']);
expect(cli.deployCalls.last.option('target'), equals('prod'));
// getopt would read -vtprod as -v -t prod. args decides on the first letter
// instead: it is a flag, so every later letter must be a flag too, and -t
// is not. The message never mentions ordering.
final trailing =
await errorFrom(() => cli.runner.run(['deploy', '-vtprod']));
expect(
(trailing as UsageException).message,
equals('Option "-t" must be a flag to be in a collapsed "-".'),
);
final split = await errorFrom(() => cli.runner.run(['deploy', '-vt', 'prod']));
expect(
(split as UsageException).message,
equals('Option "-t" must be a flag to be in a collapsed "-".'),
);
// An unknown letter has two different messages depending on whether it was
// alone, because a lone -z takes the solo-option path instead.
final solo = await errorFrom(() => cli.runner.run(['deploy', '-z']));
expect((solo as UsageException).message,
equals('Could not find an option or flag "-z".'));
final collapsed = await errorFrom(() => cli.runner.run(['deploy', '-vz']));
expect((collapsed as UsageException).message,
equals('Could not find an option with short name "-z".'));
});
test('mandatory: true is only enforced at parse time when a callback exists',
() async {
// The check sits inside Parser.parse's post-parse loop, below an early
// return for options with no callback, so this parse succeeds and the miss
// is simply not represented anywhere in the results.
final quiet = ArgParser()..addOption('tag', mandatory: true);
final parsed = quiet.parse(const []);
expect(parsed.rest, isEmpty);
expect(parsed.options, isNot(contains('tag')));
final read = await errorFrom(() async => parsed.option('tag'));
expect(read, isA<ArgumentError>());
expect((read as ArgumentError).message, equals('Option tag is mandatory.'));
// Adding a callback, which has nothing to do with requiredness, is what
// turns the parse-time check on.
final loud = ArgParser()
..addOption('tag', mandatory: true, callback: (_) {});
final atParse = await errorFrom(() async => loud.parse(const []));
expect(atParse, isA<ArgParserException>());
expect((atParse as ArgParserException).message,
equals('Option tag is mandatory.'));
// Both declarations advertise the requirement the same way in usage, with
// and without the callback, which is how the difference stays invisible
// until one of them fires.
expect(cli.runner.commands['release']!.argParser.usage,
contains('(mandatory)'));
expect(cli.runner.commands['promote']!.argParser.usage,
contains('(mandatory)'));
});
test('through CommandRunner the callback-less form fails after run() started',
() async {
final error = await errorFrom(() => cli.runner.run(['release']));
// Not a UsageException, so a main that catches only UsageException prints a
// stack trace here.
expect(error, isA<ArgumentError>());
expect(error, isNot(isA<UsageException>()));
expect((error as ArgumentError).message, equals('Option tag is mandatory.'));
expect(error.toString(), equals('Invalid argument(s): Option tag is mandatory.'));
expect(cli.releaseCalls, hasLength(1),
reason: 'the command body ran before the missing option was noticed');
// With the callback, the same declaration fails during parsing and is
// converted to the UsageException people expect, before run() is reached.
final promoted = await errorFrom(() => cli.runner.run(['promote']));
expect(promoted, isA<UsageException>());
final usage = promoted as UsageException;
expect(usage.message, equals('Option tag is mandatory.'));
expect(usage.usage, startsWith('Usage: csx promote [arguments]'));
expect(cli.promoteCalls, isEmpty);
expect(
(await runCaptured(cli.runner, ['release', '--tag', 'v1.2.0'])).value,
equals(6),
);
});
test('the required option that always prints usage is an explicit call',
() async {
final error = await errorFrom(() => cli.runner.run(['publish']));
expect(error, isA<UsageException>());
final usage = error as UsageException;
expect(usage.message, equals('Option tag is required.'));
expect(usage.usage, startsWith('Usage: csx publish [arguments]'));
expect(cli.publishCalls, isEmpty);
expect(
(await runCaptured(cli.runner, ['publish', '--tag', 'v1.2.0'])).value,
equals(6),
);
});
test('every flag is negatable unless the flag opted out', () async {
// --no-verify was never declared. addFlag created it.
expect(
(await runCaptured(cli.runner, ['deploy', '--no-verify'])).value,
equals(42),
);
expect(cli.deployCalls.single.flag('verify'), isFalse);
// Including for a flag that already defaults to false, where --no- looks
// pointless and is still accepted.
await runCaptured(cli.runner, ['deploy', '--no-verbose']);
expect(cli.deployCalls.last.flag('verbose'), isFalse);
// negatable: false is the opt-out, and it gives --no-color its own wording,
// distinct from the unknown-option error a reader might expect. Measured:
// the message quotes the spelling that was typed, --no-color, and not the
// flag being negated, so matching on the declared name --color misses.
final error =
await errorFrom(() => cli.runner.run(['deploy', '--no-color']));
expect(
(error as UsageException).message,
equals('Cannot negate option "--no-color".'),
);
// The usage block is where the difference is visible before anyone runs it.
final deployUsage = cli.runner.commands['deploy']!.argParser.usage;
expect(deployUsage, contains('--[no-]verify'));
expect(deployUsage, contains('--color'));
expect(deployUsage, isNot(contains('--[no-]color')));
});
test('everything after -- is rest and is never parsed', () async {
final outcome = await runCaptured(cli.runner, [
'deploy',
'-t',
'prod',
'--',
'--no-verify',
'-v',
'file.txt',
]);
// --no-verify past the -- did not flip anything, so the exit code is still
// the verified one. That is what makes forwarding a user's argv safe.
expect(outcome.value, equals(0));
final results = cli.deployCalls.single;
expect(results.flag('verify'), isTrue);
expect(results.flag('verbose'), isFalse);
expect(results.option('target'), equals('prod'));
// The -- itself is consumed, and only the first one is.
expect(results.rest, equals(['--no-verify', '-v', 'file.txt']));
await runCaptured(cli.runner, ['deploy', '--', 'a', '--', 'b']);
expect(cli.deployCalls.last.rest, equals(['a', '--', 'b']));
});
test('a -- before the command name hides the command from the runner',
() async {
// The top-level parser checks for -- before it looks a command up, so deploy
// is left over as a plain word and the runner reports it as unknown while
// suggesting the very command that was asked for.
final error = await errorFrom(() => cli.runner.run(['--', 'deploy']));
expect(error, isA<UsageException>());
expect(
(error as UsageException).message,
equals('Could not find a command named "deploy".\n\n'
'Did you mean one of these?\n deploy\n'),
);
expect(cli.deployCalls, isEmpty);
});
}