Skip to content

Commit bce5042

Browse files
committed
test(cargo): add unit tests for cargo version updates
1 parent 27dd1f8 commit bce5042

2 files changed

Lines changed: 323 additions & 0 deletions

File tree

tests/unit/test_version.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
VersionArgs,
1111
VersionChoice,
1212
_apply_version_choice,
13+
_find_cargo_lock_for,
14+
_get_cargo_package_name,
1315
_get_default_bump_from_commits,
1416
_handle_explicit_version_args,
1517
_handle_interactive_version_selection,
@@ -37,6 +39,7 @@
3739
get_version_from_version_txt,
3840
handle_version,
3941
show_file_diff,
42+
update_cargo_lock_version,
4043
update_cargo_toml_version,
4144
update_file,
4245
update_version_files,
@@ -941,6 +944,239 @@ def test_update_cargo_toml_version_file_not_exists(self, tmp_path):
941944
update_cargo_toml_version(non_existent_file, "1.0.0", 0, show_diff=False)
942945

943946

947+
class TestGetCargoPackageName:
948+
"""Test cases for _get_cargo_package_name."""
949+
950+
def test_basic(self, tmp_path):
951+
cargo_toml = tmp_path / "Cargo.toml"
952+
cargo_toml.write_text('[package]\nname = "arthash"\nversion = "0.2.0"\n')
953+
assert _get_cargo_package_name(cargo_toml) == "arthash"
954+
955+
def test_missing_file(self, tmp_path):
956+
assert _get_cargo_package_name(tmp_path / "Cargo.toml") is None
957+
958+
def test_missing_package_section(self, tmp_path):
959+
cargo_toml = tmp_path / "Cargo.toml"
960+
cargo_toml.write_text('[workspace]\nmembers = ["a"]\n')
961+
assert _get_cargo_package_name(cargo_toml) is None
962+
963+
def test_missing_name_key(self, tmp_path):
964+
cargo_toml = tmp_path / "Cargo.toml"
965+
cargo_toml.write_text('[package]\nversion = "0.1.0"\n')
966+
assert _get_cargo_package_name(cargo_toml) is None
967+
968+
def test_empty_name(self, tmp_path):
969+
cargo_toml = tmp_path / "Cargo.toml"
970+
cargo_toml.write_text('[package]\nname = ""\nversion = "0.1.0"\n')
971+
assert _get_cargo_package_name(cargo_toml) is None
972+
973+
def test_invalid_toml(self, tmp_path):
974+
cargo_toml = tmp_path / "Cargo.toml"
975+
cargo_toml.write_text("this is not valid toml [[[")
976+
assert _get_cargo_package_name(cargo_toml) is None
977+
978+
979+
class TestFindCargoLockFor:
980+
"""Test cases for _find_cargo_lock_for."""
981+
982+
def test_same_directory(self, tmp_path):
983+
"""Single-crate layout: Cargo.toml and Cargo.lock side-by-side."""
984+
(tmp_path / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
985+
lockfile = tmp_path / "Cargo.lock"
986+
lockfile.write_text("# lock\n")
987+
assert _find_cargo_lock_for(tmp_path) == lockfile
988+
989+
def test_parent_directory(self, tmp_path):
990+
"""Workspace layout: lockfile lives at workspace root."""
991+
(tmp_path / ".git").mkdir()
992+
lockfile = tmp_path / "Cargo.lock"
993+
lockfile.write_text("# lock\n")
994+
member = tmp_path / "crates" / "alpha"
995+
member.mkdir(parents=True)
996+
(member / "Cargo.toml").write_text('[package]\nname = "alpha"\nversion = "0.1.0"\n')
997+
assert _find_cargo_lock_for(member) == lockfile
998+
999+
def test_no_lockfile_returns_none(self, tmp_path):
1000+
(tmp_path / ".git").mkdir()
1001+
crate = tmp_path / "crate"
1002+
crate.mkdir()
1003+
(crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1004+
assert _find_cargo_lock_for(crate) is None
1005+
1006+
def test_stops_at_git_root(self, tmp_path):
1007+
"""Must not escape the current repo, even if an outer Cargo.lock exists."""
1008+
outer_lock = tmp_path / "Cargo.lock"
1009+
outer_lock.write_text("# outer\n")
1010+
repo = tmp_path / "inner_repo"
1011+
repo.mkdir()
1012+
(repo / ".git").mkdir()
1013+
crate = repo / "crate"
1014+
crate.mkdir()
1015+
(crate / "Cargo.toml").write_text('[package]\nname = "x"\nversion = "0.1.0"\n')
1016+
# Should NOT find the outer Cargo.lock — that's in a different repo.
1017+
assert _find_cargo_lock_for(crate) is None
1018+
1019+
1020+
class TestUpdateCargoLockVersion:
1021+
"""Test cases for update_cargo_lock_version."""
1022+
1023+
@staticmethod
1024+
def _make_lockfile(tmp_path: Path, content: str) -> Path:
1025+
lockfile = tmp_path / "Cargo.lock"
1026+
lockfile.write_text(content, encoding="utf-8")
1027+
return lockfile
1028+
1029+
def test_local_crate_version_updated(self, tmp_path):
1030+
"""Workspace member (no source field) gets its version rewritten."""
1031+
content = (
1032+
"# This file is automatically @generated by Cargo.\n"
1033+
"version = 4\n\n"
1034+
"[[package]]\n"
1035+
'name = "arthash"\n'
1036+
'version = "0.2.0"\n'
1037+
"dependencies = [\n"
1038+
' "matrixmultiply",\n'
1039+
"]\n"
1040+
)
1041+
lockfile = self._make_lockfile(tmp_path, content)
1042+
update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1043+
new_content = lockfile.read_text()
1044+
assert 'version = "0.3.0"' in new_content
1045+
assert 'version = "0.2.0"' not in new_content
1046+
1047+
def test_registry_dep_with_same_name_not_touched(self, tmp_path):
1048+
"""A registry dep named identically to the local crate must NOT be hit.
1049+
1050+
Registry entries have `source = "registry+..."` between `name` and
1051+
`version`, so they fail the "name immediately followed by version"
1052+
anchor. Local crates fit the anchor exactly.
1053+
"""
1054+
content = (
1055+
"[[package]]\n"
1056+
'name = "arthash"\n'
1057+
'source = "registry+https://github.com/rust-lang/crates.io-index"\n'
1058+
'version = "9.9.9"\n'
1059+
'checksum = "deadbeef"\n\n'
1060+
"[[package]]\n"
1061+
'name = "arthash"\n'
1062+
'version = "0.2.0"\n'
1063+
"dependencies = [\n"
1064+
' "matrixmultiply",\n'
1065+
"]\n"
1066+
)
1067+
lockfile = self._make_lockfile(tmp_path, content)
1068+
update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1069+
new_content = lockfile.read_text()
1070+
# registry entry untouched
1071+
assert 'version = "9.9.9"' in new_content
1072+
# local entry bumped
1073+
assert 'version = "0.3.0"' in new_content
1074+
# only one occurrence of the new version (didn't accidentally double-write)
1075+
assert new_content.count('version = "0.3.0"') == 1
1076+
1077+
def test_crate_not_in_lockfile_is_noop(self, tmp_path):
1078+
"""If the crate isn't recorded, nothing changes."""
1079+
content = (
1080+
"[[package]]\n"
1081+
'name = "other"\n'
1082+
'version = "1.0.0"\n'
1083+
)
1084+
lockfile = self._make_lockfile(tmp_path, content)
1085+
update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1086+
assert lockfile.read_text() == content
1087+
1088+
def test_missing_lockfile_is_noop(self, tmp_path):
1089+
"""Pure non-Rust projects don't have a Cargo.lock — must not error."""
1090+
lockfile = tmp_path / "Cargo.lock"
1091+
# Should not raise.
1092+
update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1093+
assert not lockfile.exists()
1094+
1095+
def test_already_in_sync_is_noop(self, tmp_path):
1096+
"""Idempotent: same version in -> file unchanged."""
1097+
content = (
1098+
"[[package]]\n"
1099+
'name = "arthash"\n'
1100+
'version = "0.3.0"\n'
1101+
)
1102+
lockfile = self._make_lockfile(tmp_path, content)
1103+
before = lockfile.stat().st_mtime_ns
1104+
update_cargo_lock_version("arthash", "0.3.0", lockfile, 0, show_diff=False)
1105+
# Content identical; we explicitly skip the write in that case.
1106+
assert lockfile.read_text() == content
1107+
# And the file was not rewritten (mtime preserved). Some filesystems
1108+
# have coarse mtime resolution, so this guard is a tightening only —
1109+
# the content check above is the real assertion.
1110+
assert lockfile.stat().st_mtime_ns == before
1111+
1112+
1113+
class TestUpdateVersionInFileCargoIntegration:
1114+
"""update_version_in_file should sync Cargo.lock when it sees Cargo.toml."""
1115+
1116+
def test_cargo_toml_branch_syncs_sibling_lockfile(self, tmp_path):
1117+
cargo_toml = tmp_path / "Cargo.toml"
1118+
cargo_toml.write_text(
1119+
"[package]\n"
1120+
'name = "arthash"\n'
1121+
'version = "0.2.0"\n'
1122+
"\n"
1123+
"[dependencies]\n"
1124+
'matrixmultiply = "0.3"\n',
1125+
encoding="utf-8",
1126+
)
1127+
lockfile = tmp_path / "Cargo.lock"
1128+
lockfile.write_text(
1129+
"[[package]]\n"
1130+
'name = "arthash"\n'
1131+
'version = "0.2.0"\n'
1132+
"dependencies = [\n"
1133+
' "matrixmultiply",\n'
1134+
"]\n",
1135+
encoding="utf-8",
1136+
)
1137+
update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1138+
# Both files should now report 0.3.0.
1139+
assert 'version = "0.3.0"' in cargo_toml.read_text()
1140+
assert 'version = "0.3.0"' in lockfile.read_text()
1141+
1142+
def test_cargo_toml_branch_syncs_workspace_root_lockfile(self, tmp_path):
1143+
"""Lockfile in workspace root, Cargo.toml in a member directory."""
1144+
(tmp_path / ".git").mkdir()
1145+
lockfile = tmp_path / "Cargo.lock"
1146+
lockfile.write_text(
1147+
"[[package]]\n"
1148+
'name = "alpha"\n'
1149+
'version = "0.2.0"\n',
1150+
encoding="utf-8",
1151+
)
1152+
member_dir = tmp_path / "crates" / "alpha"
1153+
member_dir.mkdir(parents=True)
1154+
cargo_toml = member_dir / "Cargo.toml"
1155+
cargo_toml.write_text(
1156+
"[package]\n"
1157+
'name = "alpha"\n'
1158+
'version = "0.2.0"\n',
1159+
encoding="utf-8",
1160+
)
1161+
update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1162+
assert 'version = "0.3.0"' in cargo_toml.read_text()
1163+
assert 'version = "0.3.0"' in lockfile.read_text()
1164+
1165+
def test_cargo_toml_branch_no_lockfile_present(self, tmp_path):
1166+
"""When there's no lockfile at all, the manifest update still proceeds."""
1167+
(tmp_path / ".git").mkdir()
1168+
cargo_toml = tmp_path / "Cargo.toml"
1169+
cargo_toml.write_text(
1170+
"[package]\n"
1171+
'name = "alpha"\n'
1172+
'version = "0.2.0"\n',
1173+
encoding="utf-8",
1174+
)
1175+
# Should not raise.
1176+
update_version_in_file(0, "0.3.0", "Cargo.toml", cargo_toml, show_diff=False)
1177+
assert 'version = "0.3.0"' in cargo_toml.read_text()
1178+
1179+
9441180
class TestParseGitignore:
9451181
"""Test cases for _parse_gitignore function."""
9461182

tgit/version.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,7 +875,18 @@ def update_version_in_file(verbose: int, next_version_str: str, file: str, file_
875875
elif file == "setup.py":
876876
update_file(str(file_path), r"version=['\"].*?['\"]", f"version='{next_version_str}'", verbose, show_diff=show_diff)
877877
elif file == "Cargo.toml":
878+
crate_name = _get_cargo_package_name(file_path)
878879
update_cargo_toml_version(str(file_path), next_version_str, verbose, show_diff=show_diff)
880+
# Cargo.lock pins every workspace member's version. Without
881+
# syncing it here, `cargo publish` and `cargo build --locked`
882+
# refuse to run against a manifest that disagrees with the
883+
# lockfile — the most common reason a release tag gets bumped
884+
# but the publish CI step fails on "working directory contains
885+
# changes". Idempotent / no-op for pure non-Rust projects.
886+
if crate_name:
887+
lockfile = _find_cargo_lock_for(file_path.parent)
888+
if lockfile is not None:
889+
update_cargo_lock_version(crate_name, next_version_str, lockfile, verbose, show_diff=show_diff)
879890
elif file in ("VERSION", "VERSION.txt"):
880891
update_file(str(file_path), None, next_version_str, verbose, show_diff=show_diff)
881892
elif file in ("__about__.py", "__init__.py"):
@@ -958,6 +969,82 @@ def replace_version(match: re.Match[str]) -> str:
958969
f.write(new_content)
959970

960971

972+
def _get_cargo_package_name(cargo_toml_path: Path) -> str | None:
973+
"""Read [package].name from a Cargo.toml. Returns None if missing/invalid.
974+
975+
Used to anchor lockfile rewrites — we need the crate's own name to
976+
find its entry in Cargo.lock.
977+
"""
978+
if not cargo_toml_path.is_file():
979+
return None
980+
try:
981+
with cargo_toml_path.open("rb") as f:
982+
data = tomllib.load(f)
983+
except (OSError, tomllib.TOMLDecodeError):
984+
return None
985+
package = data.get("package")
986+
if not isinstance(package, dict):
987+
return None
988+
name = package.get("name")
989+
return name if isinstance(name, str) and name else None
990+
991+
992+
def _find_cargo_lock_for(cargo_toml_dir: Path) -> Path | None:
993+
"""Walk up from a Cargo.toml's directory looking for a Cargo.lock.
994+
995+
Covers both layouts:
996+
* single crate with its own Cargo.lock (lockfile in same dir)
997+
* cargo workspace where members share a root-level Cargo.lock
998+
999+
Stops at the directory containing .git (we never escape the current
1000+
repo) or at the filesystem root.
1001+
"""
1002+
current = cargo_toml_dir.resolve()
1003+
while True:
1004+
candidate = current / "Cargo.lock"
1005+
if candidate.is_file():
1006+
return candidate
1007+
if (current / ".git").exists():
1008+
return None
1009+
if current.parent == current:
1010+
return None
1011+
current = current.parent
1012+
1013+
1014+
def update_cargo_lock_version(
1015+
crate_name: str,
1016+
next_version_str: str,
1017+
lockfile_path: Path,
1018+
verbose: int,
1019+
*,
1020+
show_diff: bool = True,
1021+
) -> None:
1022+
"""Sync a local crate's version in Cargo.lock.
1023+
1024+
Cargo.lock records every dep, but registry deps carry a
1025+
`source = "registry+..."` line between `name` and `version`.
1026+
Workspace members / path deps do NOT — their `version` is on the
1027+
line immediately after `name`. We anchor on that two-line shape so
1028+
registry deps with the same name as a local crate can't be hit by
1029+
mistake. No-op if the crate isn't in this lockfile or is already
1030+
in sync.
1031+
"""
1032+
if not lockfile_path.is_file():
1033+
return
1034+
if verbose > 0:
1035+
console.print(f"Updating {lockfile_path}")
1036+
content = lockfile_path.read_text(encoding="utf-8")
1037+
pattern = re.compile(
1038+
rf'(\[\[package\]\]\nname = "{re.escape(crate_name)}"\nversion = ")[^"]+(")',
1039+
)
1040+
new_content, n = pattern.subn(rf"\g<1>{next_version_str}\g<2>", content, count=1)
1041+
if n == 0 or new_content == content:
1042+
return
1043+
if show_diff:
1044+
show_file_diff(content, new_content, str(lockfile_path))
1045+
lockfile_path.write_text(new_content, encoding="utf-8")
1046+
1047+
9611048
def show_file_diff(old_content: str, new_content: str, filename: str) -> None:
9621049
old_lines = old_content.splitlines()
9631050
new_lines = new_content.splitlines()

0 commit comments

Comments
 (0)