Add new command `pub outdated` (#2315)

This command intends to make it easier to get an overview of your dependencies.

Support for marking null-safe dependencies is not included in this commit
diff --git a/lib/src/command/outdated.dart b/lib/src/command/outdated.dart
new file mode 100644
index 0000000..9b65a85
--- /dev/null
+++ b/lib/src/command/outdated.dart
@@ -0,0 +1,444 @@
+// Copyright (c) 2020, 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.
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+import 'dart:math';
+
+import 'package:pub_semver/pub_semver.dart';
+import 'package:meta/meta.dart';
+
+import '../command.dart';
+import '../entrypoint.dart';
+import '../log.dart' as log;
+import '../package.dart';
+import '../package_name.dart';
+import '../pubspec.dart';
+import '../solver.dart';
+import '../source.dart';
+import '../source/hosted.dart';
+
+class OutdatedCommand extends PubCommand {
+  @override
+  String get name => 'outdated';
+  @override
+  String get description =>
+      'Analyze your dependencies to find which ones can be upgraded.';
+  @override
+  String get invocation => 'pub outdated [options]';
+  @override
+  String get docUrl => 'https://dart.dev/tools/pub/cmd/pub-outdated';
+
+  OutdatedCommand() {
+    argParser.addOption('format',
+        help: 'Defines how the output should be formatted. Defaults to color '
+            'when connected to a terminal, and no-color otherwise.',
+        valueHelp: 'FORMAT',
+        allowed: ['color', 'no-color', 'json']);
+
+    argParser.addFlag('up-to-date',
+        defaultsTo: false,
+        help: 'Include dependencies that are already at the latest version');
+
+    argParser.addFlag('pre-releases',
+        defaultsTo: false,
+        help: 'Include pre-releases when reporting latest version');
+
+    argParser.addFlag(
+      'dev-dependencies',
+      defaultsTo: true,
+      help: 'When true take dev-dependencies into account when resolving.',
+    );
+
+    argParser.addOption('mark',
+        help: 'Highlight packages with some property in the report.',
+        valueHelp: 'OPTION',
+        allowed: ['outdated', 'none'],
+        defaultsTo: 'outdated');
+  }
+
+  @override
+  Future run() async {
+    entrypoint.assertUpToDate();
+
+    final includeDevDependencies = argResults['dev-dependencies'];
+
+    final upgradePubspec = includeDevDependencies
+        ? entrypoint.root.pubspec
+        : _stripDevDependencies(entrypoint.root.pubspec);
+
+    var resolvablePubspec = _stripVersionConstraints(upgradePubspec);
+
+    SolveResult upgradableSolveResult;
+    SolveResult resolvableSolveResult;
+
+    await log.warningsOnlyUnlessTerminal(
+      () => log.spinner(
+        'Resolving',
+        () async {
+          upgradableSolveResult = await resolveVersions(
+            SolveType.UPGRADE,
+            cache,
+            Package.inMemory(upgradePubspec),
+          );
+
+          resolvableSolveResult = await resolveVersions(
+            SolveType.UPGRADE,
+            cache,
+            Package.inMemory(resolvablePubspec),
+          );
+        },
+      ),
+    );
+
+    Future<_PackageDetails> analyzeDependency(PackageRef packageRef) async {
+      final name = packageRef.name;
+      final current = (entrypoint.lockFile?.packages ?? {})[name]?.version;
+      final source = packageRef.source;
+      final available = (await cache.source(source).doGetVersions(packageRef))
+          .map((id) => id.version)
+          .toList()
+            ..sort(argResults['pre-releases'] ? null : Version.prioritize);
+      final upgradable = upgradableSolveResult.packages
+          .firstWhere((id) => id.name == name, orElse: () => null)
+          ?.version;
+      final resolvable = resolvableSolveResult.packages
+          .firstWhere((id) => id.name == name, orElse: () => null)
+          ?.version;
+      final latest = available.last;
+      final description = packageRef.description;
+      return _PackageDetails(
+          name,
+          await _describeVersion(name, source, description, current),
+          await _describeVersion(name, source, description, upgradable),
+          await _describeVersion(name, source, description, resolvable),
+          await _describeVersion(name, source, description, latest),
+          _kind(name, entrypoint));
+    }
+
+    final rows = <_PackageDetails>[];
+
+    final immediateDependencies = entrypoint.root.immediateDependencies.values;
+
+    for (final packageRange in immediateDependencies) {
+      rows.add(await analyzeDependency(packageRange.toRef()));
+    }
+
+    // Now add transitive dependencies:
+    final visited = <String>{
+      entrypoint.root.name,
+      ...immediateDependencies.map((d) => d.name)
+    };
+    for (final id in [
+      if (includeDevDependencies) ...entrypoint.lockFile.packages.values,
+      ...upgradableSolveResult.packages,
+      ...resolvableSolveResult.packages
+    ]) {
+      final name = id.name;
+      if (!visited.add(name)) continue;
+      rows.add(await analyzeDependency(id.toRef()));
+    }
+
+    if (!argResults['up-to-date']) {
+      rows.retainWhere(
+          (r) => (r.current ?? r.upgradable)?.version != r.latest?.version);
+    }
+    if (!includeDevDependencies) {
+      rows.removeWhere((r) => r.kind == _DependencyKind.dev);
+    }
+
+    rows.sort();
+
+    if (argResults['format'] == 'json') {
+      await _outputJson(rows);
+    } else {
+      final useColors = argResults['format'] == 'color' ||
+          (!argResults.wasParsed('format') && stdin.hasTerminal);
+      final marker = {
+        'outdated': oudatedMarker,
+        'none': noneMarker,
+      }[argResults['mark']];
+      await _outputHuman(
+        rows,
+        marker,
+        useColors: useColors,
+        includeDevDependencies: includeDevDependencies,
+      );
+    }
+  }
+
+  /// Retrieves the pubspec of package [name] in [version] from [source].
+  Future<Pubspec> _describeVersion(
+      String name, Source source, dynamic description, Version version) async {
+    return version == null
+        ? null
+        : await cache
+            .source(source)
+            .describe(PackageId(name, source, version, description));
+  }
+}
+
+Pubspec _stripDevDependencies(Pubspec original) {
+  return Pubspec(
+    original.name,
+    sdkConstraints: original.sdkConstraints,
+    dependencies: original.dependencies.values,
+  );
+}
+
+/// Returns new pubspec with the same dependencies as [original] but with no
+/// version constraints on hosted packages.
+Pubspec _stripVersionConstraints(Pubspec original) {
+  List<PackageRange> _unconstrained(Map<String, PackageRange> constrained) {
+    final result = <PackageRange>[];
+    for (final name in constrained.keys) {
+      final packageRange = constrained[name];
+      var unconstrainedRange = packageRange;
+      if (packageRange.source is HostedSource) {
+        unconstrainedRange = PackageRange(
+            packageRange.name,
+            packageRange.source,
+            VersionConstraint.any,
+            packageRange.description,
+            features: packageRange.features);
+      }
+      result.add(unconstrainedRange);
+    }
+    return result;
+  }
+
+  return Pubspec(
+    original.name,
+    sdkConstraints: original.sdkConstraints,
+    dependencies: _unconstrained(original.dependencies),
+    devDependencies: _unconstrained(original.devDependencies),
+    // TODO(sigurdm): consider dependency overrides.
+  );
+}
+
+Future<void> _outputJson(List<_PackageDetails> rows) async {
+  log.message(JsonEncoder.withIndent('  ')
+      .convert({'packages': rows.map((row) => row.toJson()).toList()}));
+}
+
+Future<void> _outputHuman(List<_PackageDetails> rows,
+    Future<List<_FormattedString>> Function(_PackageDetails) marker,
+    {@required bool useColors, @required bool includeDevDependencies}) async {
+  if (rows.isEmpty) {
+    log.message('Found no outdated packages');
+    return;
+  }
+  final directRows = rows.where((row) => row.kind == _DependencyKind.direct);
+  final devRows = rows.where((row) => row.kind == _DependencyKind.dev);
+  final transitiveRows =
+      rows.where((row) => row.kind == _DependencyKind.transitive);
+
+  final formattedRows = <List<_FormattedString>>[
+    ['Package', 'Current', 'Upgradable', 'Resolvable', 'Latest']
+        .map((s) => _format(s, log.bold))
+        .toList(),
+    [
+      directRows.isEmpty
+          ? _raw('dependencies: all up-to-date')
+          : _format('dependencies', log.bold),
+    ],
+    ...await Future.wait(directRows.map(marker)),
+    if (includeDevDependencies)
+      [
+        devRows.isEmpty
+            ? _raw('\ndev_dependencies: all up-to-date')
+            : _format('\ndev_dependencies', log.bold),
+      ],
+    ...await Future.wait(devRows.map(marker)),
+    [
+      transitiveRows.isEmpty
+          ? _raw('\ntransitive dependencies: all up-to-date')
+          : _format('\ntransitive dependencies', log.bold)
+    ],
+    ...await Future.wait(transitiveRows.map(marker)),
+  ];
+
+  final columnWidths = <int, int>{};
+  for (var i = 0; i < formattedRows.length; i++) {
+    if (formattedRows[i].length > 1) {
+      for (var j = 0; j < formattedRows[i].length; j++) {
+        final currentMaxWidth = columnWidths[j] ?? 0;
+        columnWidths[j] = max(
+            formattedRows[i][j].computeLength(useColors: useColors),
+            currentMaxWidth);
+      }
+    }
+  }
+
+  for (final row in formattedRows) {
+    final b = StringBuffer();
+    for (var j = 0; j < row.length; j++) {
+      b.write(row[j].formatted(useColors: useColors));
+      b.write(' ' *
+          ((columnWidths[j] + 2) - row[j].computeLength(useColors: useColors)));
+    }
+    log.message(b.toString());
+  }
+
+  var upgradable = rows
+      .where((row) =>
+          row.current != null &&
+          row.upgradable != null &&
+          row.current != row.upgradable)
+      .length;
+
+  var notAtResolvable = rows
+      .where(
+          (row) => row.resolvable != null && row.upgradable != row.resolvable)
+      .length;
+
+  if (upgradable != 0) {
+    if (upgradable == 1) {
+      log.message('1 upgradable dependency is locked (in pubspec.lock) to '
+          'an older version.\n'
+          'To update it, use `pub upgrade`.');
+    } else {
+      log.message(
+          '\n$upgradable upgradable dependencies are locked (in pubspec.lock) '
+          'to older versions.\n'
+          'To update these dependencies, use `pub upgrade`.');
+    }
+  }
+
+  if (notAtResolvable != 0) {
+    if (notAtResolvable == 1) {
+      log.message('\n1 dependency is constrained to a '
+          'version that is older than a resolvable version.\n'
+          'To update it, edit pubspec.yaml.');
+    } else {
+      log.message('\n$notAtResolvable  dependencies are constrained to '
+          'versions that are older than a resolvable version.\n'
+          'To update these dependencies, edit pubspec.yaml.');
+    }
+  }
+}
+
+Future<List<_FormattedString>> oudatedMarker(
+    _PackageDetails packageDetails) async {
+  final cols = [_FormattedString(packageDetails.name)];
+  Version previous;
+  for (final pubspec in [
+    packageDetails.current,
+    packageDetails.upgradable,
+    packageDetails.resolvable,
+    packageDetails.latest
+  ]) {
+    final version = pubspec?.version;
+    if (version == null) {
+      cols.add(_raw('-'));
+    } else {
+      final isLatest = version == packageDetails.latest.version;
+      String Function(String) color;
+      if (isLatest) {
+        color = version == previous ? color = log.gray : null;
+      } else {
+        color = log.red;
+      }
+      final prefix = isLatest ? '' : '*';
+      cols.add(_format(version?.toString() ?? '-', color, prefix: prefix));
+    }
+    previous = version;
+  }
+  return cols;
+}
+
+Future<List<_FormattedString>> noneMarker(
+    _PackageDetails packageDetails) async {
+  return [
+    _FormattedString(packageDetails.name),
+    ...[
+      packageDetails.current,
+      packageDetails.upgradable,
+      packageDetails.resolvable,
+      packageDetails.latest,
+    ].map((p) => _raw(p?.version?.toString() ?? '-'))
+  ];
+}
+
+class _PackageDetails implements Comparable<_PackageDetails> {
+  final String name;
+  final Pubspec current;
+  final Pubspec upgradable;
+  final Pubspec resolvable;
+  final Pubspec latest;
+  final _DependencyKind kind;
+
+  _PackageDetails(this.name, this.current, this.upgradable, this.resolvable,
+      this.latest, this.kind);
+
+  @override
+  int compareTo(_PackageDetails other) {
+    if (kind != other.kind) {
+      return kind.index.compareTo(other.kind.index);
+    }
+    return name.compareTo(other.name);
+  }
+
+  Map<String, Object> toJson() {
+    return {
+      'package': name,
+      'current': current?.version?.toString(),
+      'upgradable': upgradable?.version?.toString(),
+      'resolvable': resolvable?.version?.toString(),
+      'latest': latest?.version?.toString(),
+    };
+  }
+}
+
+_DependencyKind _kind(String name, Entrypoint entrypoint) {
+  if (entrypoint.root.dependencies.containsKey(name)) {
+    return _DependencyKind.direct;
+  } else if (entrypoint.root.devDependencies.containsKey(name)) {
+    return _DependencyKind.dev;
+  } else {
+    return _DependencyKind.transitive;
+  }
+}
+
+enum _DependencyKind {
+  /// Direct non-dev dependencies.
+  direct,
+
+  /// Direct dev dependencies.
+  dev,
+
+  /// Transitive dependencies.
+  transitive
+}
+
+_FormattedString _format(String value, Function(String) format, {prefix = ''}) {
+  return _FormattedString(value, format: format, prefix: prefix);
+}
+
+_FormattedString _raw(String value) => _FormattedString(value);
+
+class _FormattedString {
+  final String value;
+
+  /// Should apply the ansi codes to present this string.
+  final String Function(String) _format;
+
+  /// A prefix for marking this string if colors are not used.
+  final String _prefix;
+
+  _FormattedString(this.value, {String Function(String) format, prefix = ''})
+      : _format = format ?? _noFormat,
+        _prefix = prefix;
+
+  String formatted({@required bool useColors}) {
+    return useColors ? _format(value) : _prefix + value;
+  }
+
+  int computeLength({@required bool useColors}) {
+    return useColors ? value.length : _prefix.length + value.length;
+  }
+
+  static String _noFormat(String x) => x;
+}
diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart
index 6335cee..ebe270a 100644
--- a/lib/src/command_runner.dart
+++ b/lib/src/command_runner.dart
@@ -19,6 +19,7 @@
 import 'command/lish.dart';
 import 'command/list_package_dirs.dart';
 import 'command/logout.dart';
+import 'command/outdated.dart';
 import 'command/run.dart';
 import 'command/serve.dart';
 import 'command/upgrade.dart';
@@ -102,6 +103,7 @@
     addCommand(GetCommand());
     addCommand(ListPackageDirsCommand());
     addCommand(LishCommand());
+    addCommand(OutdatedCommand());
     addCommand(RunCommand());
     addCommand(ServeCommand());
     addCommand(UpgradeCommand());
diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart
index ac2f7f9..b4d66f5 100644
--- a/lib/src/entrypoint.dart
+++ b/lib/src/entrypoint.dart
@@ -213,8 +213,16 @@
       {List<String> useLatest,
       bool dryRun = false,
       bool precompile = false}) async {
-    var result = await resolveVersions(type, cache, root,
-        lockFile: lockFile, useLatest: useLatest);
+    var result = await log.progress(
+      'Resolving dependencies',
+      () => resolveVersions(
+        type,
+        cache,
+        root,
+        lockFile: lockFile,
+        useLatest: useLatest,
+      ),
+    );
 
     // Log once about all overridden packages.
     if (warnAboutPreReleaseSdkOverrides && result.pubspecs != null) {
diff --git a/lib/src/executable.dart b/lib/src/executable.dart
index 2923822..1d8448e 100644
--- a/lib/src/executable.dart
+++ b/lib/src/executable.dart
@@ -61,48 +61,46 @@
   // normal pub output that may be shown when recompiling snapshots if we are
   // not attached to a terminal. This is to not pollute stdout when the output
   // of `pub run` is piped somewhere.
-  if (log.verbosity == log.Verbosity.NORMAL && !stdout.hasTerminal) {
-    log.verbosity = log.Verbosity.WARNING;
-  }
+  return await log.warningsOnlyUnlessTerminal(() async {
+    // Uncached packages are run from source.
+    if (snapshotPath != null) {
+      // Since we don't access the package graph, this doesn't happen
+      // automatically.
+      entrypoint.assertUpToDate();
 
-  // Uncached packages are run from source.
-  if (snapshotPath != null) {
-    // Since we don't access the package graph, this doesn't happen
-    // automatically.
-    entrypoint.assertUpToDate();
-
-    var result = await _runOrCompileSnapshot(snapshotPath, args,
-        packagesFile: packagesFile, checked: checked, recompile: recompile);
-    if (result != null) return result;
-  }
-
-  // If the command has a path separator, then it's a path relative to the
-  // root of the package. Otherwise, it's implicitly understood to be in
-  // "bin".
-  if (p.split(executable).length == 1) executable = p.join('bin', executable);
-
-  var executablePath = await _executablePath(entrypoint, package, executable);
-
-  if (executablePath == null) {
-    var message = 'Could not find ${log.bold(executable)}';
-    if (entrypoint.isGlobal || package != entrypoint.root.name) {
-      message += ' in package ${log.bold(package)}';
+      var result = await _runOrCompileSnapshot(snapshotPath, args,
+          packagesFile: packagesFile, checked: checked, recompile: recompile);
+      if (result != null) return result;
     }
-    log.error('$message.');
-    return exit_codes.NO_INPUT;
-  }
 
-  // We use an absolute path here not because the VM insists but because it's
-  // helpful for the subprocess to be able to spawn Dart with
-  // Platform.executableArguments and have that work regardless of the working
-  // directory.
-  var packageConfig = p.toUri(p.absolute(packagesFile));
+    // If the command has a path separator, then it's a path relative to the
+    // root of the package. Otherwise, it's implicitly understood to be in
+    // "bin".
+    if (p.split(executable).length == 1) executable = p.join('bin', executable);
 
-  await isolate.runUri(p.toUri(executablePath), args.toList(), null,
-      checked: checked,
-      automaticPackageResolution: packageConfig == null,
-      packageConfig: packageConfig);
-  return exitCode;
+    var executablePath = await _executablePath(entrypoint, package, executable);
+
+    if (executablePath == null) {
+      var message = 'Could not find ${log.bold(executable)}';
+      if (entrypoint.isGlobal || package != entrypoint.root.name) {
+        message += ' in package ${log.bold(package)}';
+      }
+      log.error('$message.');
+      return exit_codes.NO_INPUT;
+    }
+
+    // We use an absolute path here not because the VM insists but because it's
+    // helpful for the subprocess to be able to spawn Dart with
+    // Platform.executableArguments and have that work regardless of the working
+    // directory.
+    var packageConfig = p.toUri(p.absolute(packagesFile));
+
+    await isolate.runUri(p.toUri(executablePath), args.toList(), null,
+        checked: checked,
+        automaticPackageResolution: packageConfig == null,
+        packageConfig: packageConfig);
+    return exitCode;
+  });
 }
 
 /// Returns the full path the VM should use to load the executable at [path].
diff --git a/lib/src/global_packages.dart b/lib/src/global_packages.dart
index d832d89..8666ee4 100644
--- a/lib/src/global_packages.dart
+++ b/lib/src/global_packages.dart
@@ -180,7 +180,8 @@
     // being available, report that as a [dataError].
     SolveResult result;
     try {
-      result = await resolveVersions(SolveType.GET, cache, root);
+      result = await log.progress('Resolving dependencies',
+          () => resolveVersions(SolveType.GET, cache, root));
     } on SolveFailure catch (error) {
       for (var incompatibility
           in error.incompatibility.externalIncompatibilities) {
diff --git a/lib/src/log.dart b/lib/src/log.dart
index 446a9a9..893759c 100644
--- a/lib/src/log.dart
+++ b/lib/src/log.dart
@@ -364,6 +364,21 @@
   stderr.writeln('---- End log transcript ----');
 }
 
+/// Filter out normal pub output when not attached to a terminal
+///
+/// Unless the user has overriden the verbosity,
+///
+/// This is useful to not pollute stdout when the output is piped somewhere.
+Future<T> warningsOnlyUnlessTerminal<T>(FutureOr<T> Function() callback) async {
+  final oldVerbosity = verbosity;
+  if (verbosity == Verbosity.NORMAL && !stdout.hasTerminal) {
+    verbosity = Verbosity.WARNING;
+  }
+  final result = await callback();
+  verbosity = oldVerbosity;
+  return result;
+}
+
 /// Prints [message] then displays an updated elapsed time until the future
 /// returned by [callback] completes.
 ///
@@ -379,6 +394,17 @@
   return callback().whenComplete(progress.stop);
 }
 
+/// Like [progress] but erases the message once done.
+Future<T> spinner<T>(String message, Future<T> Function() callback) {
+  _stopProgress();
+
+  var progress = Progress(message);
+  _animatedProgress = progress;
+  return callback().whenComplete(() {
+    progress.stopAndClear();
+  });
+}
+
 /// Stops animating the running progress indicator, if currently running.
 void _stopProgress() {
   if (_animatedProgress != null) _animatedProgress.stopAnimating();
diff --git a/lib/src/progress.dart b/lib/src/progress.dart
index 50f774f..d05c361 100644
--- a/lib/src/progress.dart
+++ b/lib/src/progress.dart
@@ -75,6 +75,26 @@
     stdout.writeln();
   }
 
+  /// Erases the progress message and stops the progress indicator.
+  Future<void> stopAndClear() async {
+    _stopwatch.stop();
+
+    if (_timer != null) {
+      stdout.write('\b' * (_message.length + '... '.length + _timeLength));
+    }
+
+    // Always log the final time as [log.fine] because for the most part normal
+    // users don't care about the precise time information beyond what's shown
+    // in the animation.
+    log.fine('$_message finished $_time.');
+
+    // If we were animating, print one final update to show the user the final
+    // time.
+    if (_timer == null) return;
+    _timer.cancel();
+    _timer = null;
+  }
+
   /// Stop animating the progress indicator.
   ///
   /// This will continue running the stopwatch so that the full time can be
diff --git a/lib/src/pubspec.dart b/lib/src/pubspec.dart
index 4786470..080358e 100644
--- a/lib/src/pubspec.dart
+++ b/lib/src/pubspec.dart
@@ -474,7 +474,8 @@
       Iterable<PackageRange> devDependencies,
       Iterable<PackageRange> dependencyOverrides,
       Map fields,
-      SourceRegistry sources})
+      SourceRegistry sources,
+      Map<String, VersionConstraint> sdkConstraints})
       : _version = version,
         _dependencies = dependencies == null
             ? null
@@ -485,7 +486,8 @@
         _dependencyOverrides = dependencyOverrides == null
             ? null
             : Map.fromIterable(dependencyOverrides, key: (range) => range.name),
-        _sdkConstraints = UnmodifiableMapView({'dart': VersionConstraint.any}),
+        _sdkConstraints = sdkConstraints ??
+            UnmodifiableMapView({'dart': VersionConstraint.any}),
         _includeDefaultSdkConstraint = false,
         fields = fields == null ? YamlMap() : YamlMap.wrap(fields),
         _sources = sources;
diff --git a/lib/src/solver.dart b/lib/src/solver.dart
index c3bfb84..d7230c1 100644
--- a/lib/src/solver.dart
+++ b/lib/src/solver.dart
@@ -5,7 +5,6 @@
 import 'dart:async';
 
 import 'lock_file.dart';
-import 'log.dart' as log;
 import 'package.dart';
 import 'solver/result.dart';
 import 'solver/type.dart';
@@ -29,9 +28,11 @@
 Future<SolveResult> resolveVersions(
     SolveType type, SystemCache cache, Package root,
     {LockFile lockFile, Iterable<String> useLatest}) {
-  return log.progress('Resolving dependencies', () {
-    return VersionSolver(type, cache, root, lockFile ?? LockFile.empty(),
-            useLatest ?? const [])
-        .solve();
-  });
+  return VersionSolver(
+    type,
+    cache,
+    root,
+    lockFile ?? LockFile.empty(),
+    useLatest ?? const [],
+  ).solve();
 }
diff --git a/test/golden_file.dart b/test/golden_file.dart
new file mode 100644
index 0000000..255706a
--- /dev/null
+++ b/test/golden_file.dart
@@ -0,0 +1,34 @@
+// Copyright (c) 2018, 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.
+
+import 'dart:io';
+
+import 'package:path/path.dart' as path;
+import 'package:test/test.dart';
+
+/// Will test [actual] against the contests of the file at [goldenFilePath].
+///
+/// If the file doesn't exist, the file is instead created containing [actual].
+void expectMatchesGoldenFile(String actual, String goldenFilePath) {
+  var goldenFile = File(goldenFilePath);
+  if (goldenFile.existsSync()) {
+    expect(
+        actual, equals(goldenFile.readAsStringSync().replaceAll('\r\n', '\n')),
+        reason: 'goldenFilePath: "$goldenFilePath"');
+  } else {
+    // This enables writing the updated file when run in otherwise hermetic
+    // settings.
+    //
+    // This is to make updating the golden files easier in a bazel environment
+    // See https://docs.bazel.build/versions/2.0.0/user-manual.html#run .
+    final workspaceDirectory =
+        Platform.environment['BUILD_WORKSPACE_DIRECTORY'];
+    if (workspaceDirectory != null) {
+      goldenFile = File(path.join(workspaceDirectory, goldenFilePath));
+    }
+    goldenFile
+      ..createSync(recursive: true)
+      ..writeAsStringSync(actual);
+  }
+}
diff --git a/test/outdated/goldens/newer_versions.txt b/test/outdated/goldens/newer_versions.txt
new file mode 100644
index 0000000..7322203
--- /dev/null
+++ b/test/outdated/goldens/newer_versions.txt
@@ -0,0 +1,139 @@
+$ pub outdated --format=json
+Resolving...
+{
+  "packages": [
+    {
+      "package": "foo",
+      "current": "1.2.3",
+      "upgradable": "1.3.0",
+      "resolvable": "2.0.0",
+      "latest": "3.0.0"
+    },
+    {
+      "package": "builder",
+      "current": "1.2.3",
+      "upgradable": "1.3.0",
+      "resolvable": "2.0.0",
+      "latest": "2.0.0"
+    },
+    {
+      "package": "transitive",
+      "current": "1.2.3",
+      "upgradable": "1.3.0",
+      "resolvable": "1.3.0",
+      "latest": "2.0.0"
+    },
+    {
+      "package": "transitive2",
+      "current": null,
+      "upgradable": null,
+      "resolvable": "1.0.0",
+      "latest": "1.0.0"
+    },
+    {
+      "package": "transitive3",
+      "current": null,
+      "upgradable": null,
+      "resolvable": "1.0.0",
+      "latest": "1.0.0"
+    }
+  ]
+}
+
+$ pub outdated --format=no-color
+Resolving...
+Package      Current  Upgradable  Resolvable  Latest  
+dependencies 
+foo          *1.2.3   *1.3.0      *2.0.0      3.0.0   
+
+dev_dependencies
+builder      *1.2.3   *1.3.0      2.0.0       2.0.0   
+
+transitive dependencies
+transitive   *1.2.3   *1.3.0      *1.3.0      2.0.0   
+transitive2  -        -           1.0.0       1.0.0   
+transitive3  -        -           1.0.0       1.0.0   
+
+3 upgradable dependencies are locked (in pubspec.lock) to older versions.
+To update these dependencies, use `pub upgrade`.
+
+4  dependencies are constrained to versions that are older than a resolvable version.
+To update these dependencies, edit pubspec.yaml.
+
+$ pub outdated --format=no-color --mark=none
+Resolving...
+Package      Current  Upgradable  Resolvable  Latest  
+dependencies 
+foo          1.2.3    1.3.0       2.0.0       3.0.0   
+
+dev_dependencies
+builder      1.2.3    1.3.0       2.0.0       2.0.0   
+
+transitive dependencies
+transitive   1.2.3    1.3.0       1.3.0       2.0.0   
+transitive2  -        -           1.0.0       1.0.0   
+transitive3  -        -           1.0.0       1.0.0   
+
+3 upgradable dependencies are locked (in pubspec.lock) to older versions.
+To update these dependencies, use `pub upgrade`.
+
+4  dependencies are constrained to versions that are older than a resolvable version.
+To update these dependencies, edit pubspec.yaml.
+
+$ pub outdated --format=no-color --up-to-date
+Resolving...
+Package        Current  Upgradable  Resolvable  Latest  
+dependencies   
+bar            1.0.0    1.0.0       1.0.0       1.0.0   
+foo            *1.2.3   *1.3.0      *2.0.0      3.0.0   
+local_package  0.0.1    0.0.1       0.0.1       0.0.1   
+
+dev_dependencies
+builder        *1.2.3   *1.3.0      2.0.0       2.0.0   
+
+transitive dependencies
+transitive     *1.2.3   *1.3.0      *1.3.0      2.0.0   
+transitive2    -        -           1.0.0       1.0.0   
+transitive3    -        -           1.0.0       1.0.0   
+
+3 upgradable dependencies are locked (in pubspec.lock) to older versions.
+To update these dependencies, use `pub upgrade`.
+
+4  dependencies are constrained to versions that are older than a resolvable version.
+To update these dependencies, edit pubspec.yaml.
+
+$ pub outdated --format=no-color --pre-releases
+Resolving...
+Package      Current  Upgradable  Resolvable  Latest       
+dependencies 
+foo          *1.2.3   *1.3.0      *2.0.0      3.0.0        
+
+dev_dependencies
+builder      *1.2.3   *1.3.0      *2.0.0      3.0.0-alpha  
+
+transitive dependencies
+transitive   *1.2.3   *1.3.0      *1.3.0      2.0.0        
+transitive2  -        -           1.0.0       1.0.0        
+transitive3  -        -           1.0.0       1.0.0        
+
+3 upgradable dependencies are locked (in pubspec.lock) to older versions.
+To update these dependencies, use `pub upgrade`.
+
+4  dependencies are constrained to versions that are older than a resolvable version.
+To update these dependencies, edit pubspec.yaml.
+
+$ pub outdated --format=no-color --no-dev-dependencies
+Resolving...
+Package     Current  Upgradable  Resolvable  Latest  
+dependencies
+foo         *1.2.3   *1.3.0      3.0.0       3.0.0   
+
+transitive dependencies
+transitive  *1.2.3   2.0.0       2.0.0       2.0.0   
+
+2 upgradable dependencies are locked (in pubspec.lock) to older versions.
+To update these dependencies, use `pub upgrade`.
+
+1 dependency is constrained to a version that is older than a resolvable version.
+To update it, edit pubspec.yaml.
+
diff --git a/test/outdated/goldens/no_dependencies.txt b/test/outdated/goldens/no_dependencies.txt
new file mode 100644
index 0000000..9de1ac4
--- /dev/null
+++ b/test/outdated/goldens/no_dependencies.txt
@@ -0,0 +1,26 @@
+$ pub outdated --format=json
+Resolving...
+{
+  "packages": []
+}
+
+$ pub outdated --format=no-color
+Resolving...
+Found no outdated packages
+
+$ pub outdated --format=no-color --mark=none
+Resolving...
+Found no outdated packages
+
+$ pub outdated --format=no-color --up-to-date
+Resolving...
+Found no outdated packages
+
+$ pub outdated --format=no-color --pre-releases
+Resolving...
+Found no outdated packages
+
+$ pub outdated --format=no-color --no-dev-dependencies
+Resolving...
+Found no outdated packages
+
diff --git a/test/outdated/outdated_test.dart b/test/outdated/outdated_test.dart
new file mode 100644
index 0000000..7937261
--- /dev/null
+++ b/test/outdated/outdated_test.dart
@@ -0,0 +1,83 @@
+// Copyright (c) 2020, 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.
+
+import 'package:test/test.dart';
+import '../descriptor.dart' as d;
+import '../golden_file.dart';
+import '../test_pub.dart';
+
+/// Try running 'pub outdated' with a number of different sets of arguments.
+///
+/// Compare the output to the file in goldens/$[name].
+Future<void> variations(String name) async {
+  final buffer = StringBuffer();
+  for (final args in [
+    ['--format=json'],
+    ['--format=no-color'],
+    ['--format=no-color', '--mark=none'],
+    ['--format=no-color', '--up-to-date'],
+    ['--format=no-color', '--pre-releases'],
+    ['--format=no-color', '--no-dev-dependencies'],
+  ]) {
+    final process = await startPub(args: ['outdated', ...args]);
+    await process.shouldExit(0);
+    expect(await process.stderr.rest.toList(), isEmpty);
+    buffer.writeln([
+      '\$ pub outdated ${args.join(' ')}',
+      ...await process.stdout.rest.toList()
+    ].join('\n'));
+    buffer.write('\n');
+  }
+  // The easiest way to update the golden files is to delete them and rerun the
+  // test.
+  expectMatchesGoldenFile(buffer.toString(), 'test/outdated/goldens/$name.txt');
+}
+
+Future<void> main() async {
+  test('no dependencies', () async {
+    await d.appDir().create();
+    await pubGet();
+    await variations('no_dependencies');
+  });
+
+  test('newer versions available', () async {
+    await servePackages((builder) => builder
+      ..serve('foo', '1.2.3', deps: {'transitive': '^1.0.0'})
+      ..serve('bar', '1.0.0')
+      ..serve('builder', '1.2.3', deps: {'transitive': '^1.0.0'})
+      ..serve('transitive', '1.2.3'));
+
+    await d.dir('local_package', [
+      d.libDir('local_package'),
+      d.libPubspec('local_package', '0.0.1')
+    ]).create();
+
+    await d.dir(appPath, [
+      d.pubspec({
+        'name': 'app',
+        'dependencies': {
+          'foo': '^1.0.0',
+          'bar': '^1.0.0',
+          'local_package': {'path': '../local_package'}
+        },
+        'dev_dependencies': {'builder': '^1.0.0'},
+      })
+    ]).create();
+    await pubGet();
+    globalPackageServer.add((builder) => builder
+      ..serve('foo', '1.3.0', deps: {'transitive': '>=1.0.0<3.0.0'})
+      ..serve('foo', '2.0.0',
+          deps: {'transitive': '>=1.0.0<3.0.0', 'transitive2': '^1.0.0'})
+      ..serve('foo', '3.0.0', deps: {'transitive': '^2.0.0'})
+      ..serve('builder', '1.3.0', deps: {'transitive': '^1.0.0'})
+      ..serve('builder', '2.0.0',
+          deps: {'transitive': '^1.0.0', 'transitive3': '^1.0.0'})
+      ..serve('builder', '3.0.0-alpha', deps: {'transitive': '^1.0.0'})
+      ..serve('transitive', '1.3.0')
+      ..serve('transitive', '2.0.0')
+      ..serve('transitive2', '1.0.0')
+      ..serve('transitive3', '1.0.0'));
+    await variations('newer_versions');
+  });
+}
diff --git a/test/pub_test.dart b/test/pub_test.dart
index ab59f71..7ee7a08 100644
--- a/test/pub_test.dart
+++ b/test/pub_test.dart
@@ -36,6 +36,7 @@
           global      Work with global packages.
           help        Display help information for pub.
           logout      Log out of pub.dartlang.org.
+          outdated    Analyze your dependencies to find which ones can be upgraded.
           publish     Publish the current package to pub.dartlang.org.
           run         Run an executable from a package.
           upgrade     Upgrade the current package's dependencies to latest versions.