From a5b5d220d152040b2ce08f6fd4f8efe26230dd1d Mon Sep 17 00:00:00 2001
From: "rnystrom@google.com" <rnystrom@google.com>
Date: Thu, 6 Feb 2014 18:52:00 +0000
Subject: [PATCH] Support subcommands in pub and pub help.

BUG=
R=nweiz@google.com

Review URL: https://codereview.chromium.org//138723005

git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge@32376 260f80e4-7a28-3924-810f-c04153c831b5
---
 bin/pub.dart                                  | 135 +++++++++--
 lib/src/command.dart                          | 226 +++++++++---------
 lib/src/command/build.dart                    |   3 +-
 lib/src/command/cache.dart                    |  45 +---
 lib/src/command/cache_list.dart               |  33 +++
 lib/src/command/help.dart                     |  40 +++-
 lib/src/command/lish.dart                     |  11 +-
 lib/src/log.dart                              |   7 +
 lib/src/solver/solve_report.dart              |  65 ++---
 lib/src/utils.dart                            |   6 +
 ..._cannot_be_combined_with_dry_run_test.dart |  11 +-
 ...ckage_creation_provides_an_error_test.dart |   1 -
 test/pub_cache_test.dart                      |  24 --
 test/pub_test.dart                            | 117 ++++++++-
 14 files changed, 454 insertions(+), 270 deletions(-)
 create mode 100644 lib/src/command/cache_list.dart

diff --git a/bin/pub.dart b/bin/pub.dart
index a589dcc8..ecc8318a 100644
--- a/bin/pub.dart
+++ b/bin/pub.dart
@@ -7,9 +7,11 @@ import 'dart:io';
 
 import 'package:args/args.dart';
 import 'package:path/path.dart' as path;
+import 'package:stack_trace/stack_trace.dart';
 
 import '../lib/src/command.dart';
 import '../lib/src/exit_codes.dart' as exit_codes;
+import '../lib/src/http.dart';
 import '../lib/src/io.dart';
 import '../lib/src/log.dart' as log;
 import '../lib/src/sdk.dart' as sdk;
@@ -38,18 +40,6 @@ void main(List<String> arguments) {
     return;
   }
 
-  if (options.command == null) {
-    if (options.rest.isEmpty) {
-      // No command was chosen.
-      PubCommand.printGlobalUsage();
-    } else {
-      log.error('Could not find a command named "${options.rest[0]}".');
-      log.error('Run "pub help" to see available commands.');
-      flushThenExit(exit_codes.USAGE);
-    }
-    return;
-  }
-
   if (options['trace']) {
     log.recordTranscript();
   }
@@ -81,8 +71,125 @@ void main(List<String> arguments) {
     cacheDir = '${Platform.environment['HOME']}/.pub-cache';
   }
 
-  validatePlatform().then((_) {
-    PubCommand.commands[options.command.name].run(cacheDir, options, arguments);
+  validatePlatform().then((_) => runPub(cacheDir, options, arguments));
+}
+
+/// Runs the appropriate pub command whose [arguments] have been parsed to
+/// [options] using the system cache in [cacheDir].
+///
+/// Handles and correctly reports any errors that occur while running.
+void runPub(String cacheDir, ArgResults options, List<String> arguments) {
+  var captureStackChains =
+      options['trace'] ||
+      options['verbose'] ||
+      options['verbosity'] == 'all';
+
+  captureErrors(() => invokeCommand(cacheDir, options),
+      captureStackChains: captureStackChains).catchError((error, Chain chain) {
+    // This is basically the top-level exception handler so that we don't
+    // spew a stack trace on our users.
+    var message;
+
+    log.error(getErrorMessage(error));
+    log.fine("Exception type: ${error.runtimeType}");
+
+    if (options['trace'] || !isUserFacingException(error)) {
+      log.error(chain.terse);
+    } else {
+      log.fine(chain.terse);
+    }
+
+    if (error is ApplicationException && error.innerError != null) {
+      var message = "Wrapped exception: ${error.innerError}";
+      if (error.innerTrace != null) message = "$message\n${error.innerTrace}";
+      log.fine(message);
+    }
+
+    if (options['trace']) {
+      log.dumpTranscript();
+    } else if (!isUserFacingException(error)) {
+      log.error("""
+This is an unexpected error. Please run
+
+    pub --trace ${arguments.map((arg) => "'$arg'").join(' ')}
+
+and include the results in a bug report on http://dartbug.com/new.
+""");
+    }
+
+    return flushThenExit(chooseExitCode(error));
+  }).then((_) {
+    // Explicitly exit on success to ensure that any dangling dart:io handles
+    // don't cause the process to never terminate.
+    return flushThenExit(exit_codes.SUCCESS);
+  });
+}
+
+/// Returns the appropriate exit code for [exception], falling back on 1 if no
+/// appropriate exit code could be found.
+int chooseExitCode(exception) {
+  if (exception is HttpException || exception is HttpException ||
+      exception is SocketException || exception is PubHttpException) {
+    return exit_codes.UNAVAILABLE;
+  } else if (exception is FormatException) {
+    return exit_codes.DATA;
+  } else if (exception is UsageException) {
+    return exit_codes.USAGE;
+  } else {
+    return 1;
+  }
+}
+
+/// Walks the command tree and runs the selected pub command.
+Future invokeCommand(String cacheDir, ArgResults mainOptions) {
+  var commands = PubCommand.mainCommands;
+  var command;
+  var commandString = "pub";
+  var options = mainOptions;
+
+  while (commands.isNotEmpty) {
+    if (options.command == null) {
+      if (options.rest.isEmpty) {
+        if (command == null) {
+          // No top-level command was chosen.
+          PubCommand.printGlobalUsage();
+          return new Future.value();
+        }
+
+        command.usageError('Missing subcommand for "$commandString".');
+      } else {
+        if (command == null) {
+          PubCommand.usageErrorWithCommands(commands,
+              'Could not find a command named "${options.rest[0]}".');
+        }
+
+        command.usageError('Could not find a subcommand named '
+            '"${options.rest[0]}" for "$commandString".');
+      }
+    }
+
+    // Step into the command.
+    options = options.command;
+    command = commands[options.name];
+    commands = command.subcommands;
+    commandString += " ${options.name}";
+
+    if (options['help']) {
+      command.printUsage();
+      return new Future.value();
+    }
+  }
+
+  // Make sure there aren't unexpected arguments.
+  if (!command.takesArguments && options.rest.isNotEmpty) {
+    command.usageError(
+        'Command "${options.name}" does not take any arguments.');
+  }
+
+  return syncFuture(() {
+    return command.run(cacheDir, options);
+  }).whenComplete(() {
+    command.cache.deleteTempDir();
   });
 }
 
diff --git a/lib/src/command.dart b/lib/src/command.dart
index e3dcab5e..3f6f35e1 100644
--- a/lib/src/command.dart
+++ b/lib/src/command.dart
@@ -5,12 +5,10 @@
 library pub.command;
 
 import 'dart:async';
-import 'dart:io';
 import 'dart:math' as math;
 
 import 'package:args/args.dart';
 import 'package:path/path.dart' as path;
-import 'package:stack_trace/stack_trace.dart';
 
 import 'command/build.dart';
 import 'command/cache.dart';
@@ -23,17 +21,18 @@ import 'command/upgrade.dart';
 import 'command/uploader.dart';
 import 'command/version.dart';
 import 'entrypoint.dart';
-import 'exit_codes.dart' as exit_codes;
-import 'http.dart';
-import 'io.dart';
 import 'log.dart' as log;
 import 'system_cache.dart';
 import 'utils.dart';
 
 /// The base class for commands for the pub executable.
+///
+/// A command may either be a "leaf" command or it may be a parent for a set
+/// of subcommands. Only leaf commands are ever actually invoked. If a command
+/// has subcommands, then one of those must always be chosen.
 abstract class PubCommand {
   /// The commands that pub understands.
-  static final Map<String, PubCommand> commands = _initCommands();
+  static final Map<String, PubCommand> mainCommands = _initCommands();
 
   /// The top-level [ArgParser] used to parse the pub command line.
   static final pubArgParser = _initArgParser();
@@ -44,35 +43,59 @@ abstract class PubCommand {
     var buffer = new StringBuffer();
     buffer.writeln('Pub is a package manager for Dart.');
     buffer.writeln();
-    buffer.writeln('Usage: pub command [arguments]');
+    buffer.writeln('Usage: pub <command> [arguments]');
     buffer.writeln();
     buffer.writeln('Global options:');
     buffer.writeln(pubArgParser.getUsage());
+    buffer.write(_listCommands(mainCommands));
     buffer.writeln();
+    buffer.writeln(
+        'Use "pub help [command]" for more information about a command.');
+
+    log.message(buffer);
+  }
+
+  /// Fails with a usage error [message] when trying to select from one of
+  /// [commands].
+  static void usageErrorWithCommands(Map<String, PubCommand> commands,
+                                String message) {
+    throw new UsageException("$message\n${_listCommands(commands)}");
+  }
 
-    // Show the public commands alphabetically.
-    var names = ordered(commands.keys.where((name) =>
-        !commands[name].aliases.contains(name) &&
-        !commands[name].hidden));
+  /// Writes [commands] in a nicely formatted list to [buffer].
+  static String _listCommands(Map<String, PubCommand> commands) {
+    // If there are no subcommands, do nothing.
+    if (commands.isEmpty) return "";
 
+    // Don't include aliases.
+    var names = commands.keys
+        .where((name) => !commands[name].aliases.contains(name));
+
+    // Filter out hidden ones, unless they are all hidden.
+    var visible = names.where((name) => !commands[name].hidden);
+    if (visible.isNotEmpty) names = visible;
+
+    // Show the commands alphabetically.
+    names = ordered(names);
     var length = names.map((name) => name.length).reduce(math.max);
+    var isSubcommand = commands != mainCommands;
 
-    buffer.writeln('Available commands:');
+    var buffer = new StringBuffer();
+    buffer.writeln();
+    buffer.writeln('Available ${isSubcommand ? "sub" : ""}commands:');
     for (var name in names) {
       buffer.writeln('  ${padRight(name, length)}   '
           '${commands[name].description}');
     }
 
-    buffer.writeln();
-    buffer.write(
-        'Use "pub help [command]" for more information about a command.');
-    log.message(buffer.toString());
+    return buffer.toString();
   }
 
   SystemCache cache;
 
   /// The parsed options for this command.
-  ArgResults commandOptions;
+  ArgResults get commandOptions => _commandOptions;
+  ArgResults _commandOptions;
 
   Entrypoint entrypoint;
 
@@ -81,18 +104,27 @@ abstract class PubCommand {
 
   /// If the command is undocumented and should not appear in command listings,
   /// this will be `true`.
-  bool get hidden => false;
+  bool get hidden {
+    // Leaf commands are visible by default.
+    if (subcommands.isEmpty) return false;
+
+    // Otherwise, a command is hidden if all of its subcommands are.
+    return subcommands.values.every((subcommand) => subcommand.hidden);
+  }
 
   /// How to invoke this command (e.g. `"pub get [package]"`).
   String get usage;
 
-  /// Whether or not this command requires [entrypoint] to be defined. If false,
-  /// pub won't look for a pubspec and [entrypoint] will be null when the
-  /// command runs.
+  /// Whether or not this command requires [entrypoint] to be defined.
+  ///
+  /// If false, pub won't look for a pubspec and [entrypoint] will be null when
+  /// the command runs. This only needs to be set in leaf commands.
   bool get requiresEntrypoint => true;
 
-  /// Whether or not this command takes arguments in addition to options. If
-  /// false, pub will exit with an error if arguments are provided.
+  /// Whether or not this command takes arguments in addition to options.
+  ///
+  /// If false, pub will exit with an error if arguments are provided. This
+  /// only needs to be set in leaf commands.
   bool get takesArguments => false;
 
   /// Alternate names for this command. These names won't be used in the
@@ -102,9 +134,17 @@ abstract class PubCommand {
   /// The [ArgParser] for this command.
   final commandParser = new ArgParser();
 
+  /// Subcommands exposed by this command.
+  ///
+  /// If empty, then this command has no subcommands. Otherwise, a subcommand
+  /// must be specified by the user. In that case, this command's [onRun] will
+  /// not be called and the subcommand's will.
+  final subcommands = <String, PubCommand>{};
+
   /// Override this to use offline-only sources instead of hitting the network.
+  ///
   /// This will only be called before the [SystemCache] is created. After that,
-  /// it has no effect.
+  /// it has no effect. This only needs to be set in leaf commands.
   bool get isOffline => false;
 
   PubCommand() {
@@ -113,94 +153,50 @@ abstract class PubCommand {
         help: 'Print usage information for this command.');
   }
 
-  void run(String cacheDir, ArgResults options, List<String> arguments) {
-    commandOptions = options.command;
-
-    if (commandOptions['help']) {
-      this.printUsage();
-      return;
-    }
+  /// Runs this command using a system cache at [cacheDir] with [options].
+  Future run(String cacheDir, ArgResults options) {
+    _commandOptions = options;
 
     cache = new SystemCache.withSources(cacheDir, isOffline: isOffline);
 
-    handleError(error, Chain chain) {
-      // This is basically the top-level exception handler so that we don't
-      // spew a stack trace on our users.
-      var message;
-
-      log.error(getErrorMessage(error));
-      log.fine("Exception type: ${error.runtimeType}");
-
-      if (options['trace'] || !isUserFacingException(error)) {
-        log.error(chain.terse);
-      } else {
-        log.fine(chain.terse);
-      }
-
-      if (error is ApplicationException && error.innerError != null) {
-        var message = "Wrapped exception: ${error.innerError}";
-        if (error.innerTrace != null) message = "$message\n${error.innerTrace}";
-        log.fine(message);
-      }
-
-      if (options['trace']) {
-        log.dumpTranscript();
-      } else if (!isUserFacingException(error)) {
-        log.error("""
-This is an unexpected error. Please run
-
-    pub --trace ${arguments.map((arg) => "'$arg'").join(' ')}
-
-and include the results in a bug report on http://dartbug.com/new.
-""");
-      }
-
-      return flushThenExit(_chooseExitCode(error));
+    if (requiresEntrypoint) {
+      // TODO(rnystrom): Will eventually need better logic to walk up
+      // subdirectories until we hit one that looks package-like. For now,
+      // just assume the cwd is it.
+      entrypoint = new Entrypoint(path.current, cache);
     }
 
-    var captureStackChains =
-        options['trace'] || options['verbose'] || options['verbosity'] == 'all';
-    captureErrors(() {
-      return syncFuture(() {
-        // Make sure there aren't unexpected arguments.
-        if (!takesArguments && commandOptions.rest.isNotEmpty) {
-          log.error('Command "${commandOptions.name}" does not take any '
-                    'arguments.');
-          this.printUsage();
-          return flushThenExit(exit_codes.USAGE);
-        }
-
-        if (requiresEntrypoint) {
-          // TODO(rnystrom): Will eventually need better logic to walk up
-          // subdirectories until we hit one that looks package-like. For now,
-          // just assume the cwd is it.
-          entrypoint = new Entrypoint(path.current, cache);
-        }
-
-        var commandFuture = onRun();
-        if (commandFuture == null) return true;
-
-        return commandFuture;
-      }).whenComplete(() => cache.deleteTempDir());
-    }, captureStackChains: captureStackChains).catchError(handleError)
-        .then((_) {
-      // Explicitly exit on success to ensure that any dangling dart:io handles
-      // don't cause the process to never terminate.
-      return flushThenExit(exit_codes.SUCCESS);
-    });
+    return syncFuture(onRun);
   }
 
-  /// Override this to perform the specific command. Return a future that
-  /// completes when the command is done or fails if the command fails. If the
-  /// command is synchronous, it may return `null`.
-  Future onRun();
+  /// Override this to perform the specific command.
+  ///
+  /// Return a future that completes when the command is done or fails if the
+  /// command fails. If the command is synchronous, it may return `null`. Only
+  /// leaf command should override this.
+  Future onRun() {
+    // Leaf commands should override this and non-leaf commands should never
+    // call it.
+    assert(false);
+  }
 
   /// Displays usage information for this command.
   void printUsage([String description]) {
     if (description == null) description = this.description;
+    log.message('$description\n\n${_getUsage()}');
+  }
 
+  // TODO(rnystrom): Use this in other places handle usage failures.
+  /// Throw an [ApplicationException] for a usage error of this command with
+  /// [message].
+  void usageError(String message) {
+    throw new UsageException("$message\n\n${_getUsage()}");
+  }
+
+  /// Generates a string of usage information for this command.
+  String _getUsage() {
     var buffer = new StringBuffer();
-    buffer.write('$description\n\nUsage: $usage');
+    buffer.write('Usage: $usage');
 
     var commandUsage = commandParser.getUsage();
     if (!commandUsage.isEmpty) {
@@ -208,20 +204,12 @@ and include the results in a bug report on http://dartbug.com/new.
       buffer.write(commandUsage);
     }
 
-    log.message(buffer.toString());
-  }
-
-  /// Returns the appropriate exit code for [exception], falling back on 1 if no
-  /// appropriate exit code could be found.
-  int _chooseExitCode(exception) {
-    if (exception is HttpException || exception is HttpException ||
-        exception is SocketException || exception is PubHttpException) {
-      return exit_codes.UNAVAILABLE;
-    } else if (exception is FormatException) {
-      return exit_codes.DATA;
-    } else {
-      return 1;
+    if (subcommands.isNotEmpty) {
+      buffer.writeln();
+      buffer.write(_listCommands(subcommands));
     }
+
+    return buffer.toString();
   }
 }
 
@@ -272,9 +260,19 @@ ArgParser _initArgParser() {
       help: 'Shortcut for "--verbosity=all".');
 
   // Register the commands.
-  PubCommand.commands.forEach((name, command) {
-    argParser.addCommand(name, command.commandParser);
+  PubCommand.mainCommands.forEach((name, command) {
+    _registerCommand(name, command, argParser);
   });
 
   return argParser;
 }
+
+/// Registers a [command] with [name] on [parser].
+void _registerCommand(String name, PubCommand command, ArgParser parser) {
+  parser.addCommand(name, command.commandParser);
+
+  // Recursively wire up any subcommands.
+  command.subcommands.forEach((name, subcommand) {
+    _registerCommand(name, subcommand, command.commandParser);
+  });
+}
diff --git a/lib/src/command/build.dart b/lib/src/command/build.dart
index b470f1bf..5131ea24 100644
--- a/lib/src/command/build.dart
+++ b/lib/src/command/build.dart
@@ -27,8 +27,7 @@ final _allowedBuildDirectories = new Set<String>.from([
 
 /// Handles the `build` pub command.
 class BuildCommand extends PubCommand {
-  String get description =>
-      "Copy and compile all Dart entrypoints in the 'web' directory.";
+  String get description => "Apply transformers to build a package.";
   String get usage => "pub build [options]";
   List<String> get aliases => const ["deploy", "settle-up"];
   bool get takesArguments => true;
diff --git a/lib/src/command/cache.dart b/lib/src/command/cache.dart
index 97e5dbe7..dbef3153 100644
--- a/lib/src/command/cache.dart
+++ b/lib/src/command/cache.dart
@@ -4,48 +4,15 @@
 
 library pub.command.cache;
 
-import 'dart:async';
-import 'dart:convert';
-
 import '../command.dart';
-import '../exit_codes.dart' as exit_codes;
-import '../io.dart';
-import '../log.dart' as log;
+import 'cache_list.dart';
 
 /// Handles the `cache` pub command.
 class CacheCommand extends PubCommand {
-  String get description => "Inspect the system cache.";
-  String get usage => "pub cache list";
-  bool get hidden => true;
-  bool get requiresEntrypoint => false;
-  bool get takesArguments => true;
-
-  Future onRun() {
-    // TODO(rnystrom): Use subcommand for "list".
-    if (commandOptions.rest.length != 1) {
-      log.error('The cache command expects one argument.');
-      this.printUsage();
-      return flushThenExit(exit_codes.USAGE);
-    }
-
-    if ((commandOptions.rest[0] != 'list')) {
-      log.error('Unknown cache command "${commandOptions.rest[0]}".');
-      this.printUsage();
-      return flushThenExit(exit_codes.USAGE);
-    }
-
-    // TODO(keertip): Add flag to list packages from non default sources
-    var packagesObj = <String, Map>{};
+  String get description => "Work with the system cache.";
+  String get usage => "pub cache <subcommand>";
 
-    for (var package in cache.sources.defaultSource.getCachedPackages()) {
-
-      var packageInfo = packagesObj.putIfAbsent(package.name, () => {});
-      packageInfo[package.version.toString()] = {'location': package.dir};
-    }
-
-    // TODO(keertip): Add support for non-JSON format
-    // and check for --format flag
-    log.message(JSON.encode({'packages': packagesObj}));
-  }
+  final subcommands = {
+    "list": new CacheListCommand()
+  };
 }
-
diff --git a/lib/src/command/cache_list.dart b/lib/src/command/cache_list.dart
new file mode 100644
index 00000000..d774a6ba
--- /dev/null
+++ b/lib/src/command/cache_list.dart
@@ -0,0 +1,33 @@
+// Copyright (c) 2013, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+library pub.command.cache_list;
+
+import 'dart:async';
+import 'dart:convert';
+
+import '../command.dart';
+import '../log.dart' as log;
+
+/// Handles the `cache list` pub command.
+class CacheListCommand extends PubCommand {
+  String get description => "List packages in the system cache.";
+  String get usage => "pub cache list";
+  bool get hidden => true;
+  bool get requiresEntrypoint => false;
+
+  Future onRun() {
+    // TODO(keertip): Add flag to list packages from non default sources.
+    var packagesObj = <String, Map>{};
+
+    for (var package in cache.sources.defaultSource.getCachedPackages()) {
+      var packageInfo = packagesObj.putIfAbsent(package.name, () => {});
+      packageInfo[package.version.toString()] = {'location': package.dir};
+    }
+
+    // TODO(keertip): Add support for non-JSON format and check for --format
+    // flag.
+    log.message(JSON.encode({'packages': packagesObj}));
+  }
+}
diff --git a/lib/src/command/help.dart b/lib/src/command/help.dart
index 91181754..c1889ff2 100644
--- a/lib/src/command/help.dart
+++ b/lib/src/command/help.dart
@@ -7,9 +7,6 @@ library pub.command.help;
 import 'dart:async';
 
 import '../command.dart';
-import '../exit_codes.dart' as exit_codes;
-import '../io.dart';
-import '../log.dart' as log;
 
 /// Handles the `help` pub command.
 class HelpCommand extends PubCommand {
@@ -19,18 +16,39 @@ class HelpCommand extends PubCommand {
   bool get takesArguments => true;
 
   Future onRun() {
+    // Show the default help if no command was specified.
     if (commandOptions.rest.isEmpty) {
       PubCommand.printGlobalUsage();
-    } else {
-      var name = commandOptions.rest[0];
-      var command = PubCommand.commands[name];
-      if (command == null) {
-        log.error('Could not find a command named "$name".');
-        log.error('Run "pub help" to see available commands.');
-        return flushThenExit(exit_codes.USAGE);
+      return null;
+    }
+
+    // Walk the command tree to show help for the selected command or
+    // subcommand.
+    var commands = PubCommand.mainCommands;
+    var command = null;
+    var commandString = "pub";
+
+    for (var name in commandOptions.rest) {
+      if (commands.isEmpty) {
+        command.usageError(
+            'Command "$commandString" does not expect a subcommand.');
       }
 
-      command.printUsage();
+      if (commands[name] == null) {
+        if (command == null) {
+          PubCommand.usageErrorWithCommands(commands,
+              'Could not find a command named "$name".');
+        }
+
+        command.usageError(
+            'Could not find a subcommand named "$name" for "$commandString".');
+      }
+
+      command = commands[name];
+      commands = command.subcommands;
+      commandString += " $name";
     }
+
+    command.printUsage();
   }
 }
diff --git a/lib/src/command/lish.dart b/lib/src/command/lish.dart
index bd9c445e..d5e46763 100644
--- a/lib/src/command/lish.dart
+++ b/lib/src/command/lish.dart
@@ -10,7 +10,6 @@ import 'package:http/http.dart' as http;
 
 import '../command.dart';
 import '../directory_tree.dart';
-import '../exit_codes.dart' as exit_codes;
 import '../http.dart';
 import '../io.dart';
 import '../log.dart' as log;
@@ -94,9 +93,7 @@ class LishCommand extends PubCommand {
 
   Future onRun() {
     if (force && dryRun) {
-      log.error('Cannot use both --force and --dry-run.');
-      this.printUsage();
-      return flushThenExit(exit_codes.USAGE);
+      usageError('Cannot use both --force and --dry-run.');
     }
 
     var packageBytesFuture = entrypoint.packageFiles().then((files) {
@@ -144,15 +141,15 @@ class LishCommand extends PubCommand {
 
       if (dryRun) {
         var s = warnings.length == 1 ? '' : 's';
-        log.warning("Package has ${warnings.length} warning$s.");
+        log.warning("\nPackage has ${warnings.length} warning$s.");
         return false;
       }
 
-      var message = 'Looks great! Are you ready to upload your package';
+      var message = '\nLooks great! Are you ready to upload your package';
 
       if (!warnings.isEmpty) {
         var s = warnings.length == 1 ? '' : 's';
-        message = "Package has ${warnings.length} warning$s. Upload anyway";
+        message = "\nPackage has ${warnings.length} warning$s. Upload anyway";
       }
 
       return confirm(message).then((confirmed) {
diff --git a/lib/src/log.dart b/lib/src/log.dart
index f1726bba..0dcf088d 100644
--- a/lib/src/log.dart
+++ b/lib/src/log.dart
@@ -119,6 +119,13 @@ void write(Level level, message) {
   if (_loggers.isEmpty) showNormal();
 
   var lines = splitLines(message.toString());
+
+  // Discard a trailing newline. This is useful since StringBuffers often end
+  // up with an extra newline at the end from using [writeln].
+  if (lines.isNotEmpty && lines.last == "") {
+    lines.removeLast();
+  }
+
   var entry = new Entry(level, lines);
 
   var logFn = _loggers[level];
diff --git a/lib/src/solver/solve_report.dart b/lib/src/solver/solve_report.dart
index 68568782..53507de2 100644
--- a/lib/src/solver/solve_report.dart
+++ b/lib/src/solver/solve_report.dart
@@ -39,12 +39,6 @@ class _SolveReport {
 
   final _output = new StringBuffer();
 
-  /// To avoid emitting trailing newlines, we track if any are needed and only
-  /// emit then when text on the next line is about to be written.
-  // TODO(rnystrom): Move this into a separate class that wraps any StringSink
-  // with this logic.
-  int _pendingLines = 0;
-
   _SolveReport(this._sources, this._root, this._previousLockFile,
       this._result) {
     // Fill the map so we can use it later.
@@ -99,13 +93,13 @@ class _SolveReport {
     var removed = _previousLockFile.packages.keys.toSet();
     removed.removeAll(names);
     if (removed.isNotEmpty) {
-      _writeln("These packages are no longer being depended on:");
+      _output.writeln("These packages are no longer being depended on:");
       removed = removed.toList();
       removed.sort();
       removed.forEach(_reportPackage);
     }
 
-    log.message(_output.toString());
+    log.message(_output);
   }
 
   /// Displays a warning about the overrides currently in effect.
@@ -113,14 +107,14 @@ class _SolveReport {
     _output.clear();
 
     if (_result.overrides.isNotEmpty) {
-      _writeln("Warning: You are using these overridden dependencies:");
+      _output.writeln("Warning: You are using these overridden dependencies:");
       var overrides = _result.overrides.map((dep) => dep.name).toList();
       overrides.sort((a, b) => a.compareTo(b));
 
       overrides.forEach(
           (name) => _reportPackage(name, highlightOverride: false));
 
-      log.warning(_output.toString());
+      log.warning(_output);
     }
   }
 
@@ -149,39 +143,39 @@ class _SolveReport {
     //     < The package was downgraded from a higher version.
     //     * Any other change between the old and new package.
     if (isOverridden) {
-      _write(log.magenta("! "));
+      _output.write(log.magenta("! "));
     } else if (newId == null) {
-      _write(log.red("- "));
+      _output.write(log.red("- "));
     } else if (oldId == null) {
-      _write(log.green("+ "));
+      _output.write(log.green("+ "));
     } else if (!_descriptionsEqual(oldId, newId)) {
-      _write(log.cyan("* "));
+      _output.write(log.cyan("* "));
       changed = true;
     } else if (oldId.version < newId.version) {
-      _write(log.green("> "));
+      _output.write(log.green("> "));
       changed = true;
     } else if (oldId.version > newId.version) {
-      _write(log.cyan("< "));
+      _output.write(log.cyan("< "));
       changed = true;
     } else {
       // Unchanged.
-      _write("  ");
+      _output.write("  ");
     }
 
-    _write(log.bold(id.name));
-    _write(" ");
+    _output.write(log.bold(id.name));
+    _output.write(" ");
     _writeId(id);
 
     // If the package was upgraded, show what it was upgraded from.
     if (changed) {
-      _write(" (was ");
+      _output.write(" (was ");
       _writeId(oldId);
-      _write(")");
+      _output.write(")");
     }
 
     // Highlight overridden packages.
     if (isOverridden && highlightOverride) {
-      _write(" ${log.magenta('(overridden)')}");
+      _output.write(" ${log.magenta('(overridden)')}");
     }
 
     // See if there are any newer versions of the package that we were
@@ -211,10 +205,10 @@ class _SolveReport {
             "${pluralize('version', newerUnstable)} available)";
       }
 
-      if (message != null) _write(" ${log.cyan(message)}");
+      if (message != null) _output.write(" ${log.cyan(message)}");
     }
 
-    _writeln();
+    _output.writeln();
   }
 
   /// Returns `true` if [a] and [b] are from the same source and have the same
@@ -233,7 +227,7 @@ class _SolveReport {
 
   /// Writes a terse description of [id] (not including its name) to the output.
   void _writeId(PackageId id) {
-    _write(id.version);
+    _output.write(id.version);
 
     var source = null;
     if (_sources.contains(id.source)) {
@@ -242,26 +236,7 @@ class _SolveReport {
 
     if (source != null && source != _sources.defaultSource) {
       var description = source.formatDescription(_root.dir, id.description);
-      _write(" from ${id.source} $description");
-    }
-  }
-
-  /// Writes [obj] to the output.
-  void _write(Object obj) {
-    while (_pendingLines > 0) {
-      _output.writeln();
-      _pendingLines--;
+      _output.write(" from ${id.source} $description");
     }
-    _output.write(obj);
-  }
-
-  /// Writes [obj] (if not null) followed by a newline to the output.
-  ///
-  /// Doesn't actually immediately write a newline. Instead, it waits until
-  /// output is written on the next line. That way, trailing newlines aren't
-  /// displayed.
-  void _writeln([Object obj]) {
-    if (obj != null) _write(obj);
-    _pendingLines++;
   }
 }
\ No newline at end of file
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
index 4a63ca3b..c5f81c25 100644
--- a/lib/src/utils.dart
+++ b/lib/src/utils.dart
@@ -853,6 +853,12 @@ class ApplicationException implements Exception {
   String toString() => message;
 }
 
+/// A class for command usage exceptions.
+class UsageException extends ApplicationException {
+  UsageException(String message)
+      : super(message);
+}
+
 /// An class for exceptions where a package could not be found in a [Source].
 ///
 /// The source is responsible for wrapping its internal exceptions in this so
diff --git a/test/lish/force_cannot_be_combined_with_dry_run_test.dart b/test/lish/force_cannot_be_combined_with_dry_run_test.dart
index 26231e3e..c436ff24 100644
--- a/test/lish/force_cannot_be_combined_with_dry_run_test.dart
+++ b/test/lish/force_cannot_be_combined_with_dry_run_test.dart
@@ -14,7 +14,16 @@ main() {
 
   integration('--force cannot be combined with --dry-run', () {
     schedulePub(args: ['lish', '--force', '--dry-run'],
-        error: "Cannot use both --force and --dry-run.",
+        error: """
+          Cannot use both --force and --dry-run.
+          
+          Usage: pub publish [options]
+          -h, --help       Print usage information for this command.
+          -n, --dry-run    Validate but do not publish the package.
+          -f, --force      Publish without confirmation if there are no errors.
+              --server     The package server to which to upload this package.
+                           (defaults to "https://pub.dartlang.org")
+          """,
         exitCode: exit_codes.USAGE);
   });
 }
diff --git a/test/lish/package_creation_provides_an_error_test.dart b/test/lish/package_creation_provides_an_error_test.dart
index d747b3be..a1c134d1 100644
--- a/test/lish/package_creation_provides_an_error_test.dart
+++ b/test/lish/package_creation_provides_an_error_test.dart
@@ -7,7 +7,6 @@ import 'dart:convert';
 import 'package:scheduled_test/scheduled_test.dart';
 import 'package:scheduled_test/scheduled_server.dart';
 
-import '../../lib/src/exit_codes.dart' as exit_codes;
 import '../descriptor.dart' as d;
 import '../test_pub.dart';
 import 'utils.dart';
diff --git a/test/pub_cache_test.dart b/test/pub_cache_test.dart
index 91bc4a0c..44831661 100644
--- a/test/pub_cache_test.dart
+++ b/test/pub_cache_test.dart
@@ -17,30 +17,6 @@ main() {
         "pub.dartlang.org", package);
   }
 
-  integration('running pub cache displays error message', () {
-    schedulePub(args: ['cache'],
-        output: '''
-          Inspect the system cache.
-
-          Usage: pub cache list
-          -h, --help    Print usage information for this command.
-          ''',
-        error: 'The cache command expects one argument.',
-        exitCode: 64);
-  });
-
-  integration('running pub cache foo displays error message', () {
-    schedulePub(args: ['cache' ,'foo'],
-        output: '''
-          Inspect the system cache.
-
-          Usage: pub cache list
-          -h, --help    Print usage information for this command.
-          ''',
-        error: 'Unknown cache command "foo".',
-        exitCode: 64);
-  });
-
   integration('running pub cache list when there is no cache', () {
     schedulePub(args: ['cache', 'list'], output: '{"packages":{}}');
   });
diff --git a/test/pub_test.dart b/test/pub_test.dart
index 336e125d..8c9e6525 100644
--- a/test/pub_test.dart
+++ b/test/pub_test.dart
@@ -6,12 +6,13 @@ library pub_tests;
 
 import 'package:scheduled_test/scheduled_test.dart';
 
+import '../lib/src/exit_codes.dart' as exit_codes;
 import 'test_pub.dart';
 
 final USAGE_STRING = """
     Pub is a package manager for Dart.
 
-    Usage: pub command [arguments]
+    Usage: pub <command> [arguments]
 
     Global options:
     -h, --help            Print this usage information.
@@ -27,7 +28,7 @@ final USAGE_STRING = """
     -v, --verbose         Shortcut for "--verbosity=all".
 
     Available commands:
-      build      Copy and compile all Dart entrypoints in the 'web' directory.
+      build      Apply transformers to build a package.
       get        Get the current package's dependencies.
       help       Display help information for Pub.
       publish    Publish the current package to pub.dartlang.org.
@@ -80,6 +81,21 @@ main() {
     ''');
   });
 
+  integration('running pub with --help after a command with subcommands shows '
+              'command usage', () {
+    schedulePub(args: ['cache', '--help'],
+        output: '''
+          Work with the system cache.
+          
+          Usage: pub cache <subcommand>
+          -h, --help    Print usage information for this command.
+          
+          Available subcommands:
+            list   List packages in the system cache.
+     ''');
+  });
+
+
   integration('running pub with just --version displays version', () {
     schedulePub(args: ['--version'], output: VERSION_STRING);
   });
@@ -88,9 +104,32 @@ main() {
     schedulePub(args: ['quylthulg'],
         error: '''
         Could not find a command named "quylthulg".
-        Run "pub help" to see available commands.
+   
+        Available commands:
+          build      Apply transformers to build a package.
+          get        Get the current package's dependencies.
+          help       Display help information for Pub.
+          publish    Publish the current package to pub.dartlang.org.
+          serve      Run a local web development server.
+          upgrade    Upgrade the current package's dependencies to latest versions.
+          uploader   Manage uploaders for a package on pub.dartlang.org.
+          version    Print pub version.
+        ''',
+        exitCode: exit_codes.USAGE);
+  });
+
+  integration('an unknown subcommand displays an error message', () {
+    schedulePub(args: ['cache', 'quylthulg'],
+        error: '''
+        Could not find a subcommand named "quylthulg" for "pub cache".
+   
+        Usage: pub cache <subcommand>
+        -h, --help    Print usage information for this command.
+        
+        Available subcommands:
+          list   List packages in the system cache.
         ''',
-        exitCode: 64);
+        exitCode: exit_codes.USAGE);
   });
 
   integration('an unknown option displays an error message', () {
@@ -99,7 +138,7 @@ main() {
         Could not find an option named "blorf".
         Run "pub help" to see available options.
         ''',
-        exitCode: 64);
+        exitCode: exit_codes.USAGE);
   });
 
   integration('an unknown command option displays an error message', () {
@@ -110,21 +149,32 @@ main() {
         Could not find an option named "blorf".
         Run "pub help" to see available options.
         ''',
-        exitCode: 64);
+        exitCode: exit_codes.USAGE);
   });
 
   integration('an unexpected argument displays an error message', () {
     schedulePub(args: ['version', 'unexpected'],
-        output: '''
-        Print pub version.
+        error: '''
+        Command "version" does not take any arguments.
 
         Usage: pub version
          -h, --help    Print usage information for this command.
         ''',
+        exitCode: exit_codes.USAGE);
+  });
+
+  integration('a missing subcommand displays an error message', () {
+    schedulePub(args: ['cache'],
         error: '''
-        Command "version" does not take any arguments.
+        Missing subcommand for "pub cache".
+
+        Usage: pub cache <subcommand>
+        -h, --help    Print usage information for this command.
+
+        Available subcommands:
+          list   List packages in the system cache.
         ''',
-        exitCode: 64);
+        exitCode: exit_codes.USAGE);
   });
 
   group('help', () {
@@ -157,15 +207,58 @@ main() {
             ''');
     });
 
+    integration('shows help for a subcommand', () {
+      schedulePub(args: ['help', 'cache', 'list'],
+          output: '''
+            List packages in the system cache.
+
+            Usage: pub cache list
+            -h, --help    Print usage information for this command.
+            ''');
+    });
+
     integration('an unknown help command displays an error message', () {
       schedulePub(args: ['help', 'quylthulg'],
           error: '''
             Could not find a command named "quylthulg".
-            Run "pub help" to see available commands.
+       
+            Available commands:
+              build      Apply transformers to build a package.
+              get        Get the current package's dependencies.
+              help       Display help information for Pub.
+              publish    Publish the current package to pub.dartlang.org.
+              serve      Run a local web development server.
+              upgrade    Upgrade the current package's dependencies to latest versions.
+              uploader   Manage uploaders for a package on pub.dartlang.org.
+              version    Print pub version.
+            ''',
+            exitCode: exit_codes.USAGE);
+    });
+
+    integration('an unknown help subcommand displays an error message', () {
+      schedulePub(args: ['help', 'cache', 'quylthulg'],
+          error: '''
+            Could not find a subcommand named "quylthulg" for "pub cache".
+       
+            Usage: pub cache <subcommand>
+            -h, --help    Print usage information for this command.
+
+            Available subcommands:
+              list   List packages in the system cache.
             ''',
-            exitCode: 64);
+            exitCode: exit_codes.USAGE);
     });
 
+    integration('an unexpected help subcommand displays an error message', () {
+      schedulePub(args: ['help', 'version', 'badsubcommand'],
+          error: '''
+            Command "pub version" does not expect a subcommand.
+            
+            Usage: pub version
+            -h, --help    Print usage information for this command.
+            ''',
+            exitCode: exit_codes.USAGE);
+    });
   });
 
   group('version', () {
-- 
GitLab