chromium/tools/crates/create_update_cl.py

#!/usr/bin/env vpython3
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
This script runs `gnrt update`, `gnrt vendor`, and `gnrt gen`, and then uploads
zero, one, or more resulting CLs to Gerrit.  For more details please see
`tools/crates/create_update_cl.md`."""

import argparse
import datetime
import os
import re
import shlex
import subprocess
import sys
import textwrap
import toml
from dataclasses import dataclass
from typing import List, Set, Dict

# Throughout the script, the following naming conventions are used (illustrated
# with a crate named `syn` and published as version `2.0.50`):
#
# * `crate_name`   : "syn" string
# * `crate_version`: "2.0.50" string
# * `crate_id`     : "[email protected]" string (syntax used by `cargo`)
# * `crate_epoch`  : "v2" string (syntax used in dir names under
#                    //third_party/rust/<crate name>/<crate epoch>)
#
# Note that `crate_name` may not be unique (e.g. if there is both `[email protected]`
# and `[email protected]`).  Also note that f`{crate_name}@{crate_epoch}` doesn't
# change during a minor version update (such as the one that this script
# produces in `auto` and `single` modes).

THIS_DIR = os.path.dirname(__file__)
CHROMIUM_DIR = os.path.normpath(os.path.join(THIS_DIR, '..', '..'))
THIRD_PARTY_RUST = os.path.join(CHROMIUM_DIR, "third_party", "rust")
CRATES_DIR = os.path.join(THIRD_PARTY_RUST, "chromium_crates_io")
VENDOR_DIR = os.path.join(CRATES_DIR, "vendor")
CARGO_LOCK = os.path.join(CRATES_DIR, "Cargo.lock")
VET_CONFIG = os.path.join(CRATES_DIR, "supply-chain", "config.toml")
INCLUSIVE_LANG_SCRIPT = os.path.join(
    CHROMIUM_DIR, "infra", "update_inclusive_language_presubmit_exempt_dirs.sh")
INCLUSIVE_LANG_CONFIG = os.path.join(
    CHROMIUM_DIR, "infra", "inclusive_language_presubmit_exempt_dirs.txt")
RUN_GNRT = os.path.join(THIS_DIR, "run_gnrt.py")
UPDATE_RUST_SCRIPT = os.path.join(CHROMIUM_DIR, "tools", "rust",
                                  "update_rust.py")

g_is_verbose = False

timestamp = datetime.datetime.now()
BRANCH_BASENAME = "rust-crates-update"
BRANCH_BASENAME += f"--{timestamp.year}{timestamp.month:02}{timestamp.day:02}"
BRANCH_BASENAME += f"-{timestamp.hour:02}{timestamp.minute:02}"


def RunCommandAndCheckForErrors(args, check_stdout: bool):
    """Runs a command and returns its output."""
    args = list(args)
    assert args

    if g_is_verbose:
        escaped = [shlex.quote(arg) for arg in args]
        msg = " ".join(escaped)
        print(f"    Running: {msg}")

    # Needs shell=True on Windows due to git.bat in depot_tools.
    is_win = sys.platform.startswith('win32')
    result = subprocess.run(args,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT,
                            text=True,
                            shell=is_win)

    success = result.returncode == 0
    if check_stdout:
        success &= re.search(r'\bwarning\b', result.stdout.lower()) is None
        success &= re.search(r'\berror\b', result.stdout.lower()) is None
    if not success:
        print(f"ERROR: Failure when running: {' '.join(args)}")
        print(result.stdout)
        raise RuntimeError(f"Failure when running {args[0]}")
    return result.stdout


def Git(*args) -> str:
    """Runs a git command."""
    return RunCommandAndCheckForErrors(['git'] + list(args), False)


def GitAddRustFiles():
    Git("add", "-f", f"{VENDOR_DIR}")
    Git("add", f"{THIRD_PARTY_RUST}")


def Gnrt(*args) -> str:
    """Runs a gnrt command."""
    return RunCommandAndCheckForErrors([RUN_GNRT] + list(args), True)


def GetCurrentCrateIds() -> Set[str]:
    """Parses Cargo.lock and returns a set of crate ids
    (e.g. "[email protected]", "[email protected]", ...)."""
    t = toml.load(open(CARGO_LOCK))
    result = set()
    for p in t["package"]:
        name = p["name"]
        version = p["version"]
        crate_id = f"{name}@{version}"
        assert crate_id not in result
        result.add(crate_id)
    return result


def GetVetExemptedCrateIds() -> Set[str]:
    """Parses supply-chain/config.toml and returns a set of crate ids
    that have `exemptions` entries."""
    vet_config = toml.load(open(VET_CONFIG))
    result = set()
    if "exemptions" not in vet_config:
        return result
    for crate_name, exemptions in vet_config["exemptions"].items():
        for exemption in exemptions:
            # Ignoring which criteria are covered by the exemption
            # (it is not needed if we just want to emit a warning).
            crate_version = exemption["version"]
            crate_id = f"{crate_name}@{crate_version}"
            result.add(crate_id)
    return result


@dataclass(eq=True, order=True)
class UpdatedCrate:
    old_crate_id: str
    new_crate_id: str

    def __str__(self):
        name = ConvertCrateIdToCrateName(self.old_crate_id)
        assert name == ConvertCrateIdToCrateName(self.new_crate_id)

        old_version = ConvertCrateIdToCrateVersion(self.old_crate_id)
        new_version = ConvertCrateIdToCrateVersion(self.new_crate_id)

        return f"{name}: {old_version} => {new_version}"


@dataclass
class CratesDiff:
    updates: List[UpdatedCrate]
    removed_crate_ids: List[str]
    added_crate_ids: List[str]

    def size(self):
        return len(self.updates) + len(self.added_crate_ids) + len(
            self.removed_crate_ids)


def DiffCrateIds(old_crate_ids: Set[str],
                 new_crate_ids: Set[str],
                 only_minor_updates=True) -> CratesDiff:
    """Compares two results of `GetCurrentCrateIds` and returns what changed.
    When `only_minor_updates` is True, then `[email protected]` => `[email protected]` will be
    treated as a removal of `[email protected]` and an addition of `[email protected]`.  Otherwise,
    it will be treated as an update.
    """

    def CrateIdsToDict(crate_ids: Set[str],
                       only_minor_updates) -> Dict[str, str]:
        """Transforms `crate_ids` into a dictionary that maps either 1) a crate
        name (when `only_minor_updates=False`) or 2) a crate epoch (when
        `only_minor_updates=True`) into 3) a crate version.  The caller should
        treat the key format as an opaque implementation detail and just assume
        that it will be stable for tracking a crate version across updates."""
        result = dict()
        for crate_id in crate_ids:
            name = ConvertCrateIdToCrateName(crate_id)
            version = ConvertCrateIdToCrateVersion(crate_id)
            if only_minor_updates:
                epoch = GetEpoch(version)
                key = f'{name}@{epoch}'
            else:
                key = name
            if key in result:
                # No conflicts expected in `auto` or `single` mode.
                assert not only_minor_updates
                old_crate_id = result[key]
                new_crate_id = crate_id
                raise RuntimeError(f"Error calculating a `Cargo.lock` diff:" + \
                                   f" conflict between {old_crate_id} and " + \
                                   f"{new_crate_id}")
            result[key] = crate_id
        return result

    # Ignoring `unchanged_ids` limits the situations when the key of `[email protected]`
    # may conflict with the key of `[email protected]`.
    unchanged_ids = new_crate_ids & old_crate_ids
    old_dict = CrateIdsToDict(old_crate_ids - unchanged_ids, only_minor_updates)
    new_dict = CrateIdsToDict(new_crate_ids - unchanged_ids, only_minor_updates)

    updates = list()
    removed_crate_ids = list()
    for key, old_crate_id in old_dict.items():
        new_crate_id = new_dict.get(key)
        if new_crate_id:
            assert old_crate_id != new_crate_id
            updates.append(UpdatedCrate(old_crate_id, new_crate_id))
        else:
            removed_crate_ids.append(old_crate_id)

    added_crate_ids = list()
    for key, new_crate_id in new_dict.items():
        if key not in old_dict:
            added_crate_ids.append(new_crate_id)

    return CratesDiff(sorted(updates), sorted(removed_crate_ids),
                      sorted(added_crate_ids))


def GetEpoch(crate_version: str) -> str:
    v = crate_version.split('.')
    if v[0] == '0':
        return f'v0_{v[1]}'
    return f'v{v[0]}'


def ConvertCrateIdToCrateName(crate_id: str) -> str:
    """ Converts a `crate_id` into a `crate_name`."""
    return crate_id[:crate_id.find("@")]


def ConvertCrateIdToCrateVersion(crate_id: str) -> str:
    """ Converts a `crate_id` into a `crate_version`."""
    crate_version = crate_id[crate_id.find("@") + 1:]
    return crate_version


def _ConvertCrateIdToEpochDirRelativeToChromiumRoot(crate_id: str) -> str:
    """ Converts a `crate_id` (e.g. "[email protected]") into an epoch dir
    (e.g. "third_party/rust/foo/v_1").  The returned dir is relative
    to Chromium root. """
    crate_name = ConvertCrateIdToCrateName(crate_id)
    crate_name = crate_name.replace("-", "_")
    epoch = GetEpoch(ConvertCrateIdToCrateVersion(crate_id))
    target = os.path.join("third_party", "rust", crate_name, epoch)
    return target


def ConvertCrateIdToEpochDir(crate_id: str) -> str:
    """ Converts a `crate_id` (e.g. "[email protected]") into a path to an epoch dir
    (e.g. on Windows: "<path to chromium root>\\third_party\\rust\\foo\\v_1").
    """
    return os.path.join(
        CHROMIUM_DIR, _ConvertCrateIdToEpochDirRelativeToChromiumRoot(crate_id))


def ConvertCrateIdToVendorDir(crate_id: str) -> str:
    """ Converts a `crate_id` (e.g. "[email protected]") into a path to a target dir
    (e.g. on Windows: "<path to chromium root>\\third_party\\rust\\foo\\v_1").
    """
    crate_name = ConvertCrateIdToCrateName(crate_id)
    crate_version = ConvertCrateIdToCrateVersion(crate_id)
    crate_vendor_dir = os.path.join(VENDOR_DIR, f"{crate_name}-{crate_version}")
    return crate_vendor_dir


def ConvertCrateIdToGnLabel(crate_id: str) -> str:
    """ Converts a `crate_id` (e.g. "[email protected]") into
    [a GN label](https://gn.googlesource.com/gn/+/main/docs/reference.md#labels)
    (e.g. "//third_party/rust/foo/v_1:lib").  """
    dir_name = _ConvertCrateIdToEpochDirRelativeToChromiumRoot(crate_id)
    dir_name = dir_name.replace(os.sep, "/")  # GN uses `/` as a path separator.
    return f"//{dir_name}:lib"


def FindUpdateableCrates() -> List[str]:
    """Runs `gnrt update` and returns a `list` of old crate ids (e.g.
    "[email protected]") that can be updated to a new version.  (Idempotent -
    afterwards it runs `git reset --hard` to undo any changes.)"""
    print("Checking which crates can be updated...")
    assert not Git("status", "--porcelain")  # No local changes expected here.
    old_crate_ids = GetCurrentCrateIds()
    Gnrt("update")
    new_crate_ids = GetCurrentCrateIds()
    Git("reset", "--hard")
    diff = DiffCrateIds(old_crate_ids, new_crate_ids)
    crate_ids = [update.old_crate_id for update in diff.updates]
    if crate_ids:
        names = sorted([ConvertCrateIdToCrateName(id) for id in crate_ids])
        text = f"Found updates for {len(crate_ids)} crates: {', '.join(names)}"
        print(textwrap.shorten(text, 80))
    return sorted(crate_ids)


def FindSizeOfCrateUpdate(crate_id: str) -> int:
    """Runs `gnrt update <crate_id>` and returns how many crates this would
    update.  (`crate_id` typically looks like "[email protected]".  This function is
    idempotent - at the end it runs `git reset --hard` to undo any changes.)"""
    print(f"Measuring the delta of updating {crate_id} to a newer version...")
    assert not Git("status", "--porcelain")  # No local changes expected here.
    old_crate_ids = GetCurrentCrateIds()
    Gnrt("update", crate_id)
    new_crate_ids = GetCurrentCrateIds()
    Git("reset", "--hard")
    diff = DiffCrateIds(old_crate_ids, new_crate_ids)
    return diff.size()


def FormatMarkdownItem(item: str) -> str:
    return textwrap.fill(f"* {item}",
                         width=72,
                         subsequent_indent='  ',
                         break_long_words=False,
                         break_on_hyphens=False).strip()


def SortedMarkdownList(input_list: List[str]) -> str:
    input_list = [FormatMarkdownItem(item) for item in sorted(input_list)]
    return "\n".join(input_list)


def CreateVetPolicyDescription(crate_ids: List[str]) -> str:
    """Returns a textual description of the required `cargo vet`'s
    certifications.

    Args:
        crate_ids: List of crate ids - e.g. `["[email protected]","[email protected]"]`

    Returns:
        String suitable for including in the CL description.
    """
    vet_config = toml.load(open(VET_CONFIG))
    crate_id_to_criteria = dict()
    for crate_id in crate_ids:
        crate_name = ConvertCrateIdToCrateName(crate_id)
        crate_version = ConvertCrateIdToCrateVersion(crate_id)
        policy = vet_config["policy"][f"{crate_name}:{crate_version}"][
            "criteria"]
        policy.sort()
        crate_id_to_criteria[crate_id] = policy

    criteria_to_crate_ids = dict()
    for crate_id, criteria in crate_id_to_criteria.items():
        criteria = ', '.join(criteria)
        if criteria not in criteria_to_crate_ids:
            criteria_to_crate_ids[criteria] = []
        criteria_to_crate_ids[criteria].append(crate_id)

    items = []
    for criteria, crate_ids in criteria_to_crate_ids.items():
        crate_ids.sort()
        crate_ids = ', '.join(crate_ids)
        if not criteria:
            criteria = "No audit criteria found. Crates with no audit " \
                       "criteria can be submitted without an update to " \
                       "audits.toml."
        items.append(f"{crate_ids}: {criteria}")

    description = \
"""Chromium `supply-chain/config.toml` policy requires that the following
audit criteria are met (note that these are the *minimum* required
criteria and `supply-chain/audits.toml` can and should record a stricter
certification if possible;  see also //docs/rust-unsafe.md):

"""
    description += SortedMarkdownList(items)

    return description


def CreateCommitTitle(old_crate_id: str, diff: CratesDiff) -> str:
    crate_name = ConvertCrateIdToCrateName(old_crate_id)
    old_version = ConvertCrateIdToCrateVersion(old_crate_id)
    update = [u for u in diff.updates if u.old_crate_id == old_crate_id][0]
    update = next(filter(lambda u: u.old_crate_id == old_crate_id,
                         diff.updates))
    new_version = ConvertCrateIdToCrateVersion(update.new_crate_id)
    roll_summary = f"{crate_name}: " + \
        f"{old_version} => {new_version}"
    title = f"Roll {roll_summary} in //third_party/rust."
    return title


def CreateCommitDescription(title: str, diff: CratesDiff,
                            include_vet_criteria: bool) -> str:
    description = f"""{title}

This CL has been created semi-automatically.  The expected review
process and other details can be found at
//tools/crates/create_update_cl.md
"""

    update_descriptions = SortedMarkdownList(
        [str(update) for update in diff.updates])
    new_crate_descriptions = SortedMarkdownList(diff.added_crate_ids)
    removed_crate_descriptions = SortedMarkdownList(diff.removed_crate_ids)
    assert (update_descriptions)
    description += f"\nUpdated crates:\n\n{update_descriptions}\n"
    if new_crate_descriptions:
        description += f"\nNew crates:\n\n{new_crate_descriptions}\n"
    if removed_crate_descriptions:
        description += f"\nRemoved crates:\n\n{removed_crate_descriptions}\n"

    new_or_updated_crate_ids = diff.added_crate_ids + \
        [update.new_crate_id for update in diff.updates]
    if include_vet_criteria:
        vet_policies = CreateVetPolicyDescription(new_or_updated_crate_ids)
        description += f"\n{vet_policies}"

    description += """
Bug: None
Cq-Include-Trybots: chromium/try:android-rust-arm32-rel
Cq-Include-Trybots: chromium/try:android-rust-arm64-dbg
Cq-Include-Trybots: chromium/try:android-rust-arm64-rel
Cq-Include-Trybots: chromium/try:linux-rust-x64-dbg
Cq-Include-Trybots: chromium/try:linux-rust-x64-rel
Cq-Include-Trybots: chromium/try:win-rust-x64-dbg
Cq-Include-Trybots: chromium/try:win-rust-x64-rel
Disable-Rts: True
"""

    return description


def UpdateCrate(args, crate_id: str, upstream_branch: str):
    """Runs `gnrt update <crate_id>` and other follow-up commands to actually
    update the crate."""

    print(f"Updating {crate_id} to a newer version...")
    assert not Git("status", "--porcelain")  # No local changes expected here.
    Git("checkout", upstream_branch)
    assert not Git("status", "--porcelain")  # No local changes expected here.

    # gnrt update
    old_crate_ids = GetCurrentCrateIds()
    print(f"  Running `gnrt update {crate_id}` ...")
    Gnrt("update", crate_id)
    new_crate_ids = GetCurrentCrateIds()
    if old_crate_ids == new_crate_ids:
        print("  `gnrt update` resulted in no changes - "\
              "maybe other steps will handle this crate...")
        return upstream_branch
    diff = DiffCrateIds(old_crate_ids, new_crate_ids)
    title = CreateCommitTitle(crate_id, diff)
    description = CreateCommitDescription(title, diff, False)

    # Checkout a new git branch + `git cl upload`
    new_branch = f"{BRANCH_BASENAME}--{crate_id.replace('@', '-')}"
    Git("checkout", upstream_branch, "-b", new_branch)
    Git("branch", "--set-upstream-to", upstream_branch)
    GitAddRustFiles()
    Git("commit", "-m", description)
    if args.upload:
        print(f"  Running `git cl upload ...` ...")
        GitClUpload("--hashtag=cratesio-autoupdate",
                    "[email protected]")

    FinishUpdatingCrate(args, title, diff)
    return new_branch


def FinishUpdatingCrate(args, title: str, diff: CratesDiff):
    vet_exempted_crate_ids = GetVetExemptedCrateIds()
    updated_old_crate_ids = set()

    # git mv <vendor/old version> <vendor/new version>
    print(f"  Running `git mv <old dir> <new dir>` (for better diff)...")
    for update in diff.updates:
        updated_old_crate_ids.add(update.old_crate_id)

        old_dir = ConvertCrateIdToVendorDir(update.old_crate_id)
        new_dir = ConvertCrateIdToVendorDir(update.new_crate_id)
        Git("mv", "--force", f"{old_dir}", f"{new_dir}")

        old_target_dir = ConvertCrateIdToEpochDir(update.old_crate_id)
        new_target_dir = ConvertCrateIdToEpochDir(update.new_crate_id)
        if old_target_dir != new_target_dir:
            Git("mv", "--force", old_target_dir, new_target_dir)
    GitAddRustFiles()
    GitCommit(args, "git mv <old dir> <new dir> (for better diff)")
    Git("reset", "--hard", "HEAD^")  # Undoing `git mv ...`

    # gnrt vendor
    print(f"  Running `gnrt vendor`...")
    Gnrt("vendor")
    GitAddRustFiles()
    # `INCLUSIVE_LANG_SCRIPT` below uses `git grep` and therefore depends on the
    # earlier `Git("add"...)` above.  Please don't reorder/coalesce the `add`.
    new_content = RunCommandAndCheckForErrors([INCLUSIVE_LANG_SCRIPT], False)
    with open(INCLUSIVE_LANG_CONFIG, "w") as f:
        f.write(new_content)
    Git("add", INCLUSIVE_LANG_CONFIG)
    GitCommit(args, "gnrt vendor")
    if args.upload:
        print(f"  Running `git cl upload --commit-description=...` ...")
        description = CreateCommitDescription(title, diff, True)
        GitClUpload(f"--commit-description={description}", "-t",
                    "Edit CL description to include vet policy")

    # gnrt gen
    print(f"  Running `gnrt gen`...")
    Gnrt("gen")
    # Some crates (e.g. ones in the `remove_crates` list of `gnrt_config.toml`)
    # may result in no changes - this is why we have an `if` below...
    if Git("status", "--porcelain"):
        GitAddRustFiles()
        GitCommit(args, "gnrt gen")

    if args.upload:
        issue = Git("cl", "issue")
        print(f"  {issue}")

    for exempted_crate_id in (
            updated_old_crate_ids.intersection(vet_exempted_crate_ids)):
        exempted_crate_name = ConvertCrateIdToCrateName(exempted_crate_id)
        print(f"  WARNING: The `{exempted_crate_name}` crate "\
               "is covered by an exemption rather than an audit. "\
               "Please bump the exemption in `vet_config.toml.hbs` "\
               "and run `tools/crates/run_gnrt.py vendor` again.")


def RaiseErrorIfGitIsDirty():
    if Git("status", "--porcelain"):
        raise RuntimeError("Dirty `git status` - save you local changes "\
                           "before rerunning the script")


def CheckoutInitialBranch(branch):
    print(f"Checking out the `{branch}` branch...")
    RaiseErrorIfGitIsDirty()
    Git("checkout", branch)
    RaiseErrorIfGitIsDirty()

    # Ensure the //third_party/rust-toolchain version matches the branch.
    print("Running //tools/rust/update_rust.py (hopefully a no-op)...")
    RunCommandAndCheckForErrors([UPDATE_RUST_SCRIPT], False)


def GitClUpload(*args):
    # `--bypass-hooks` because the uploaded CL will initially fail
    # `tools/crates/run_cargo_vet.py check`.
    #
    # `-o banned-words-skip` is used, because the CL is auto-generated and only
    # modifies third-party libraries (where any banned words would be purely
    # accidental; see also https://crbug.com/346174899).
    #
    # I am not 100% sure exactly why `--force` is needed, but without it
    # `git cl upload` hangs sometimes.  I am guessing that `--force` is needed
    # to suppress a prompt, although I am not sure what prompt + why that prompt
    # appears.
    Git("cl", "upload", "--bypass-hooks", "--force", "-o", "banned-words~skip",
        *args)


def GitCommit(args, title):
    Git("commit", "-m", title)
    if args.upload:
        print(f"  Running `git cl upload ...` ...")
        GitClUpload("-m", title)


def ResolveCrateNameToCrateId(crate_name):
    """Parses `Cargo.toml` to resolve `crate_name` into "[email protected]".
    Throws if `crate_name` can't be resolved.

    Parameters:
      crate_name: Either "crate-name" or already "[email protected]"
    """
    t = toml.load(open(CARGO_LOCK))
    if '@' in crate_name:
        resolved_crate_version = ConvertCrateIdToCrateVersion(crate_name)
        resolved_crate_name = ConvertCrateIdToCrateName(crate_name)
    else:
        same_name = [p for p in t["package"] if p["name"] == crate_name]
        if len(same_name) == 0:
            raise RuntimeError(
                f"`Cargo.toml` has no crates matching `{crate_name}`")
        elif len(same_name) > 1:
            ver1 = same_name[0]["version"]
            ver2 = same_name[1]["version"]
            raise RuntimeError(
                f"Ambiguous argument - specify which old version to update, "\
                f"e.g. `{crate_name}@{ver1}` or `{crate_name}@{ver2}")
        resolved_crate_name = crate_name
        resolved_crate_version = same_name[0]["version"]

    crate_id = f"{resolved_crate_name}@{resolved_crate_version}"
    return crate_id


def AutoUpdate(args):
    upstream_branch = args.upstream_branch
    CheckoutInitialBranch(upstream_branch)

    todo_crate_ids = FindUpdateableCrates()

    if args.skip:
        todo_crate_ids = list([
            crate_id for crate_id in todo_crate_ids
            if not crate_id.split("@")[0] in args.skip
        ])

    if not todo_crate_ids:
        print("There were no updates - exiting early...")
        return 0

    update_sizes = dict()
    for crate_id in todo_crate_ids:
        update_sizes[crate_id] = FindSizeOfCrateUpdate(crate_id)

    todo_crate_ids = sorted(todo_crate_ids,
                            key=lambda crate_id: update_sizes[crate_id])
    print(f"** Updating {len(todo_crate_ids)} crates! "
          f"Expect this to take about {len(todo_crate_ids) * 2} minutes.")
    while todo_crate_ids:
        old_crate_ids = GetCurrentCrateIds()
        for crate_id in todo_crate_ids:
            upstream_branch = UpdateCrate(args, crate_id, upstream_branch)

        new_crate_ids = GetCurrentCrateIds()
        diff = DiffCrateIds(old_crate_ids, new_crate_ids)
        actually_updated_crate_ids = set([u.old_crate_id for u in diff.updates])
        missed_crate_ids = [
            crate_id for crate_id in todo_crate_ids
            if crate_id not in actually_updated_crate_ids
        ]
        if missed_crate_ids:
            if len(missed_crate_ids) == len(todo_crate_ids):
                print("ERROR: Failed to make progress with these crates:")
                print(f"{', '.join(todo_crate_ids)}")
                raise RuntimeError("Failed to make progress")
            else:
                print(f"** Retrying {len(missed_crate_ids)} crates.")
        todo_crate_ids = missed_crate_ids


def SingleCrate(args):
    upstream_branch = args.upstream_branch
    CheckoutInitialBranch(upstream_branch)

    crate_id = ResolveCrateNameToCrateId(args.crate[0])
    UpdateCrate(args, crate_id, upstream_branch)


def ManualUpdate(args):
    title = args.title

    RaiseErrorIfGitIsDirty()
    print(f"Post-processing a manual edit of `Cargo.toml`...")

    print(f"  Running `gnrt vendor` to detect `Cargo.lock` changes...")
    old_crate_ids = GetCurrentCrateIds()
    Gnrt("vendor")
    new_crate_ids = GetCurrentCrateIds()
    Git("reset", "--hard")
    Git("clean", "-d", "--force", "--", f"{THIRD_PARTY_RUST}")
    diff = DiffCrateIds(old_crate_ids, new_crate_ids, only_minor_updates=False)
    if diff.size() == 0:
        raise RuntimeError(
            "No changes in `Cargo.lock` after running `gnrt vendor`")

    # This covers most update steps: git mv, gnrt vendor, gnrt gen
    FinishUpdatingCrate(args, title, diff)

    # Remove old `//third_party/rust/foo/v<old>` directories
    print(f"  Removing //third_party/rust/.../<old_epoch> ...")
    for update in diff.updates:
        old_target_dir = ConvertCrateIdToEpochDir(update.old_crate_id)
        new_target_dir = ConvertCrateIdToEpochDir(update.new_crate_id)
        if old_target_dir == new_target_dir:
            continue  # Skip minor crate updates

        old_files_count = len(
            Git("ls-files", "--", old_target_dir).splitlines())
        new_files_count = len(
            Git("ls-files", "--", new_target_dir).splitlines())
        if old_files_count == new_files_count:
            Git("rm", "-r", "--force", "--", old_target_dir)
        elif old_files_count > new_files_count:
            print(f"WARNING: not deleting {old_target_dir} "\
                   "because it contains extra files")
        else:
            print(
                f"WARNING: {old_target_dir} unexpectedly has less files "\
                f"than {new_target_dir}")
    GitCommit(args, "Removing //third_party/rust/.../<old_epoch>")

    # Fix up the target names
    print(f"  Updating the target name in BUILD.gn files...")
    for update in diff.updates:
        old_target = ConvertCrateIdToGnLabel(update.old_crate_id)
        new_target = ConvertCrateIdToGnLabel(update.new_crate_id)
        if old_target == new_target: continue
        grep = Git("grep", "-l", old_target, "--", "*/BUILD.gn")
        for path in grep.splitlines():
            if not path: continue
            if "third_party/rust" in path: continue
            with open(path, 'r') as file:
                file_contents = file.read()
            file_contents = file_contents.replace(old_target, new_target)
            with open(path, 'w') as file:
                file.write(file_contents)
            Git("add", "--", path)
    GitCommit(args, "Updating the target name in BUILD.gn files")


def main():
    parser = argparse.ArgumentParser(description="Update Rust crates")
    parser.add_argument("--no-upload",
                        dest='upload',
                        default=True,
                        action='store_false',
                        help="Avoids uploading CLs to Gerrit")
    parser.add_argument("--verbose", action='store_true')
    subparsers = parser.add_subparsers(required=False)

    parser_auto = subparsers.add_parser(
        "auto", description="Automatically update minor version of all crates")
    parser_auto.set_defaults(func=AutoUpdate)
    parser_auto.add_argument(
        "--upstream-branch",
        default="origin/main",
        help="The upstream branch on which to base the series of CLs.")
    parser_auto.add_argument("--skip",
                             nargs="+",
                             help="Skip updating this crate name.")

    parser_single = subparsers.add_parser(
        "single",
        description="Automatically update minor version of a single crate")
    parser_single.set_defaults(func=SingleCrate)
    parser_single.add_argument("crate",
                               nargs=1,
                               help="The name of the crate to update.")
    parser_single.add_argument(
        "--upstream-branch",
        default="origin/main",
        help="The upstream branch on which to base the update CL.")

    parser_manual = subparsers.add_parser(
        "manual",
        description="Generate update CL after manual edit of `Cargo.toml`")
    parser_manual.set_defaults(func=ManualUpdate)
    parser_manual.add_argument("--title",
                               required=True,
                               help="The first line of CL description.")

    args = parser.parse_args()
    if "func" not in args:
        msg = "ERROR: No auto/single/manual mode specified"
        print(msg)
        parser.print_help()
        raise RuntimeError(msg)

    global g_is_verbose
    g_is_verbose = args.verbose

    args.func(args)

    return 0


if __name__ == '__main__':
    sys.exit(main())