Make it possible to add default subcommand (#925)

Make it possible to add default subcommand (designated by an empty
name) for a branch command: default subcommand will be run when no other
subcommand is selected. This allows creating command line interfaces
where both `program command` and `program command subcommand` are
runnable.

Fixes #103
diff --git a/pkgs/args/CHANGELOG.md b/pkgs/args/CHANGELOG.md
index f712877..54985ff 100644
--- a/pkgs/args/CHANGELOG.md
+++ b/pkgs/args/CHANGELOG.md
@@ -1,3 +1,12 @@
+## 2.8.0
+
+* Allow designating a top-level command or a subcommand as a default one by
+  passing `isDefault: true` to `addCommand` or `addSubcommand`.
+  Default command will be selected by argument parser if no sibling command
+  matches. This allows creating command line interfaces where both
+  `program command` and `program command subcommand` are runnable
+  (Fixes #103).
+
 ## 2.7.0
 
 * Remove sorting of the `allowedHelp` argument in usage output. Ordering will
diff --git a/pkgs/args/lib/command_runner.dart b/pkgs/args/lib/command_runner.dart
index e72a08d..2f878c5 100644
--- a/pkgs/args/lib/command_runner.dart
+++ b/pkgs/args/lib/command_runner.dart
@@ -31,9 +31,19 @@
 
   /// A single-line template for how to invoke this executable.
   ///
-  /// Defaults to `"$executableName <command> arguments`". Subclasses can
-  /// override this for a more specific template.
-  String get invocation => '$executableName <command> [arguments]';
+  /// Defaults to `"$executableName <command> arguments"` (if there is no
+  /// default command) or `"$executableName [<command>] arguments"` (otherwise).
+  ///
+  /// Subclasses can override this for a more specific template.
+  String get invocation {
+    var command = '<command>';
+
+    if (argParser.defaultCommand != null) {
+      command = '[$command]';
+    }
+
+    return '$executableName $command [arguments]';
+  }
 
   /// Generates a string displaying usage information for the executable.
   ///
@@ -56,9 +66,10 @@
     );
     buffer.writeln(_wrap('Global options:'));
     buffer.writeln('${argParser.usage}\n');
-    buffer.writeln(
-      '${_getCommandUsage(_commands, lineLength: argParser.usageLineLength)}\n',
-    );
+    buffer.writeln(_getCommandUsage(_commands,
+        lineLength: argParser.usageLineLength,
+        defaultCommand: argParser.defaultCommand));
+    buffer.writeln();
     buffer.write(_wrap(
         'Run "$executableName help <command>" for more information about a '
         'command.'));
@@ -105,12 +116,25 @@
       throw UsageException(message, _usageWithoutDescription);
 
   /// Adds [Command] as a top-level command to this runner.
-  void addCommand(Command<T> command) {
+  ///
+  /// If [isDefault] is `true` then added command will be designated as a
+  /// default one. Default command is selected if no other sibling command
+  /// matches. Only a single leaf-command can be designated as a default.
+  void addCommand(Command<T> command, {bool isDefault = false}) {
+    if (isDefault && command.subcommands.isNotEmpty) {
+      throw ArgumentError('default command must be a leaf command');
+    }
+    if (isDefault && argParser.defaultCommand != null) {
+      throw StateError('default command already defined');
+    }
     var names = [command.name, ...command.aliases];
     for (var name in names) {
       _commands[name] = command;
       argParser.addCommand(name, command.argParser);
     }
+    if (isDefault) {
+      argParser.defaultCommand = command.name;
+    }
     command._runner = this;
   }
 
@@ -288,9 +312,13 @@
     parents.add(runner!.executableName);
 
     var invocation = parents.reversed.join(' ');
-    return _subcommands.isNotEmpty
-        ? '$invocation <subcommand> [arguments]'
-        : '$invocation [arguments]';
+    if (argParser.defaultCommand != null) {
+      return '$invocation [<subcommand>] [arguments]';
+    } else if (_subcommands.isNotEmpty) {
+      return '$invocation <subcommand> [arguments]';
+    } else {
+      return '$invocation [arguments]';
+    }
   }
 
   /// The command's parent command, if this is a subcommand.
@@ -363,11 +391,10 @@
 
     if (_subcommands.isNotEmpty) {
       buffer.writeln();
-      buffer.writeln(_getCommandUsage(
-        _subcommands,
-        isSubcommand: true,
-        lineLength: length,
-      ));
+      buffer.writeln(_getCommandUsage(_subcommands,
+          isSubcommand: true,
+          lineLength: length,
+          defaultCommand: argParser.defaultCommand));
     }
 
     buffer.writeln();
@@ -446,12 +473,26 @@
   }
 
   /// Adds [Command] as a subcommand of this.
-  void addSubcommand(Command<T> command) {
+  ///
+  /// If [isDefault] is `true` then added command will be designated as a
+  /// default one. Default subcommand is selected if no other sibling subcommand
+  /// matches. Only a single leaf-command can be designated as a default.
+  void addSubcommand(Command<T> command, {bool isDefault = false}) {
+    if (isDefault && command.subcommands.isNotEmpty) {
+      throw ArgumentError('default command must be a leaf command');
+    }
+    if (isDefault && argParser.defaultCommand != null) {
+      throw StateError('default command already defined');
+    }
+
     var names = [command.name, ...command.aliases];
     for (var name in names) {
       _subcommands[name] = command;
       argParser.addCommand(name, command.argParser);
     }
+    if (isDefault) {
+      argParser.defaultCommand = command.name;
+    }
     command._parent = this;
   }
 
@@ -470,8 +511,10 @@
 ///
 /// [isSubcommand] indicates whether the commands should be called "commands" or
 /// "subcommands".
+///
+/// [defaultCommand] indicate which command (if any) is designated as default.
 String _getCommandUsage(Map<String, Command> commands,
-    {bool isSubcommand = false, int? lineLength}) {
+    {bool isSubcommand = false, int? lineLength, String? defaultCommand}) {
   // Don't include aliases.
   var names =
       commands.keys.where((name) => !commands[name]!.aliases.contains(name));
@@ -502,7 +545,8 @@
       buffer.write(category);
     }
     for (var command in commandsByCategory[category]!) {
-      var lines = wrapTextAsLines(command.summary,
+      var defaultMarker = defaultCommand == command.name ? '(default) ' : '';
+      var lines = wrapTextAsLines(defaultMarker + command.summary,
           start: columnStart, length: lineLength);
       buffer.writeln();
       buffer.write('  ${padRight(command.name, length)}   ${lines.first}');
@@ -515,6 +559,15 @@
     }
   }
 
+  if (defaultCommand != null) {
+    buffer.writeln();
+    buffer.writeln();
+    buffer.write(wrapText(
+        'Default command ($defaultCommand) will be selected if no command'
+        ' is explicitly specified.',
+        length: lineLength));
+  }
+
   return buffer.toString();
 }
 
diff --git a/pkgs/args/lib/src/allow_anything_parser.dart b/pkgs/args/lib/src/allow_anything_parser.dart
index 69472b3..20d77db 100644
--- a/pkgs/args/lib/src/allow_anything_parser.dart
+++ b/pkgs/args/lib/src/allow_anything_parser.dart
@@ -104,4 +104,11 @@
 
   @override
   Option? findByNameOrAlias(String name) => null;
+
+  @override
+  String? get defaultCommand => null;
+
+  @override
+  set defaultCommand(String? value) => throw UnsupportedError(
+      "ArgParser.allowAnything().defaultCommand= isn't supported.");
 }
diff --git a/pkgs/args/lib/src/arg_parser.dart b/pkgs/args/lib/src/arg_parser.dart
index 37041d7..22bc2b4 100644
--- a/pkgs/args/lib/src/arg_parser.dart
+++ b/pkgs/args/lib/src/arg_parser.dart
@@ -25,6 +25,11 @@
   /// The commands that have been defined for this parser.
   final Map<String, ArgParser> commands;
 
+  /// Command which will be executed by default if no command is specified.
+  ///
+  /// When `null` it is a usage error to omit the command name.
+  String? defaultCommand;
+
   /// A list of the [Option]s in [options] intermingled with [String]
   /// separators.
   final _optionsAndSeparators = <Object>[];
diff --git a/pkgs/args/lib/src/parser.dart b/pkgs/args/lib/src/parser.dart
index 660e56d..9aca0f2 100644
--- a/pkgs/args/lib/src/parser.dart
+++ b/pkgs/args/lib/src/parser.dart
@@ -49,7 +49,7 @@
           _grammar, const {}, _commandName, null, arguments, arguments);
     }
 
-    ArgResults? commandResults;
+    ({String name, ArgParser parser})? command;
 
     // Parse the args.
     while (_args.isNotEmpty) {
@@ -61,26 +61,19 @@
 
       // Try to parse the current argument as a command. This happens before
       // options so that commands can have option-like names.
-      var command = _grammar.commands[_current];
-      if (command != null) {
-        _validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
-            _current);
-        var commandName = _args.removeFirst();
-        var commandParser = Parser(commandName, command, _args, this, _rest);
-
-        try {
-          commandResults = commandParser.parse();
-        } on ArgParserException catch (error) {
-          throw ArgParserException(
-              error.message,
-              [commandName, ...error.commands],
-              error.argumentName,
-              error.source,
-              error.offset);
-        }
-
-        // All remaining arguments were passed to command so clear them here.
-        _rest.clear();
+      //
+      // Otherwise, if there is a default command then select it before parsing
+      // any arguments. We make exception for situations when help flag is
+      // passed because we want `program command -h` to display help for
+      // `command` rather than display help for the default subcommand of the
+      // `command`.
+      if (_grammar.commands[_current] case final parser?) {
+        command = (name: _args.removeFirst(), parser: parser);
+        break;
+      } else if (_grammar.defaultCommand case final defaultCommand?
+          when !(_current == '-h' || _current == '--help')) {
+        command =
+            (name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
         break;
       }
 
@@ -96,6 +89,38 @@
       _rest.add(_args.removeFirst());
     }
 
+    // If there is a default command and we did not select any other commands
+    // and we don't have any trailing arguments then select the default
+    // command unless user requested help.
+    if (command == null && _rest.isEmpty && !_results.containsKey('help')) {
+      if (_grammar.defaultCommand case final defaultCommand?) {
+        command =
+            (name: defaultCommand, parser: _grammar.commands[defaultCommand]!);
+      }
+    }
+
+    ArgResults? commandResults;
+    if (command != null) {
+      _validate(_rest.isEmpty, 'Cannot specify arguments before a command.',
+          command.name);
+      var commandParser =
+          Parser(command.name, command.parser, _args, this, _rest);
+
+      try {
+        commandResults = commandParser.parse();
+      } on ArgParserException catch (error) {
+        throw ArgParserException(
+            error.message,
+            [command.name, ...error.commands],
+            error.argumentName,
+            error.source,
+            error.offset);
+      }
+
+      // All remaining arguments were passed to command so clear them here.
+      _rest.clear();
+    }
+
     // Check if mandatory and invoke existing callbacks.
     _grammar.options.forEach((name, option) {
       var parsedOption = _results[name];
diff --git a/pkgs/args/pubspec.yaml b/pkgs/args/pubspec.yaml
index d0a54e0..d65ca0b 100644
--- a/pkgs/args/pubspec.yaml
+++ b/pkgs/args/pubspec.yaml
@@ -1,5 +1,5 @@
 name: args
-version: 2.7.0
+version: 2.8.0
 description: >-
   Library for defining parsers for parsing raw command-line arguments into a set
   of options and values using GNU and POSIX style options.
diff --git a/pkgs/args/test/command_runner_test.dart b/pkgs/args/test/command_runner_test.dart
index b9fde8a..b3ac7b8 100644
--- a/pkgs/args/test/command_runner_test.dart
+++ b/pkgs/args/test/command_runner_test.dart
@@ -24,8 +24,15 @@
     runner = CommandRunner('test', 'A test command runner.');
   });
 
-  test('.invocation has a sane default', () {
-    expect(runner.invocation, equals('test <command> [arguments]'));
+  group('.invocation has a sensible default', () {
+    test('without default command', () {
+      expect(runner.invocation, equals('test <command> [arguments]'));
+    });
+
+    test('with default command', () {
+      runner.addCommand(FooCommand(), isDefault: true);
+      expect(runner.invocation, equals('test [<command>] [arguments]'));
+    });
   });
 
   group('.usage', () {
@@ -256,6 +263,27 @@
 Run "name help <command>" for more
 information about a command.'''));
     });
+
+    test('contains default command', () {
+      runner.addCommand(FooCommand());
+      runner.addCommand(BarCommand(), isDefault: true);
+
+      expect(runner.usage, equals('''
+A test command runner.
+
+Usage: test [<command>] [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+  bar   (default) Set another value.
+  foo   Set a value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help <command>" for more information about a command.'''));
+    });
   });
 
   test('usageException splits up the message and usage', () {
@@ -263,6 +291,21 @@
         throwsUsageException('message', _defaultUsage));
   });
 
+  group('.addCommand', () {
+    test('only one command can be default', () {
+      runner.addCommand(FooCommand(), isDefault: true);
+      expect(() => runner.addCommand(BarCommand(), isDefault: true),
+          throwsStateError);
+    });
+
+    test('only leaf command can be default', () {
+      expect(
+          () => runner.addCommand(BarCommand()..addSubcommand(FooCommand()),
+              isDefault: true),
+          throwsArgumentError);
+    });
+  });
+
   group('run()', () {
     test('runs a command', () {
       var command = FooCommand();
@@ -741,6 +784,141 @@
     expect(await runner.run([subcommand.name, '--mandatory-option', 'foo']),
         'foo');
   });
+
+  test('default command runs', () {
+    final defaultSubcommand = FooCommand();
+    runner.addCommand(defaultSubcommand, isDefault: true);
+
+    expect(
+        runner.run([]).then((_) {
+          expect(defaultSubcommand.hasRun, isTrue);
+        }),
+        completes);
+  });
+
+  test('default subcommand runs', () {
+    final defaultSubcommand = FooCommand();
+    final command = FooCommand()
+      ..addSubcommand(AsyncCommand())
+      ..addSubcommand(defaultSubcommand, isDefault: true);
+    runner.addCommand(command);
+
+    expect(
+        runner.run(['foo']).then((_) {
+          expect(defaultSubcommand.hasRun, isTrue);
+        }),
+        completes);
+  });
+
+  test('default subcommand parses flags', () {
+    final defaultSubcommand = BarCommand();
+    final command = FooCommand()
+      ..addSubcommand(AsyncCommand())
+      ..addSubcommand(defaultSubcommand, isDefault: true);
+    runner.addCommand(command);
+
+    expect(
+        runner.run(['foo', '--flag']).then((_) {
+          expect(defaultSubcommand.hasRun, isTrue);
+          expect(defaultSubcommand.argResults?.flag('flag'), isTrue);
+        }),
+        completes);
+  });
+
+  test('named subcommand has precedence over default', () {
+    final defaultSubcommand = BarCommand();
+    final asyncCommand = AsyncCommand();
+    final command = FooCommand()
+      ..addSubcommand(asyncCommand)
+      ..addSubcommand(defaultSubcommand, isDefault: true);
+    runner.addCommand(command);
+
+    expect(
+        runner.run(['foo', 'async']).then((_) {
+          expect(defaultSubcommand.hasRun, isFalse);
+          expect(asyncCommand.hasRun, isTrue);
+        }),
+        completes);
+  });
+
+  test('default command throws meaningful error for unexpected argument', () {
+    final defaultSubcommand = BarCommand();
+    runner.addCommand(defaultSubcommand, isDefault: true);
+
+    expect(
+        runner.run(['foo']),
+        throwsUsageException(
+            '''Command "bar" does not take any arguments.''', anything));
+  });
+
+  test('default subcommand throws meaningful error for unexpected argument',
+      () {
+    final defaultSubcommand = BarCommand();
+    final asyncCommand = AsyncCommand();
+    final command = FooCommand()
+      ..addSubcommand(asyncCommand)
+      ..addSubcommand(defaultSubcommand, isDefault: true);
+    runner.addCommand(command);
+
+    expect(
+        runner.run(['foo', 'baz']),
+        throwsUsageException(
+            '''Command "bar" does not take any arguments.''', anything));
+  });
+
+  test('help flag has precedence over default command', () {
+    final defaultCommand = BarCommand();
+    runner.addCommand(defaultCommand, isDefault: true);
+
+    expect(
+        () => runner.run(['-h']).then((_) {
+              expect(defaultCommand.hasRun, isFalse);
+            }),
+        prints('''
+A test command runner.
+
+Usage: test [<command>] [arguments]
+
+Global options:
+-h, --help    Print this usage information.
+
+Available commands:
+  bar   (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help <command>" for more information about a command.
+'''));
+  });
+
+  test('help flag has precedence over default subcommand', () {
+    final defaultSubcommand = BarCommand();
+    final asyncCommand = AsyncCommand();
+    final command = FooCommand()
+      ..addSubcommand(asyncCommand)
+      ..addSubcommand(defaultSubcommand, isDefault: true);
+    runner.addCommand(command);
+
+    expect(
+        () => runner.run(['foo', '-h']).then((_) {
+              expect(defaultSubcommand.hasRun, isFalse);
+              expect(asyncCommand.hasRun, isFalse);
+            }),
+        prints('''
+Set a value.
+
+Usage: test foo [<subcommand>] [arguments]
+-h, --help    Print this usage information.
+
+Available subcommands:
+  async   Set a value asynchronously.
+  bar     (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help" to see global options.
+'''));
+  });
 }
 
 class _MandatoryOptionCommand extends Command {
diff --git a/pkgs/args/test/command_test.dart b/pkgs/args/test/command_test.dart
index 555cc8d..619bded 100644
--- a/pkgs/args/test/command_test.dart
+++ b/pkgs/args/test/command_test.dart
@@ -16,7 +16,7 @@
     CommandRunner<void>('test', 'A test command runner.').addCommand(foo);
   });
 
-  group('.invocation has a sane default', () {
+  group('.invocation has a sensible default', () {
     test('without subcommands', () {
       expect(foo.invocation, equals('test foo [arguments]'));
     });
@@ -26,6 +26,11 @@
       expect(foo.invocation, equals('test foo <subcommand> [arguments]'));
     });
 
+    test('with default subcommand', () {
+      foo.addSubcommand(AsyncCommand(), isDefault: true);
+      expect(foo.invocation, equals('test foo [<subcommand>] [arguments]'));
+    });
+
     test('for a subcommand', () {
       var async = AsyncCommand();
       foo.addSubcommand(async);
@@ -140,6 +145,24 @@
 Run "longtest help" to see global
 options.'''));
     });
+
+    test('prints default subcommand', () {
+      foo.addSubcommand(BarCommand(), isDefault: true);
+      foo.addSubcommand(AsyncCommand());
+      expect(foo.usage, equals('''
+Set a value.
+
+Usage: test foo [<subcommand>] [arguments]
+-h, --help    Print this usage information.
+
+Available subcommands:
+  async   Set a value asynchronously.
+  bar     (default) Set another value.
+
+Default command (bar) will be selected if no command is explicitly specified.
+
+Run "test help" to see global options.'''));
+    });
   });
 
   test('usageException splits up the message and usage', () {
@@ -155,4 +178,19 @@
     foo.addSubcommand(HiddenCommand());
     expect(foo.hidden, isTrue);
   });
+
+  group('.addSubcommand', () {
+    test('only one subcommand can be default', () {
+      foo.addSubcommand(BarCommand(), isDefault: true);
+      expect(() => foo.addSubcommand(AsyncCommand(), isDefault: true),
+          throwsStateError);
+    });
+
+    test('only leaf subcommand can be default', () {
+      expect(
+          () => foo.addSubcommand(BarCommand()..addSubcommand(FooCommand()),
+              isDefault: true),
+          throwsArgumentError);
+    });
+  });
 }
diff --git a/pkgs/args/test/test_utils.dart b/pkgs/args/test/test_utils.dart
index f7d8b8a..340e52b 100644
--- a/pkgs/args/test/test_utils.dart
+++ b/pkgs/args/test/test_utils.dart
@@ -45,6 +45,28 @@
   }
 }
 
+class BarCommand extends Command {
+  bool hasRun = false;
+
+  @override
+  final name = 'bar';
+
+  @override
+  final description = 'Set another value.';
+
+  @override
+  final takesArguments = false;
+
+  BarCommand() {
+    argParser.addFlag('flag', help: 'Some flag');
+  }
+
+  @override
+  void run() {
+    hasRun = true;
+  }
+}
+
 class ValueCommand extends Command<int> {
   @override
   final name = 'foo';