downgrade --tighten (#4245)

diff --git a/lib/src/command/downgrade.dart b/lib/src/command/downgrade.dart
index 6055d5f..acd40c8 100644
--- a/lib/src/command/downgrade.dart
+++ b/lib/src/command/downgrade.dart
@@ -5,10 +5,10 @@
 import 'dart:async';
 
 import '../command.dart';
+import '../command_runner.dart';
 import '../log.dart' as log;
 import '../solver.dart';
 
-/// Handles the `downgrade` pub command.
 class DowngradeCommand extends PubCommand {
   @override
   String get name => 'downgrade';
@@ -23,6 +23,12 @@
   @override
   bool get isOffline => argResults.flag('offline');
 
+  bool get _dryRun => argResults.flag('dry-run');
+
+  bool get _tighten => argResults.flag('tighten');
+
+  bool get _example => argResults.flag('example');
+
   DowngradeCommand() {
     argParser.addFlag(
       'offline',
@@ -51,6 +57,13 @@
       help: 'Run this in the directory <dir>.',
       valueHelp: 'dir',
     );
+
+    argParser.addFlag(
+      'tighten',
+      help:
+          'Updates lower bounds in pubspec.yaml to match the resolved version.',
+      negatable: false,
+    );
   }
 
   @override
@@ -62,23 +75,32 @@
         ),
       );
     }
-    var dryRun = argResults.flag('dry-run');
 
     await entrypoint.acquireDependencies(
       SolveType.downgrade,
       unlock: argResults.rest,
-      dryRun: dryRun,
+      dryRun: _dryRun,
     );
     var example = entrypoint.example;
     if (argResults.flag('example') && example != null) {
       await example.acquireDependencies(
         SolveType.get,
         unlock: argResults.rest,
-        dryRun: dryRun,
+        dryRun: _dryRun,
         summaryOnly: true,
       );
     }
 
+    if (_tighten) {
+      if (_example && entrypoint.example != null) {
+        log.warning(
+          'Running `downgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.',
+        );
+      }
+      final changes = entrypoint.tighten();
+      entrypoint.applyChanges(changes, _dryRun);
+    }
+
     if (isOffline) {
       log.warning('Warning: Downgrading when offline may not update you to '
           'the oldest versions of your dependencies.');
diff --git a/lib/src/command/upgrade.dart b/lib/src/command/upgrade.dart
index 3c40b67..1864aa2 100644
--- a/lib/src/command/upgrade.dart
+++ b/lib/src/command/upgrade.dart
@@ -5,7 +5,6 @@
 import 'dart:async';
 
 import 'package:pub_semver/pub_semver.dart';
-import 'package:yaml_edit/yaml_edit.dart';
 
 import '../command.dart';
 import '../command_runner.dart';
@@ -16,7 +15,6 @@
 import '../package_name.dart';
 import '../pubspec.dart';
 import '../pubspec_utils.dart';
-import '../sdk.dart';
 import '../solver.dart';
 import '../source/hosted.dart';
 import '../utils.dart';
@@ -137,25 +135,14 @@
     } else {
       await _runUpgrade(entrypoint);
       if (_tighten) {
-        final changes = tighten(
-          entrypoint,
-          entrypoint.lockFile.packages.values.toList(),
-        );
-        if (!_dryRun) {
-          for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
-            final changesForPackage = changes[package];
-            if (changesForPackage == null || changesForPackage.isEmpty) {
-              continue;
-            }
-            final newPubspecText =
-                _updatePubspecText(package, changesForPackage);
-
-            if (changes.isNotEmpty) {
-              writeTextFile(package.pubspecPath, newPubspecText);
-            }
-          }
+        if (argResults.flag('example') && entrypoint.example != null) {
+          log.warning(
+            'Running `upgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.',
+          );
         }
-        _outputChangeSummary(changes);
+        final changes =
+            entrypoint.tighten(packagesToUpgrade: _packagesToUpgrade);
+        entrypoint.applyChanges(changes, _dryRun);
       }
     }
     if (argResults.flag('example') && entrypoint.example != null) {
@@ -178,75 +165,6 @@
     _showOfflineWarning();
   }
 
-  /// Returns a list of changes to constraints in [pubspec] updated them to
-  ///  have their lower bound match the version in [packages].
-  ///
-  /// The return value is a mapping from the original package range to the updated.
-  ///
-  /// If packages to update where given in [_packagesToUpgrade], only those are
-  /// tightened. Otherwise all packages are tightened.
-  ///
-  /// If a dependency has already been updated in [existingChanges], the update
-  /// will apply on top of that change (eg. preserving the new upper bound).
-  Map<Package, Map<PackageRange, PackageRange>> tighten(
-    Entrypoint entrypoint,
-    List<PackageId> packages, {
-    Map<Package, Map<PackageRange, PackageRange>> existingChanges = const {},
-  }) {
-    final result = {...existingChanges};
-    if (argResults.flag('example') && entrypoint.example != null) {
-      log.warning(
-        'Running `upgrade --tighten` only in `${entrypoint.workspaceRoot.dir}`. Run `$topLevelProgram pub upgrade --tighten --directory example/` separately.',
-      );
-    }
-
-    final toTighten = <(Package, PackageRange)>[];
-
-    for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
-      if (_packagesToUpgrade.isEmpty) {
-        for (final range in [
-          ...package.dependencies.values,
-          ...package.devDependencies.values,
-        ]) {
-          toTighten.add((package, range));
-        }
-      } else {
-        for (final packageToUpgrade in _packagesToUpgrade) {
-          final range = package.dependencies[packageToUpgrade] ??
-              package.devDependencies[packageToUpgrade];
-          if (range != null) {
-            toTighten.add((package, range));
-          }
-        }
-      }
-    }
-
-    for (final (package, range) in toTighten) {
-      final changesForPackage = result[package] ??= {};
-      final constraint = (changesForPackage[range] ?? range).constraint;
-      final resolvedVersion =
-          packages.firstWhere((p) => p.name == range.name).version;
-      if (range.source is HostedSource && constraint.isAny) {
-        changesForPackage[range] = range
-            .toRef()
-            .withConstraint(VersionConstraint.compatibleWith(resolvedVersion));
-      } else if (constraint is VersionRange) {
-        final min = constraint.min;
-        if (min != null && min < resolvedVersion) {
-          changesForPackage[range] = range.toRef().withConstraint(
-                VersionRange(
-                  min: resolvedVersion,
-                  max: constraint.max,
-                  includeMin: true,
-                  includeMax: constraint.includeMax,
-                ).asCompatibleWithIfPossible(),
-              );
-        }
-      }
-    }
-    return result;
-  }
-
   /// Return names of packages to be upgraded, and throws [UsageException] if
   /// any package names not in the direct dependencies or dev_dependencies are given.
   ///
@@ -346,10 +264,10 @@
           return applyChanges(package.pubspec, changes[package] ?? {});
         }),
       );
-      changes = tighten(
-        entrypoint,
-        solveResult.packages,
+      changes = entrypoint.tighten(
+        packagesToUpgrade: _packagesToUpgrade,
         existingChanges: changes,
+        packageVersions: solveResult.packages,
       );
     }
 
@@ -361,15 +279,7 @@
     final solveType =
         _packagesToUpgrade.isEmpty ? SolveType.upgrade : SolveType.get;
 
-    if (!_dryRun) {
-      for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
-        final changesForPackage = changes[package] ?? {};
-        if (changesForPackage.isNotEmpty) {
-          final newPubspecText = _updatePubspecText(package, changesForPackage);
-          writeTextFile(package.pubspecPath, newPubspecText);
-        }
-      }
-    }
+    entrypoint.applyChanges(changes, _dryRun);
     await entrypoint.withUpdatedRootPubspecs({
       for (final MapEntry(key: package, value: changesForPackage)
           in changes.entries)
@@ -380,8 +290,6 @@
       precompile: !_dryRun && _precompile,
     );
 
-    _outputChangeSummary(changes);
-
     // If any of the packages to upgrade are dependency overrides, then we
     // show a warning.
     final toUpgradeOverrides = toUpgrade
@@ -416,65 +324,6 @@
     );
   }
 
-  /// Loads `pubspec.yaml` of [package] and applies [changes] to its
-  /// (dev)-dependencies.
-  ///
-  /// Returns the updated textual representation using yaml-edit to preserve
-  /// structure.
-  String _updatePubspecText(
-    Package package,
-    Map<PackageRange, PackageRange> changes,
-  ) {
-    ArgumentError.checkNotNull(changes, 'changes');
-    final yamlEditor = YamlEditor(readTextFile(package.pubspecPath));
-    final deps = package.dependencies.keys;
-
-    for (final change in changes.values) {
-      final section =
-          deps.contains(change.name) ? 'dependencies' : 'dev_dependencies';
-      yamlEditor.update(
-        [section, change.name],
-        pubspecDescription(change, cache, package),
-      );
-    }
-    return yamlEditor.toString();
-  }
-
-  /// Outputs a summary of changes made to `pubspec.yaml`.
-  void _outputChangeSummary(
-    Map<Package, Map<PackageRange, PackageRange>> changes,
-  ) {
-    if (entrypoint.workspaceRoot.workspaceChildren.isEmpty) {
-      final changesToWorkspaceRoot = changes[entrypoint.workspaceRoot] ?? {};
-      if (changesToWorkspaceRoot.isEmpty) {
-        final wouldBe = _dryRun ? 'would be made to' : 'to';
-        log.message('\nNo changes $wouldBe pubspec.yaml!');
-      } else {
-        final changed = _dryRun ? 'Would change' : 'Changed';
-        log.message('\n$changed ${changesToWorkspaceRoot.length} '
-            '${pluralize('constraint', changesToWorkspaceRoot.length)} in pubspec.yaml:');
-        changesToWorkspaceRoot.forEach((from, to) {
-          log.message('  ${from.name}: ${from.constraint} -> ${to.constraint}');
-        });
-      }
-    } else {
-      if (changes.isEmpty) {
-        final wouldBe = _dryRun ? 'would be made to' : 'to';
-        log.message('\nNo changes $wouldBe any pubspec.yaml!');
-      }
-      for (final package in entrypoint.workspaceRoot.transitiveWorkspace) {
-        final changesToPackage = changes[package] ?? {};
-        if (changesToPackage.isEmpty) continue;
-        final changed = _dryRun ? 'Would change' : 'Changed';
-        log.message('\n$changed ${changesToPackage.length} '
-            '${pluralize('constraint', changesToPackage.length)} in ${package.pubspecPath}:');
-        changesToPackage.forEach((from, to) {
-          log.message('  ${from.name}: ${from.constraint} -> ${to.constraint}');
-        });
-      }
-    }
-  }
-
   void _showOfflineWarning() {
     if (isOffline) {
       log.warning('Warning: Upgrading when offline may not update you to the '
diff --git a/lib/src/entrypoint.dart b/lib/src/entrypoint.dart
index 60515f4..557c2b0 100644
--- a/lib/src/entrypoint.dart
+++ b/lib/src/entrypoint.dart
@@ -13,6 +13,7 @@
 import 'package:pub_semver/pub_semver.dart';
 import 'package:source_span/source_span.dart';
 import 'package:yaml/yaml.dart';
+import 'package:yaml_edit/yaml_edit.dart';
 
 import 'command_runner.dart';
 import 'dart.dart' as dart;
@@ -27,12 +28,14 @@
 import 'package_graph.dart';
 import 'package_name.dart';
 import 'pubspec.dart';
+import 'pubspec_utils.dart';
 import 'sdk.dart';
 import 'sdk/flutter.dart';
 import 'solver.dart';
 import 'solver/report.dart';
 import 'solver/solve_suggestions.dart';
 import 'source/cached.dart';
+import 'source/hosted.dart';
 import 'source/root.dart';
 import 'source/unknown.dart';
 import 'system_cache.dart';
@@ -1244,4 +1247,139 @@
       }
     }
   }
+
+  /// Returns a list of changes to constraints of workspace pubspecs updated to
+  /// have their lower bound match the version in [packageVersions] (or
+  /// `this.lockFile`).
+  ///
+  /// The return value for each workspace package is a mapping from the original
+  /// package range to the updated.
+  ///
+  /// If packages to update where given in [packagesToUpgrade], only those are
+  /// tightened. Otherwise all packages are tightened.
+  ///
+  /// If a dependency has already been updated in [existingChanges], the update
+  /// will apply on top of that change (eg. preserving the new upper bound).
+  Map<Package, Map<PackageRange, PackageRange>> tighten({
+    List<String> packagesToUpgrade = const [],
+    Map<Package, Map<PackageRange, PackageRange>> existingChanges = const {},
+    List<PackageId>? packageVersions,
+  }) {
+    final result = {...existingChanges};
+
+    final toTighten = <(Package, PackageRange)>[];
+
+    for (final package in workspaceRoot.transitiveWorkspace) {
+      if (packagesToUpgrade.isEmpty) {
+        for (final range in [
+          ...package.dependencies.values,
+          ...package.devDependencies.values,
+        ]) {
+          toTighten.add((package, range));
+        }
+      } else {
+        for (final packageToUpgrade in packagesToUpgrade) {
+          final range = package.dependencies[packageToUpgrade] ??
+              package.devDependencies[packageToUpgrade];
+          if (range != null) {
+            toTighten.add((package, range));
+          }
+        }
+      }
+    }
+
+    for (final (package, range) in toTighten) {
+      final changesForPackage = result[package] ??= {};
+      final constraint = (changesForPackage[range] ?? range).constraint;
+      final resolvedVersion =
+          (packageVersions?.firstWhere((p) => p.name == range.name) ??
+                  lockFile.packages[range.name])!
+              .version;
+      if (range.source is HostedSource && constraint.isAny) {
+        changesForPackage[range] = range
+            .toRef()
+            .withConstraint(VersionConstraint.compatibleWith(resolvedVersion));
+      } else if (constraint is VersionRange) {
+        final min = constraint.min;
+        if (min != null && min < resolvedVersion) {
+          changesForPackage[range] = range.toRef().withConstraint(
+                VersionRange(
+                  min: resolvedVersion,
+                  max: constraint.max,
+                  includeMin: true,
+                  includeMax: constraint.includeMax,
+                ).asCompatibleWithIfPossible(),
+              );
+        }
+      }
+    }
+    return result;
+  }
+
+  /// Unless [dryRun], loads `pubspec.yaml` of each [package] in [changeSet] and applies the
+  /// changes to its (dev)-dependencies using yaml_edit to preserve textual structure.
+  ///
+  /// Outputs a summary of changes done or would have been done if not [dryRun].
+  void applyChanges(ChangeSet changeSet, bool dryRun) {
+    if (!dryRun) {
+      for (final package in workspaceRoot.transitiveWorkspace) {
+        final changesForPackage = changeSet[package];
+        if (changesForPackage == null || changesForPackage.isEmpty) {
+          continue;
+        }
+        final yamlEditor = YamlEditor(readTextFile(package.pubspecPath));
+        final deps = package.dependencies.keys;
+
+        for (final change in changesForPackage.values) {
+          final section =
+              deps.contains(change.name) ? 'dependencies' : 'dev_dependencies';
+          yamlEditor.update(
+            [section, change.name],
+            pubspecDescription(change, cache, package),
+          );
+        }
+        writeTextFile(package.pubspecPath, yamlEditor.toString());
+      }
+    }
+    _outputChangeSummary(changeSet, dryRun: dryRun);
+  }
+
+  /// Outputs a summary of [changeSet].
+  void _outputChangeSummary(
+    ChangeSet changeSet, {
+    required bool dryRun,
+  }) {
+    if (workspaceRoot.workspaceChildren.isEmpty) {
+      final changesToWorkspaceRoot = changeSet[workspaceRoot] ?? {};
+      if (changesToWorkspaceRoot.isEmpty) {
+        final wouldBe = dryRun ? 'would be made to' : 'to';
+        log.message('\nNo changes $wouldBe pubspec.yaml!');
+      } else {
+        final changed = dryRun ? 'Would change' : 'Changed';
+        log.message('\n$changed ${changesToWorkspaceRoot.length} '
+            '${pluralize('constraint', changesToWorkspaceRoot.length)} in pubspec.yaml:');
+        changesToWorkspaceRoot.forEach((from, to) {
+          log.message('  ${from.name}: ${from.constraint} -> ${to.constraint}');
+        });
+      }
+    } else {
+      if (changeSet.isEmpty) {
+        final wouldBe = dryRun ? 'would be made to' : 'to';
+        log.message('\nNo changes $wouldBe any pubspec.yaml!');
+      }
+      for (final package in workspaceRoot.transitiveWorkspace) {
+        final changesToPackage = changeSet[package] ?? {};
+        if (changesToPackage.isEmpty) continue;
+        final changed = dryRun ? 'Would change' : 'Changed';
+        log.message('\n$changed ${changesToPackage.length} '
+            '${pluralize('constraint', changesToPackage.length)} in ${package.pubspecPath}:');
+        changesToPackage.forEach((from, to) {
+          log.message('  ${from.name}: ${from.constraint} -> ${to.constraint}');
+        });
+      }
+    }
+  }
 }
+
+/// For each package in a workspace, a set of changes to dependencies.
+typedef ChangeSet = Map<Package, Map<PackageRange, PackageRange>>;
diff --git a/test/downgrade/tighten_test.dart b/test/downgrade/tighten_test.dart
new file mode 100644
index 0000000..7505b83
--- /dev/null
+++ b/test/downgrade/tighten_test.dart
@@ -0,0 +1,33 @@
+// 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' as d;
+import '../test_pub.dart';
+
+void main() {
+  test('--tighten will set lower bounds to the actually achieved version',
+      () async {
+    await servePackages()
+      ..serve(
+        'foo',
+        '1.0.0',
+      ) // Because of the bar constraint, this is not achievable.
+      ..serve('foo', '2.0.0')
+      ..serve('foo', '3.0.0')
+      ..serve('bar', '1.0.0', deps: {'foo': '>=2.0.0'});
+
+    await d.appDir(dependencies: {'foo': '>=1.0.0', 'bar': '^1.0.0'}).create();
+
+    await pubGet(output: contains('foo 3.0.0'));
+    await pubDowngrade(
+      args: ['--tighten'],
+      output: allOf(
+        contains('< foo 2.0.0 (was 3.0.0)'),
+        contains('foo: >=1.0.0 -> >=2.0.0'),
+      ),
+    );
+  });
+}
diff --git a/test/testdata/goldens/help_test/pub downgrade --help.txt b/test/testdata/goldens/help_test/pub downgrade --help.txt
index 483e764..a971395 100644
--- a/test/testdata/goldens/help_test/pub downgrade --help.txt
+++ b/test/testdata/goldens/help_test/pub downgrade --help.txt
@@ -11,6 +11,7 @@
     --[no-]offline       Use cached packages instead of accessing the network.
 -n, --dry-run            Report what dependencies would change but don't change any.
 -C, --directory=<dir>    Run this in the directory <dir>.
+    --tighten            Updates lower bounds in pubspec.yaml to match the resolved version.
 
 Run "pub help" to see global options.
 See https://dart.dev/tools/pub/cmd/pub-downgrade for detailed documentation.
diff --git a/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt b/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt
index b5ea61a..0fb9eb7 100644
--- a/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt
+++ b/test/testdata/goldens/upgrade/example_warns_about_major_versions_test/pub upgrade --major-versions does not update major versions in example~.txt
@@ -2,13 +2,13 @@
 
 ## Section 0
 $ pub upgrade --major-versions --example
+
+Changed 1 constraint in pubspec.yaml:
+  bar: ^1.0.0 -> ^2.0.0
 Resolving dependencies...
 Downloading packages...
 + bar 2.0.0
 Changed 1 dependency!
-
-Changed 1 constraint in pubspec.yaml:
-  bar: ^1.0.0 -> ^2.0.0
 Resolving dependencies in `./example`...
 Downloading packages...
 Got dependencies in `./example`.
@@ -18,11 +18,11 @@
 
 ## Section 1
 $ pub upgrade --major-versions --directory example
+
+Changed 1 constraint in pubspec.yaml:
+  foo: ^1.0.0 -> ^2.0.0
 Resolving dependencies in `example`...
 Downloading packages...
 > foo 2.0.0 (was 1.0.0)
 Changed 1 dependency in `example`!
 
-Changed 1 constraint in pubspec.yaml:
-  foo: ^1.0.0 -> ^2.0.0
-