CodeSampleX

Пример

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…

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

Контракт

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. assert everything after -- lands in rest unparsed so a flag past it does not take effect, and that only the first -- is consumed
  12. 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

Скачать артефакт с исходным кодом (tar.gz)

Исходный код

csx.json
{"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"}
lib/cli.dart
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;
}
pubspec.lock
# 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"
pubspec.yaml
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
test/contract_test.dart
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);
  });
}

Исходный сидер

anonymous