"""Tests for file-level rename detection in structured_delta. When a file is renamed (deleted at one path, inserted at another with identical blob content), ``CodePlugin.diff()`` must emit a ``RenameOp`` (``op="rename"``) — not a bare ``InsertOp + DeleteOp`` pair. A moved+edited file emits ``RenameOp`` followed by ``PatchOp`` (two orthogonal ops). ``PatchOp`` never carries ``from_address``. """ from __future__ import annotations import json import pathlib from collections.abc import Mapping import pytest from muse.core.types import blob_id from muse.core.object_store import write_object from muse.core.paths import muse_dir from muse.domain import SnapshotManifest from muse.plugins.code.plugin import CodePlugin plugin = CodePlugin() # --------------------------------------------------------------------------- # Repo and snapshot helpers # --------------------------------------------------------------------------- def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Create the minimal .muse directory structure needed for plugin.diff().""" dot_muse = muse_dir(tmp_path) dot_muse.mkdir() (dot_muse / "repo.json").write_text( json.dumps({ "repo_id": "sha256:" + "a" * 64, "domain": "code", "default_branch": "main", "created_at": "2025-01-01T00:00:00+00:00", }), encoding="utf-8", ) (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot_muse / "objects").mkdir() (dot_muse / "snapshots").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "refs" / "heads").mkdir(parents=True) return tmp_path def _snap(root: pathlib.Path, files: Mapping[str, bytes]) -> SnapshotManifest: """Write blobs to the object store and return a SnapshotManifest.""" manifest: dict[str, str] = {} for path, content in files.items(): oid = blob_id(content) write_object(root, oid, content) manifest[path] = oid return SnapshotManifest(files=manifest, domain="code") def _op_types(ops: list[Mapping[str, object]]) -> list[str]: return [str(o["op"]) for o in ops] def _op_addresses(ops: list[Mapping[str, object]]) -> list[str]: return [str(o["address"]) for o in ops] # --------------------------------------------------------------------------- # Core rename detection — blob-identical files # --------------------------------------------------------------------------- class TestBlobIdenticalRename: """A file moved to a new path with no content change must produce a ``RenameOp`` (op="rename"), not a bare insert + delete pair.""" def test_txt_rename_produces_rename_op_not_insert_delete( self, tmp_path: pathlib.Path ) -> None: """Renaming hello.txt → hello.md (identical content) must emit a ``RenameOp`` — not an ``InsertOp`` plus a ``DeleteOp``.""" root = _init_repo(tmp_path) content = b"some plain text content\n" base = _snap(root, {"hello.txt": content}) target = _snap(root, {"hello.md": content}) delta = plugin.diff(base, target, repo_root=root) ops = delta["ops"] rename_ops = [o for o in ops if o["op"] == "rename"] assert rename_ops, f"Expected RenameOp for rename, got ops: {ops}" assert not any(o["op"] == "insert" for o in ops), ( f"InsertOp must not appear for a pure rename, got: {ops}" ) assert not any(o["op"] == "delete" for o in ops), ( f"DeleteOp must not appear for a pure rename, got: {ops}" ) def test_txt_rename_op_has_correct_address( self, tmp_path: pathlib.Path ) -> None: """The RenameOp address must be the new path (hello.md).""" root = _init_repo(tmp_path) content = b"some plain text content\n" base = _snap(root, {"hello.txt": content}) target = _snap(root, {"hello.md": content}) delta = plugin.diff(base, target, repo_root=root) rename_ops = [o for o in delta["ops"] if o["op"] == "rename"] assert len(rename_ops) == 1 assert rename_ops[0]["address"] == "hello.md", ( f"RenameOp address must be 'hello.md', got: {rename_ops[0]['address']!r}" ) def test_txt_rename_op_has_from_address( self, tmp_path: pathlib.Path ) -> None: """The RenameOp must carry ``from_address`` pointing to the old path.""" root = _init_repo(tmp_path) content = b"some plain text content\n" base = _snap(root, {"hello.txt": content}) target = _snap(root, {"hello.md": content}) delta = plugin.diff(base, target, repo_root=root) rename_ops = [o for o in delta["ops"] if o["op"] == "rename"] assert len(rename_ops) == 1 assert rename_ops[0]["from_address"] == "hello.txt", ( f"from_address must be 'hello.txt', got: {rename_ops[0]['from_address']!r}" ) def test_rename_summary_mentions_renamed(self, tmp_path: pathlib.Path) -> None: """The delta summary must describe the operation as a rename, not as an addition plus a removal.""" root = _init_repo(tmp_path) content = b"plain text\n" base = _snap(root, {"hello.txt": content}) target = _snap(root, {"hello.md": content}) delta = plugin.diff(base, target, repo_root=root) assert "renamed" in delta["summary"].lower(), ( f"Summary must mention rename, got: {delta['summary']!r}" ) assert "added" not in delta["summary"].lower(), ( f"Summary must not say 'added' for a rename, got: {delta['summary']!r}" ) assert "removed" not in delta["summary"].lower(), ( f"Summary must not say 'removed' for a rename, got: {delta['summary']!r}" ) def test_no_patch_op_has_from_address(self, tmp_path: pathlib.Path) -> None: """PatchOp must never carry from_address — rename is now RenameOp.""" root = _init_repo(tmp_path) content = b"plain text\n" base = _snap(root, {"hello.txt": content}) target = _snap(root, {"hello.md": content}) delta = plugin.diff(base, target, repo_root=root) for op in delta["ops"]: if op["op"] == "patch": assert "from_address" not in op, ( f"PatchOp at {op['address']} must not carry from_address" ) # --------------------------------------------------------------------------- # Rename of files that DO have symbol trees (regression guard) # --------------------------------------------------------------------------- class TestSymbolFileRename: """Moving a Python file to a new path with identical content must also produce a RenameOp — exercising the existing _detect_file_move_edits symbol-tree path as a regression guard.""" def test_python_file_rename_same_content_produces_rename_op( self, tmp_path: pathlib.Path ) -> None: """Renaming utils.py → helpers.py with identical content must not produce InsertOp + DeleteOp.""" root = _init_repo(tmp_path) content = b"def add(a, b):\n return a + b\n" base = _snap(root, {"utils.py": content}) target = _snap(root, {"helpers.py": content}) delta = plugin.diff(base, target, repo_root=root) bare_inserts = [ o for o in delta["ops"] if o["op"] == "insert" and "::" not in o["address"] ] bare_deletes = [ o for o in delta["ops"] if o["op"] == "delete" and "::" not in o["address"] ] assert not bare_inserts, ( f"No bare file-level InsertOp expected for rename, got: {bare_inserts}" ) assert not bare_deletes, ( f"No bare file-level DeleteOp expected for rename, got: {bare_deletes}" ) def test_python_file_rename_emits_rename_op( self, tmp_path: pathlib.Path ) -> None: """A Python file rename must emit a RenameOp with from_address.""" root = _init_repo(tmp_path) content = b"def add(a, b):\n return a + b\n" base = _snap(root, {"utils.py": content}) target = _snap(root, {"helpers.py": content}) delta = plugin.diff(base, target, repo_root=root) rename_ops = [o for o in delta["ops"] if o["op"] == "rename"] assert any(o["from_address"] == "utils.py" for o in rename_ops), ( f"Expected RenameOp with from_address='utils.py', got: {rename_ops}" ) # --------------------------------------------------------------------------- # Non-rename: different content, same extension — must NOT be detected # --------------------------------------------------------------------------- class TestNotARename: """When content differs between the deleted and added file, it is NOT a rename — it is a genuine insert + delete and must be treated as such.""" def test_different_content_not_detected_as_rename( self, tmp_path: pathlib.Path ) -> None: """A deleted file and an added file with *different* content must produce InsertOp + DeleteOp, not a RenameOp.""" root = _init_repo(tmp_path) base = _snap(root, {"old.txt": b"original content\n"}) target = _snap(root, {"new.txt": b"completely different\n"}) delta = plugin.diff(base, target, repo_root=root) op_types = _op_types(delta["ops"]) assert "insert" in op_types, ( f"Non-rename must produce InsertOp, got: {op_types}" ) assert "delete" in op_types, ( f"Non-rename must produce DeleteOp, got: {op_types}" ) assert not any(o["op"] == "rename" for o in delta["ops"]), ( f"No RenameOp expected for different content, got: {delta['ops']}" ) def test_same_extension_different_content_not_rename( self, tmp_path: pathlib.Path ) -> None: """Two different .txt files (added + deleted) with different content must not be collapsed into a rename even if they share an extension.""" root = _init_repo(tmp_path) base = _snap(root, {"a.txt": b"aaa\n"}) target = _snap(root, {"b.txt": b"bbb\n"}) delta = plugin.diff(base, target, repo_root=root) op_types = _op_types(delta["ops"]) assert "insert" in op_types assert "delete" in op_types # --------------------------------------------------------------------------- # Rename alongside other changes # --------------------------------------------------------------------------- class TestRenameWithSiblings: """A rename must be detected correctly even when other files are simultaneously added, deleted, or modified in the same delta.""" def test_rename_plus_new_file(self, tmp_path: pathlib.Path) -> None: """A rename and a genuinely new file in the same commit must each be represented correctly — RenameOp for the rename and InsertOp for the new file.""" root = _init_repo(tmp_path) base = _snap(root, {"hello.txt": b"content\n"}) target = _snap(root, { "hello.md": b"content\n", # rename "new_file.py": b"x = 1\n", # new addition }) delta = plugin.diff(base, target, repo_root=root) rename_ops = [ o for o in delta["ops"] if o["op"] == "rename" and o["from_address"] == "hello.txt" ] insert_ops = [o for o in delta["ops"] if o["op"] == "insert" and "new_file" in o["address"]] assert rename_ops, "Rename must be detected alongside a new file" assert insert_ops or any( o["op"] == "patch" and "new_file" in o["address"] for o in delta["ops"] ), "New file must appear as an insert or patch op" def test_rename_plus_deletion(self, tmp_path: pathlib.Path) -> None: """A rename and a genuine deletion in the same delta must each be handled independently — the deletion must not be absorbed into the rename.""" root = _init_repo(tmp_path) base = _snap(root, { "hello.txt": b"content\n", "old.txt": b"old content\n", }) target = _snap(root, { "hello.md": b"content\n", # rename of hello.txt # old.txt is genuinely deleted }) delta = plugin.diff(base, target, repo_root=root) rename_ops = [ o for o in delta["ops"] if o["op"] == "rename" and o["from_address"] == "hello.txt" ] delete_ops = [o for o in delta["ops"] if o["op"] == "delete" and "old" in o["address"]] assert rename_ops, "hello.txt → hello.md rename must be detected" assert delete_ops, "old.txt deletion must produce a DeleteOp" def test_two_simultaneous_renames(self, tmp_path: pathlib.Path) -> None: """Two files renamed in the same commit must both produce RenameOps — neither rename must consume the other's partner.""" root = _init_repo(tmp_path) base = _snap(root, { "a.txt": b"content A\n", "b.txt": b"content B\n", }) target = _snap(root, { "a.md": b"content A\n", # rename of a.txt "b.md": b"content B\n", # rename of b.txt }) delta = plugin.diff(base, target, repo_root=root) a_rename = [ o for o in delta["ops"] if o["op"] == "rename" and o["from_address"] == "a.txt" ] b_rename = [ o for o in delta["ops"] if o["op"] == "rename" and o["from_address"] == "b.txt" ] assert a_rename, "a.txt → a.md rename must be detected" assert b_rename, "b.txt → b.md rename must be detected" assert not any( o["op"] in ("insert", "delete") for o in delta["ops"] ), f"No bare insert/delete expected for two pure renames, got: {delta['ops']}" def test_rename_ambiguity_resolved_by_content_id( self, tmp_path: pathlib.Path ) -> None: """When one file is deleted and two files with the same content are added, the content_id uniquely identifies the rename target. Only one of the added files must be paired as the rename; the other remains an insert.""" root = _init_repo(tmp_path) content = b"shared content\n" base = _snap(root, {"original.txt": content}) target = _snap(root, { "copy_a.txt": content, "copy_b.txt": content, }) delta = plugin.diff(base, target, repo_root=root) rename_ops = [ o for o in delta["ops"] if o["op"] == "rename" and o["from_address"] == "original.txt" ] assert len(rename_ops) == 1, ( f"Exactly one rename expected when one source matches two targets, " f"got {len(rename_ops)} rename ops" ) # --------------------------------------------------------------------------- # delta_summary # --------------------------------------------------------------------------- class TestDeltaSummaryForRenames: """delta_summary must report renamed files correctly, not as adds+removes.""" def test_summary_single_rename(self, tmp_path: pathlib.Path) -> None: """One renamed file → summary says '1 renamed file' (or similar).""" root = _init_repo(tmp_path) content = b"text\n" base = _snap(root, {"a.txt": content}) target = _snap(root, {"b.txt": content}) delta = plugin.diff(base, target, repo_root=root) assert "renamed" in delta["summary"].lower(), ( f"Summary must mention rename for a single renamed file: {delta['summary']!r}" ) def test_summary_two_renames(self, tmp_path: pathlib.Path) -> None: """Two renamed files → summary must count both.""" root = _init_repo(tmp_path) base = _snap(root, {"a.txt": b"AAA\n", "b.txt": b"BBB\n"}) target = _snap(root, {"a.md": b"AAA\n", "b.md": b"BBB\n"}) delta = plugin.diff(base, target, repo_root=root) assert "2" in delta["summary"] or "renamed" in delta["summary"].lower(), ( f"Summary must count both renames: {delta['summary']!r}" )