diff --git a/src/pkgmgr/actions/release/files/__init__.py b/src/pkgmgr/actions/release/files/__init__.py index e09f468..b96b347 100644 --- a/src/pkgmgr/actions/release/files/__init__.py +++ b/src/pkgmgr/actions/release/files/__init__.py @@ -10,6 +10,7 @@ Keep this package stable so existing imports continue to work, e.g.: from __future__ import annotations +from .cargo import update_cargo_version from .changelog_md import update_changelog from .debian import _get_debian_author, update_debian_changelog from .editor import _open_editor_for_changelog @@ -22,6 +23,7 @@ from .rpm_spec import update_spec_version __all__ = [ "_get_debian_author", "_open_editor_for_changelog", + "update_cargo_version", "update_changelog", "update_debian_changelog", "update_flake_version", diff --git a/src/pkgmgr/actions/release/files/cargo.py b/src/pkgmgr/actions/release/files/cargo.py new file mode 100644 index 0000000..eb62d49 --- /dev/null +++ b/src/pkgmgr/actions/release/files/cargo.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import os +import re + + +def _read(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _write(path: str, content: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def update_cargo_version( + repo_root: str, new_version: str, preview: bool = False +) -> list[str]: + """ + Set the root crate's version in Cargo.toml and its entry in Cargo.lock. + + Args: + repo_root: repository root that may hold Cargo.toml and Cargo.lock. + new_version: release version without the leading "v". + preview: only print what would change. + + Returns: + The paths that were (or in preview would be) changed. + """ + toml_path = os.path.join(repo_root, "Cargo.toml") + if not os.path.exists(toml_path): + print("[INFO] No Cargo.toml found. Skipping Cargo version update.") + return [] + + toml = _read(toml_path) + block = re.search(r"(?ms)^\[package\]\s*$.*?(?=^\s*\[|\Z)", toml) + if not block: + print("[INFO] Cargo.toml has no [package] section. Skipping.") + return [] + + name = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', block.group(0)) + if not name: + raise RuntimeError("Missing name key in [package] section of Cargo.toml") + new_block, count = re.subn( + r'(?m)^(\s*version\s*=\s*")[^"]+(")', + lambda m: f"{m.group(1)}{new_version}{m.group(2)}", + block.group(0), + count=1, + ) + if count == 0: + raise RuntimeError("Missing version key in [package] section of Cargo.toml") + changes = {toml_path: toml[: block.start()] + new_block + toml[block.end() :]} + + lock_path = os.path.join(repo_root, "Cargo.lock") + if os.path.exists(lock_path): + lock, count = re.subn( + rf'(?m)^(\[\[package\]\]\nname = "{re.escape(name.group(1))}"\n' + r'version = ")[^"]+("\n)(?!source = )', + lambda m: f"{m.group(1)}{new_version}{m.group(2)}", + _read(lock_path), + ) + if count != 1: + raise RuntimeError( + f"Expected one local {name.group(1)!r} package in Cargo.lock, " + f"found {count}" + ) + changes[lock_path] = lock + + for path, content in changes.items(): + if preview: + print(f"[PREVIEW] Would update {path} version to {new_version}") + else: + _write(path, content) + print(f"Updated {path} version to {new_version}") + return list(changes) diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index 09f6262..31a58f3 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -10,6 +10,7 @@ from pkgmgr.core.git.queries import get_current_branch from pkgmgr.core.repository.paths import resolve_repo_paths from .files import ( + update_cargo_version, update_changelog, update_debian_changelog, update_flake_version, @@ -74,6 +75,8 @@ def _release_impl( update_flake_version(paths.flake_nix, new_ver_str, preview=preview) + cargo_files = update_cargo_version(repo_root, new_ver_str, preview=preview) + if paths.arch_pkgbuild: update_pkgbuild_version(paths.arch_pkgbuild, new_ver_str, preview=preview) else: @@ -124,6 +127,7 @@ def _release_impl( paths.arch_pkgbuild, paths.rpm_spec, paths.debian_changelog, + *cargo_files, ] existing_files = [ p for p in files_to_add if isinstance(p, str) and p and os.path.exists(p) diff --git a/tests/unit/pkgmgr/actions/release/test_cargo.py b/tests/unit/pkgmgr/actions/release/test_cargo.py new file mode 100644 index 0000000..aa8e30c --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_cargo.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +import tempfile +import unittest + +from pkgmgr.actions.release.files import update_cargo_version + +CARGO_TOML = """[package] +name = "security-check" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1.0.0" } +""" + +CARGO_LOCK = """version = 4 + +[[package]] +name = "security-check" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc" + +[[package]] +name = "security-check" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +""" + + +class TestUpdateCargoVersion(unittest.TestCase): + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory() + self.root = self._tmp.name + + def tearDown(self) -> None: + self._tmp.cleanup() + + def _write(self, name: str, content: str) -> str: + path = os.path.join(self.root, name) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + return path + + def _read(self, name: str) -> str: + with open(os.path.join(self.root, name), encoding="utf-8") as f: + return f.read() + + def test_bumps_package_and_local_lock_entry_only(self) -> None: + toml = self._write("Cargo.toml", CARGO_TOML) + lock = self._write("Cargo.lock", CARGO_LOCK) + + changed = update_cargo_version(self.root, "0.4.0") + + self.assertEqual(changed, [toml, lock]) + self.assertEqual( + self._read("Cargo.toml"), + CARGO_TOML.replace('version = "0.1.0"', 'version = "0.4.0"'), + ) + self.assertEqual( + self._read("Cargo.lock"), + CARGO_LOCK.replace( + 'name = "security-check"\nversion = "0.1.0"\ndependencies', + 'name = "security-check"\nversion = "0.4.0"\ndependencies', + ), + ) + + def test_without_cargo_toml_changes_nothing(self) -> None: + self.assertEqual(update_cargo_version(self.root, "0.4.0"), []) + self.assertEqual(os.listdir(self.root), []) + + def test_workspace_without_package_is_untouched(self) -> None: + workspace = '[workspace]\nmembers = ["a"]\n' + self._write("Cargo.toml", workspace) + + self.assertEqual(update_cargo_version(self.root, "0.4.0"), []) + self.assertEqual(self._read("Cargo.toml"), workspace) + + def test_without_lock_bumps_only_toml(self) -> None: + toml = self._write("Cargo.toml", CARGO_TOML) + + self.assertEqual(update_cargo_version(self.root, "0.4.0"), [toml]) + + def test_preview_writes_nothing(self) -> None: + self._write("Cargo.toml", CARGO_TOML) + self._write("Cargo.lock", CARGO_LOCK) + + update_cargo_version(self.root, "0.4.0", preview=True) + + self.assertEqual(self._read("Cargo.toml"), CARGO_TOML) + self.assertEqual(self._read("Cargo.lock"), CARGO_LOCK) + + def test_lock_without_local_entry_fails_before_writing(self) -> None: + self._write("Cargo.toml", CARGO_TOML) + self._write("Cargo.lock", 'version = 4\n\n[[package]]\nname = "serde"\n') + + with self.assertRaises(RuntimeError): + update_cargo_version(self.root, "0.4.0") + self.assertEqual(self._read("Cargo.toml"), CARGO_TOML) + + +if __name__ == "__main__": + unittest.main()