Files

577 lines
19 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# Runs a cache-aware Cargo Hack check for all crates in the workspace.
import json
import dataclasses
2026-04-09 11:14:05 +02:00
import argparse
import os
from collections import namedtuple
from pathlib import Path
from sys import stderr
from typing import List, Dict, Set, Iterable, TypedDict, Any, Optional
import heapq
import subprocess
import re
2026-04-09 11:14:05 +02:00
import time
from ui import CargoHackDashboard
#################
### Constants ###
#################
# TODO: Parametrize
# Improvement: These constants are fragile. They rely on:
# - WORKSPACE_ROOT pointing to the root of the workspace.
# - HASH_SCRIPT being in the same directory as this script.
# If these prerequisites are not met, the script will not behave as expected.
2026-01-26 11:25:36 +01:00
# Moving these to parameters would be safer.
CURRENT_FILE_DIRECTORY = Path(__file__).parent.resolve()
WORKSPACE_ROOT = CURRENT_FILE_DIRECTORY.parent.parent
WORKSPACE_CARGO_LOCK = WORKSPACE_ROOT / "Cargo.lock"
CACHE_DIRECTORY = WORKSPACE_ROOT / ".cache/cargo-hack-check"
COMPUTE_CREATE_HASH_SCRIPT = CURRENT_FILE_DIRECTORY / "compute_crate_hash.sh"
TAG = "[Cargo Hack Powerset]"
STRICT_WARNING_FLAG = "deny"
###############
### Helpers ###
###############
def with_tag(message: str) -> str:
return f"{TAG} {message}"
def with_indent(message: str, indent_level: int = 1, bullet: str = "-") -> str:
indent = " " * indent_level
return f"{indent}{bullet} {message}"
2026-04-09 11:14:05 +02:00
def ensure_cache_directory_exists():
CACHE_DIRECTORY.mkdir(parents=True, exist_ok=True)
def normalize_path_to_workspace_root(str_path: str) -> Path:
path = Path(str_path)
if not path.is_absolute():
path = WORKSPACE_ROOT / path
return path.resolve()
2026-04-09 11:14:05 +02:00
def build_cargo_environment() -> Dict[str, str]:
env = os.environ.copy()
rustflags = env.get("CARGO_BUILD_WARNINGS", "").strip()
2026-04-09 11:14:05 +02:00
if STRICT_WARNING_FLAG not in rustflags:
env["CARGO_BUILD_WARNINGS"] = f"{rustflags} {STRICT_WARNING_FLAG}".strip()
2026-04-09 11:14:05 +02:00
return env
###################################################### Workspace #######################################################
#############
### Types ###
#############
WorkspaceMemberHeapItem = namedtuple("WorkspaceMemberHeapItem", ["key", "member"])
@dataclasses.dataclass
class WorkspaceMember:
id: str
name: str # Improvement: Ensure these are unique among WorkspaceMember instances
manifest_path: Path
dependency_ids: Set[str] = dataclasses.field(default_factory=set)
def __hash__(self):
return hash(self.id)
def __eq__(self, other: "WorkspaceMember | Any"):
if not isinstance(other, WorkspaceMember):
raise TypeError("Comparison is only supported between WorkspaceMember instances.")
return self.id == other.id
def get_heap_key(self) -> str:
return self.name
def as_heap_item(self) -> WorkspaceMemberHeapItem:
return WorkspaceMemberHeapItem(self.get_heap_key(), self)
@property
def manifest_path_posix(self) -> str:
return self.manifest_path.as_posix()
### Caching ###
def get_cache_path(self) -> Path:
return CACHE_DIRECTORY / f"{self.name}.key"
def compute_cache_key(self) -> str:
manifest = Path(self.manifest_path)
return compute_crate_hash(manifest.parent)
def save_cache_key(self, cache_key: str):
with self.get_cache_path().open("w") as file:
file.write(cache_key)
def load_cache_key(self) -> Optional[str]:
try:
with self.get_cache_path().open("r") as file:
return file.read().strip()
except FileNotFoundError:
return None
def is_cache_valid(self, current_cache_key: str) -> bool:
return self.load_cache_key() == current_cache_key
@dataclasses.dataclass
class Workspace:
members: List[WorkspaceMember]
dependencies_dispatcher: Dict[str, Set[WorkspaceMember]]
dependents_dispatcher: Dict[str, Set[WorkspaceMember]]
def __post_init__(self):
assert len(self.members) == len(self.dependencies_dispatcher) == len(self.dependents_dispatcher), "Inconsistent workspace data."
class DepsKindEntry(TypedDict, total=False):
kind: Optional[str]
target: Optional[str]
class DepsEntry(TypedDict):
name: str
pkg: str
dep_kinds: List[DepsKindEntry]
#############
### Cargo ###
#############
def run_cargo_metadata() -> dict:
result = subprocess.run(
["cargo", "metadata", "--format-version", "1"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
return json.loads(result.stdout)
#################
### Workspace ###
#################
def build_workspace_members(workspace_nodes: Iterable[dict], workspace_package_dispatcher: Dict[str, dict]) -> Iterable[WorkspaceMember]:
return (
WorkspaceMember(
id=node["id"],
name=workspace_package_dispatcher[node["id"]]["name"],
manifest_path=normalize_path_to_workspace_root(workspace_package_dispatcher[node["id"]]["manifest_path"]),
dependency_ids={dependency["pkg"] for dependency in filter_workspace_dependencies(node, workspace_package_dispatcher)},
)
for node in workspace_nodes
)
def filter_workspace_nodes(metadata_nodes: List[dict], workspace_member_ids: Set[str]) -> Iterable[dict]:
return (
node
for node in metadata_nodes
if node["id"] in workspace_member_ids
)
def is_dev_only_dependency(dependency_entry: DepsEntry) -> bool:
"""
Return True if this dependency is *exclusively* a dev-dependency.
"""
kinds: List[DepsKindEntry] = dependency_entry.get("dep_kinds", [])
if not kinds:
return False
return all(kind.get("kind") == "dev" for kind in kinds)
def filter_workspace_dependencies(node: dict, workspace_package_dispatcher: Dict[str, dict]) -> Iterable[DepsEntry]:
"""
Filter dependencies to include only those that are workspace members and not dev-only dependencies.
"""
workspace_member_ids = workspace_package_dispatcher.keys()
dependencies: Iterable[DepsEntry] = node.get("deps", [])
return (
dependency
for dependency in dependencies
if dependency["pkg"] in workspace_member_ids and not is_dev_only_dependency(dependency)
)
def filter_workspace_members(metadata: dict) -> List[WorkspaceMember]:
workspace_member_ids = set(metadata["workspace_members"])
metadata_nodes = metadata["resolve"]["nodes"]
workspace_nodes = filter_workspace_nodes(metadata_nodes, workspace_member_ids)
workspace_package_dispatcher = {
package["id"]: package
for package in metadata["packages"]
if package["id"] in workspace_member_ids
}
return list(build_workspace_members(workspace_nodes, workspace_package_dispatcher))
def build_dependencies_dispatcher(members_dispatcher: Dict[str, WorkspaceMember]) -> Dict[str, Set[WorkspaceMember]]:
return {
member.id: { members_dispatcher[dependency_id] for dependency_id in member.dependency_ids }
for member in members_dispatcher.values()
}
def build_dependents_dispatcher(members_dispatcher: Dict[str, WorkspaceMember]) -> Dict[str, Set[WorkspaceMember]]:
dependents_dispatcher = {member_id: set() for member_id in members_dispatcher} # Preseed so there's one entry per member
for member in members_dispatcher.values():
for dependency_id in member.dependency_ids:
dependents_dispatcher[dependency_id].add(member)
return dependents_dispatcher
def build_workspace(metadata: dict) -> Workspace:
members: List[WorkspaceMember] = filter_workspace_members(metadata)
members_dispatcher = {member.id: member for member in members}
# Improvement: Assert both dispatchers' keys are equal to the workspace member IDs.
dependencies_dispatcher = build_dependencies_dispatcher(members_dispatcher)
dependents_dispatcher = build_dependents_dispatcher(members_dispatcher)
return Workspace(
members=members,
dependencies_dispatcher=dependencies_dispatcher,
dependents_dispatcher=dependents_dispatcher,
)
def sort_workspace_topologically(workspace: Workspace) -> Workspace:
"""
:param workspace: Mutable, sorted in place by topological order.
"""
# Clone the dependencies to avoid mutating the original data
member_id: str
member_dependencies: Set[WorkspaceMember]
remaining_dependencies: Dict[str, Set[WorkspaceMember]] = {
member_id: set(member_dependencies)
for member_id, member_dependencies in workspace.dependencies_dispatcher.items()
}
def degree(member: WorkspaceMember) -> int:
return len(remaining_dependencies[member.id])
zero_degree_member_heap = [
member.as_heap_item() for member in workspace.members if degree(member) == 0
]
heapq.heapify(zero_degree_member_heap)
sorted_members: List[WorkspaceMember] = []
while zero_degree_member_heap:
item: WorkspaceMemberHeapItem = heapq.heappop(zero_degree_member_heap)
member: WorkspaceMember = item.member
member_dependents = workspace.dependents_dispatcher[member.id]
for dependent in member_dependents:
remaining_dependencies[dependent.id].remove(member)
if degree(dependent) == 0:
heapq.heappush(zero_degree_member_heap, dependent.as_heap_item())
sorted_members.append(item.member)
if len(sorted_members) != len(workspace.members):
print(with_tag("Warning: cycle detected in workspace dependencies"), file=stderr)
workspace.members = sorted_members
return workspace
def get_workspace_sorted_topologically() -> Workspace:
metadata = run_cargo_metadata()
workspace = build_workspace(metadata)
return sort_workspace_topologically(workspace)
##################################################### Cargo Hack #######################################################
#############
### Types ###
#############
class CargoHackCheckEntry:
_MANIFEST_RE = re.compile(r"--manifest-path\s+(\S+)")
def __init__(self, cmd_line: str, manifest_path: str):
self.cmd_line = cmd_line
self.manifest_path: Path = normalize_path_to_workspace_root(manifest_path)
@property
def manifest_path_posix(self) -> str:
return self.manifest_path.as_posix()
@classmethod
def from_command_line(cls, cmd_line: str) -> "CargoHackCheckEntry":
manifest_path = cls._MANIFEST_RE.search(cmd_line)
if manifest_path:
return cls(cmd_line, manifest_path.group(1))
raise ValueError(f"No manifest path was found in command: {cmd_line}.")
def __str__(self):
return f"CargoHackCommand({self.manifest_path})"
def __repr__(self):
return f"CargoHackCommand(cmd_line={self.cmd_line}, manifest_path={self.manifest_path})"
def __hash__(self):
return hash(self.manifest_path_posix)
def as_feature_powerset_command(self) -> List[str]:
# Changing this doesn't void the cache.
# TODO: Consider adding the command line options to the cache key.
return [
"cargo", "hack", "check", "--feature-powerset", "--all-targets", "--manifest-path", self.manifest_path_posix
]
class CargoHackCheckCommand:
2026-04-09 11:14:05 +02:00
def __init__(self, entry, member, dependents):
self.entry = entry
self.member = member
2026-04-09 11:14:05 +02:00
self.dependents = dependents
@property
2026-04-09 11:14:05 +02:00
def crate_name(self):
return self.member.name
2026-04-09 11:14:05 +02:00
def run(self, dashboard) -> int:
current_cache_key = self.member.compute_cache_key()
2026-04-09 11:14:05 +02:00
if self.member.is_cache_valid(current_cache_key):
2026-04-09 11:14:05 +02:00
dashboard.log_crate_detail(self.crate_name, "Cache is valid, skipping.")
return 0
2026-04-09 11:14:05 +02:00
dashboard.log_crate_detail(self.crate_name, "Running...")
result = subprocess.run(
self.entry.as_feature_powerset_command(),
capture_output=True,
text=True,
check=False,
env=build_cargo_environment(),
)
if result.returncode == 0:
2026-04-09 11:14:05 +02:00
self.handle_success(dashboard, current_cache_key)
else:
2026-04-09 11:14:05 +02:00
self.handle_failure(dashboard, result)
return result.returncode
2026-04-09 11:14:05 +02:00
# ----------------------------------------------------------
def handle_success(self, dashboard, current_cache_key: str):
dashboard.log_crate_detail(self.crate_name, "Succeeded.")
dashboard.log_crate_detail(self.crate_name, "Updating cache...")
self.member.save_cache_key(current_cache_key)
2026-04-09 11:14:05 +02:00
dashboard.log_crate_detail(self.crate_name, "Invalidating dependents...")
2026-04-09 11:14:05 +02:00
self.invalidate_dependents(dashboard)
dashboard.log_crate_detail(self.crate_name, "Done.")
# ----------------------------------------------------------
def handle_failure(self, dashboard, result):
dashboard.log_crate_detail(self.crate_name, "Failed.")
dashboard.log(result.stdout)
dashboard.log(result.stderr)
# ----------------------------------------------------------
def invalidate_dependents(self, dashboard):
if not self.dependents:
dashboard.log_crate_detail(self.crate_name, "No dependents to invalidate.")
return
2026-04-09 11:14:05 +02:00
removed = 0
missing = 0
for dependent in self.dependents:
try:
dependent.get_cache_path().unlink(missing_ok=False)
2026-04-09 11:14:05 +02:00
removed += 1
if dashboard.verbose:
dashboard.log(
with_indent(
f"invalidated dependent cache for {dependent.name}.",
4,
)
)
except FileNotFoundError:
2026-04-09 11:14:05 +02:00
missing += 1
if dashboard.verbose:
dashboard.log(
with_indent(
f"no dependent cache entry existed for {dependent.name}.",
4,
)
)
dashboard.log_cache_invalidation_summary(removed, missing)
#################
### Functions ###
#################
def compute_crate_hash(crate_directory: Path) -> str:
result = subprocess.run(
[COMPUTE_CREATE_HASH_SCRIPT, crate_directory, WORKSPACE_CARGO_LOCK],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def list_cargo_hack_check_commands() -> Set[CargoHackCheckEntry]:
result = subprocess.run(
["cargo", "hack", "check", "--no-dev-deps", "--print-command-list"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
2026-04-09 11:14:05 +02:00
env=build_cargo_environment(),
)
return {CargoHackCheckEntry.from_command_line(line) for line in result.stdout.splitlines()}
def build_cargo_hack_commands_sorted_topologically() -> List[CargoHackCheckCommand]:
commands = list_cargo_hack_check_commands()
command_dispatcher = {command.manifest_path_posix: command for command in commands}
workspace = get_workspace_sorted_topologically()
sorted_commands = [
CargoHackCheckCommand(
command_dispatcher[member.manifest_path.as_posix()],
member,
workspace.dependents_dispatcher[member.id]
)
for member in workspace.members
]
assert len(sorted_commands) == len(commands), "Some commands are missing in the sorted list. Either the workspace members or commands are inconsistent."
return sorted_commands
######################################################## Main ##########################################################
2026-04-09 11:14:05 +02:00
# def main():
# sorted_commands = build_cargo_hack_commands_sorted_topologically()
# ensure_cache_directory_exists()
# for command in sorted_commands:
# return_code = command.run()
# if return_code != 0:
# # Save time by exiting early since dependent crates cannot be trusted.
# return return_code
# return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Run cache-aware cargo-hack feature powerset checks for workspace crates.",
)
output_mode = parser.add_mutually_exclusive_group()
output_mode.add_argument(
"--interactive",
action="store_true",
dest="interactive",
help="Force interactive output even if terminal auto-detection would disable it.",
)
output_mode.add_argument(
"--plain",
action="store_true",
dest="plain",
help="Force plain line-based output even if terminal auto-detection would enable interactive output.",
)
parser.add_argument(
"--continue-on-failure",
action="store_true",
help="Continue checking remaining crates after a failure; exit non-zero if any crate fails.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show detailed cache invalidation output for each dependent crate.",
)
return parser.parse_args()
def main(args: argparse.Namespace):
sorted_commands = build_cargo_hack_commands_sorted_topologically()
ensure_cache_directory_exists()
2026-04-09 11:14:05 +02:00
rich_enabled = True if args.interactive else False if args.plain else None
max_crate_name_width = max((len(command.crate_name) for command in sorted_commands), default=0)
dashboard = CargoHackDashboard(
len(sorted_commands),
TAG,
max_crate_name_width=max_crate_name_width,
rich_enabled=rich_enabled,
verbose=args.verbose,
)
failed_crates: List[str] = []
try:
for i, command in enumerate(sorted_commands, start=1):
cache_key = command.member.compute_cache_key()
was_cached = command.member.is_cache_valid(cache_key)
dashboard.start_crate(command.crate_name, i)
crate_started_at = time.monotonic()
rc = command.run(dashboard)
crate_elapsed = time.monotonic() - crate_started_at
dashboard.finish_crate(
crate_name=command.crate_name,
index=i,
skipped=was_cached,
success=(rc == 0 and not was_cached),
crate_elapsed=crate_elapsed,
)
if rc != 0:
failed_crates.append(command.crate_name)
if not args.continue_on_failure:
dashboard.fail(command.crate_name)
dashboard.print_summary(failed_crates, stopped_early=True)
return rc
dashboard.finish()
dashboard.print_summary(failed_crates)
if failed_crates:
return 1
return 0
except KeyboardInterrupt:
dashboard.interrupt()
dashboard.close()
dashboard.print_summary(failed_crates, interrupted=True)
return 130
finally:
dashboard.close()
if __name__ == "__main__":
2026-04-09 11:14:05 +02:00
status = main(parse_args())
# TODO: Return different exit code for "everything skipped", to avoid saving cache again.
exit(status)