`pub bump` command (#4361)

diff --git a/lib/src/command/bump.dart b/lib/src/command/bump.dart
new file mode 100644
index 0000000..906ca2b
--- /dev/null
+++ b/lib/src/command/bump.dart
@@ -0,0 +1,131 @@
+// Copyright (c) 2024, 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 'package:collection/collection.dart';
+import 'package:pub_semver/pub_semver.dart';
+import 'package:yaml/yaml.dart';
+import 'package:yaml_edit/yaml_edit.dart';
+
+import '../command.dart';
+import '../io.dart';
+import '../log.dart' as log;
+
+class BumpSubcommand extends PubCommand {
+  @override
+  final String name;
+  @override
+  final String description;
+
+  final Version Function(Version) updateVersion;
+  BumpSubcommand(this.name, this.description, this.updateVersion) {
+    argParser.addFlag(
+      'dry-run',
+      abbr: 'n',
+      negatable: false,
+      help: "Report what would change, but don't change anything.",
+    );
+  }
+
+  String? _versionLines(YamlMap map, String text, String prefix) {
+    final entry = map.nodes.entries
+        .firstWhereOrNull((e) => (e.key as YamlNode).value == 'version');
+    if (entry == null) return null;
+
+    final firstLine = (entry.key as YamlNode).span.start.line;
+    final lastLine = entry.value.span.end.line;
+    final lines = text.split('\n');
+    return lines
+        .sublist(firstLine, lastLine + 1)
+        .map((x) => '$prefix$x')
+        .join('\n');
+  }
+
+  @override
+  Future<void> runProtected() async {
+    final pubspec = entrypoint.workPackage.pubspec;
+    final currentVersion = pubspec.version;
+
+    final newVersion = updateVersion(currentVersion);
+
+    final originalPubspecText =
+        readTextFile(entrypoint.workPackage.pubspecPath);
+    final yamlEditor = YamlEditor(originalPubspecText);
+    yamlEditor.update(['version'], newVersion.toString());
+    final updatedPubspecText = yamlEditor.toString();
+    final beforeText = _versionLines(pubspec.fields, originalPubspecText, '- ');
+    final afterText = _versionLines(
+      yamlEditor.parseAt([]) as YamlMap,
+      updatedPubspecText,
+      '+ ',
+    );
+    if (argResults.flag('dry-run')) {
+      log.message('Would update version from $currentVersion to $newVersion.');
+      log.message('Diff:');
+      if (beforeText != null) {
+        log.message(beforeText);
+      }
+      if (afterText != null) {
+        log.message(afterText);
+      }
+    } else {
+      log.message('Updating version from $currentVersion to $newVersion.');
+      log.message('Diff:');
+
+      if (beforeText != null) {
+        log.message(beforeText);
+      }
+      if (afterText != null) {
+        log.message(afterText);
+        log.message('\nRemember to update `CHANGELOG.md` before publishing.');
+      }
+      writeTextFile(
+        entrypoint.workPackage.pubspecPath,
+        yamlEditor.toString(),
+      );
+    }
+  }
+}
+
+class BumpCommand extends PubCommand {
+  @override
+  String get name => 'bump';
+  @override
+  String get description => '''
+Increases the version number of the current package.
+''';
+
+  BumpCommand() {
+    addSubcommand(
+      BumpSubcommand(
+        'major',
+        'Increment the major version number (eg. 3.1.2 -> 4.0.0)',
+        (v) => v.nextMajor,
+      ),
+    );
+    addSubcommand(
+      BumpSubcommand(
+        'minor',
+        'Increment the minor version number (eg. 3.1.2 -> 3.2.0)',
+        (v) => v.nextMinor,
+      ),
+    );
+    addSubcommand(
+      BumpSubcommand(
+        'patch',
+        'Increment the patch version number (eg. 3.1.2 -> 3.1.3)',
+        (v) => v.nextPatch,
+      ),
+    );
+
+    addSubcommand(
+      BumpSubcommand(
+        'breaking',
+        'Increment to the next breaking version (eg. 0.1.2 -> 0.2.0)',
+        (v) => v.nextBreaking,
+      ),
+    );
+  }
+}
diff --git a/lib/src/command_runner.dart b/lib/src/command_runner.dart
index e089aad..cb42f47 100644
--- a/lib/src/command_runner.dart
+++ b/lib/src/command_runner.dart
@@ -11,6 +11,7 @@
 
 import 'command.dart' show PubTopLevel, lineLength;
 import 'command/add.dart';
+import 'command/bump.dart';
 import 'command/cache.dart';
 import 'command/deps.dart';
 import 'command/downgrade.dart';
@@ -139,6 +140,7 @@
     // When adding new commands be sure to also add them to
     // `pub_embeddable_command.dart`.
     addCommand(AddCommand());
+    addCommand(BumpCommand());
     addCommand(CacheCommand());
     addCommand(DepsCommand());
     addCommand(DowngradeCommand());
diff --git a/lib/src/pub_embeddable_command.dart b/lib/src/pub_embeddable_command.dart
index a5fad51..e36bb42 100644
--- a/lib/src/pub_embeddable_command.dart
+++ b/lib/src/pub_embeddable_command.dart
@@ -5,6 +5,7 @@
 import 'command.dart' show PubCommand, PubTopLevel;
 import 'command.dart';
 import 'command/add.dart';
+import 'command/bump.dart';
 import 'command/cache.dart';
 import 'command/deps.dart';
 import 'command/downgrade.dart';
@@ -69,6 +70,7 @@
     //
     // New commands should (most likely) be included in both lists.
     addSubcommand(AddCommand());
+    addSubcommand(BumpCommand());
     addSubcommand(CacheCommand());
     addSubcommand(DepsCommand());
     addSubcommand(DowngradeCommand());
diff --git a/test/bump_test.dart b/test/bump_test.dart
new file mode 100644
index 0000000..b558a55
--- /dev/null
+++ b/test/bump_test.dart
@@ -0,0 +1,103 @@
+// Copyright (c) 2024, 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';
+import 'test_pub.dart';
+
+void main() {
+  void testBump(String part, String from, String to) {
+    test('Bumps the $part version from $from to $to', () async {
+      await dir(appPath, [
+        file(
+          'pubspec.yaml',
+          '''
+name: myapp
+version: $from # comment
+environment:
+  sdk: $defaultSdkConstraint
+''',
+        ),
+      ]).create();
+      await runPub(
+        args: ['bump', part, '--dry-run'],
+        output: '''
+Would update version from $from to $to.
+Diff:
+- version: $from # comment
++ version: $to # comment
+''',
+      );
+      await runPub(
+        args: ['bump', part],
+        output: '''
+Updating version from $from to $to.
+Diff:
+- version: $from # comment
++ version: $to # comment
+
+Remember to update `CHANGELOG.md` before publishing.
+        ''',
+      );
+      await appDir(pubspec: {'version': to}).validate();
+    });
+  }
+
+  testBump('major', '0.0.0', '1.0.0');
+  testBump('major', '1.2.3', '2.0.0');
+  testBump('minor', '0.1.1-dev+2', '0.2.0');
+  testBump('minor', '1.2.3', '1.3.0');
+  testBump('patch', '0.1.1-dev+2', '0.1.1');
+  testBump('patch', '0.1.1+2', '0.1.2');
+  testBump('patch', '1.2.3', '1.2.4');
+  testBump('breaking', '0.2.0', '0.3.0');
+  testBump('breaking', '1.2.3', '2.0.0');
+
+  test('Creates top-level version field if missing', () async {
+    await dir(appPath, [
+      file('pubspec.yaml', '''
+name: my_app
+'''),
+    ]).create();
+    await runPub(
+      args: ['bump', 'breaking'],
+      output: contains('Updating version from 0.0.0 to 0.1.0'),
+    );
+    await dir(appPath, [
+      file('pubspec.yaml', '''
+name: my_app
+version: 0.1.0
+'''),
+    ]).validate();
+  });
+
+  test('Writes all lines of diff', () async {
+    await dir(appPath, [
+      file('pubspec.yaml', '''
+name: my_app
+version: >-
+  1.0.0
+'''),
+    ]).create();
+    await runPub(
+      args: ['bump', 'minor'],
+      output: allOf(
+        contains('Updating version from 1.0.0 to 1.1.0'),
+        contains('''
+Diff:
+- version: >-
+-   1.0.0
++ version: 1.1.0
+'''),
+      ),
+    );
+    await dir(appPath, [
+      file('pubspec.yaml', '''
+name: my_app
+version: 1.1.0
+'''),
+    ]).validate();
+  });
+}
diff --git a/test/testdata/goldens/embedding/embedding_test/--help.txt b/test/testdata/goldens/embedding/embedding_test/--help.txt
index 55eacc6..16beaac 100644
--- a/test/testdata/goldens/embedding/embedding_test/--help.txt
+++ b/test/testdata/goldens/embedding/embedding_test/--help.txt
@@ -14,6 +14,7 @@
 
 Available subcommands:
   add   Add dependencies to `pubspec.yaml`.
+  bump   Increases the version number of the current package.
   cache   Work with the system cache.
   deps   Print package dependencies.
   downgrade   Downgrade the current package's dependencies to oldest versions.
diff --git a/test/testdata/goldens/help_test/pub bump --help.txt b/test/testdata/goldens/help_test/pub bump --help.txt
new file mode 100644
index 0000000..a076958
--- /dev/null
+++ b/test/testdata/goldens/help_test/pub bump --help.txt
@@ -0,0 +1,18 @@
+# GENERATED BY: test/help_test.dart
+
+## Section 0
+$ pub bump --help
+Increases the version number of the current package.
+
+
+Usage: pub bump [arguments...]
+-h, --help    Print this usage information.
+
+Available subcommands:
+  breaking   Increment to the next breaking version (eg. 0.1.2 -> 0.2.0)
+  major      Increment the major version number (eg. 3.1.2 -> 4.0.0)
+  minor      Increment the minor version number (eg. 3.1.2 -> 3.2.0)
+  patch      Increment the patch version number (eg. 3.1.2 -> 3.1.3)
+
+Run "pub help" to see global options.
+
diff --git a/test/testdata/goldens/help_test/pub bump breaking --help.txt b/test/testdata/goldens/help_test/pub bump breaking --help.txt
new file mode 100644
index 0000000..d25b9f9
--- /dev/null
+++ b/test/testdata/goldens/help_test/pub bump breaking --help.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/help_test.dart
+
+## Section 0
+$ pub bump breaking --help
+Increment to the next breaking version (eg. 0.1.2 -> 0.2.0)
+
+Usage: pub bump breaking <subcommand> [arguments...]
+-h, --help       Print this usage information.
+-n, --dry-run    Report what would change, but don't change anything.
+
+Run "pub help" to see global options.
+
diff --git a/test/testdata/goldens/help_test/pub bump major --help.txt b/test/testdata/goldens/help_test/pub bump major --help.txt
new file mode 100644
index 0000000..15937ad
--- /dev/null
+++ b/test/testdata/goldens/help_test/pub bump major --help.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/help_test.dart
+
+## Section 0
+$ pub bump major --help
+Increment the major version number (eg. 3.1.2 -> 4.0.0)
+
+Usage: pub bump major <subcommand> [arguments...]
+-h, --help       Print this usage information.
+-n, --dry-run    Report what would change, but don't change anything.
+
+Run "pub help" to see global options.
+
diff --git a/test/testdata/goldens/help_test/pub bump minor --help.txt b/test/testdata/goldens/help_test/pub bump minor --help.txt
new file mode 100644
index 0000000..927d5c2
--- /dev/null
+++ b/test/testdata/goldens/help_test/pub bump minor --help.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/help_test.dart
+
+## Section 0
+$ pub bump minor --help
+Increment the minor version number (eg. 3.1.2 -> 3.2.0)
+
+Usage: pub bump minor <subcommand> [arguments...]
+-h, --help       Print this usage information.
+-n, --dry-run    Report what would change, but don't change anything.
+
+Run "pub help" to see global options.
+
diff --git a/test/testdata/goldens/help_test/pub bump patch --help.txt b/test/testdata/goldens/help_test/pub bump patch --help.txt
new file mode 100644
index 0000000..057b669
--- /dev/null
+++ b/test/testdata/goldens/help_test/pub bump patch --help.txt
@@ -0,0 +1,12 @@
+# GENERATED BY: test/help_test.dart
+
+## Section 0
+$ pub bump patch --help
+Increment the patch version number (eg. 3.1.2 -> 3.1.3)
+
+Usage: pub bump patch <subcommand> [arguments...]
+-h, --help       Print this usage information.
+-n, --dry-run    Report what would change, but don't change anything.
+
+Run "pub help" to see global options.
+