""" core/diff_utils.py โ Visual Before/After diff generator (v4.7). Purpose ======= Before the user hits **Download ZIP**, we want to show them *exactly* what the AI changed. This module: * Captures a **before snapshot** of every file the AI is about to touch, keyed by ``relative_path -> str``. The pipeline calls this **before** ``apply_ai_changes`` writes anything to disk. * After the patch is applied, compares before/after and produces: * A **unified diff** (Python's ``difflib.unified_diff``) โ the raw text the UI can drop into a monospaced viewer. * An **HTML side-by-side diff** styled with the studio's pink/purple palette (drop into ``gr.HTML``). * A **summary** dict with added / removed / modified line counts so the Confidence Badge can factor it in later. We deliberately avoid installing the ``diff-match-patch`` package here โ Python's ``difflib`` gives us character/line/word-level diffs already, and skipping the extra native dep keeps the Hugging Face Space build fast. The output shape (``{"diffs": [...], "summary": {...}}``) is 100% compatible with what a diff-match-patch viewer expects, so a UI upgrade later is still a drop-in change. """ from __future__ import annotations import difflib import html import os from typing import Any, Dict, List, Optional, Sequence, Tuple MAX_BEFORE_CHARS = 400_000 # per-file cap on snapshot text (safety guard) MAX_DIFF_LINES = 4_000 # per-file cap on rendered diff lines MAX_FILES_IN_HTML = 40 # cap the HTML view so the UI stays fast # --------------------------------------------------------------------------- # Snapshot helpers # --------------------------------------------------------------------------- def _read_text(path: str, max_chars: int = MAX_BEFORE_CHARS) -> str: try: with open(path, "r", encoding="utf-8", errors="replace") as f: return f.read(max_chars) except FileNotFoundError: return "" except Exception: return "" def capture_before_snapshot( project_root: str, candidate_files: Sequence[str], ) -> Dict[str, str]: """ Read every ``candidate_files`` (relative paths) *before* patching so we can compute a diff after. Missing files are recorded as empty strings, which naturally shows up in the diff as pure additions. """ if not project_root or not candidate_files: return {} snapshot: Dict[str, str] = {} for rel in candidate_files: rel = str(rel or "").strip().lstrip("/") if not rel: continue abs_path = os.path.join(project_root, rel) snapshot[rel] = _read_text(abs_path) return snapshot def compute_after_snapshot( project_root: str, files: Sequence[str], ) -> Dict[str, str]: """Same as ``capture_before_snapshot`` but for the post-patch state.""" return capture_before_snapshot(project_root, files) # --------------------------------------------------------------------------- # Diff computation # --------------------------------------------------------------------------- def _unified_lines(before: str, after: str, path: str) -> List[str]: """Wrap ``difflib.unified_diff`` with a bounded line cap.""" a = (before or "").splitlines(keepends=False) b = (after or "").splitlines(keepends=False) diff_iter = difflib.unified_diff( a, b, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="", n=3, ) out: List[str] = [] for i, line in enumerate(diff_iter): if i >= MAX_DIFF_LINES: out.append(f"... (diff truncated at {MAX_DIFF_LINES} lines)") break out.append(line) return out def _summarise_diff(diff_lines: Sequence[str]) -> Dict[str, int]: added = removed = hunks = 0 for line in diff_lines: if not line: continue if line.startswith("+++") or line.startswith("---"): continue if line.startswith("+"): added += 1 elif line.startswith("-"): removed += 1 elif line.startswith("@@"): hunks += 1 return {"added_lines": added, "removed_lines": removed, "hunk_count": hunks} def build_diffs( before: Dict[str, str], after: Dict[str, str], ) -> Dict[str, Any]: """ Produce a per-file list of diffs + an aggregate summary. Returns:: { "diffs": [ { "path": "core/foo.py", "status": "modified" | "added" | "deleted" | "unchanged", "unified_diff": "..." (str), "added_lines": 12, "removed_lines": 3, "hunk_count": 2, }, ... ], "summary": { "file_count": 5, "modified": 3, "added": 1, "deleted": 0, "unchanged": 1, "total_added_lines": 42, "total_removed_lines": 11, }, } """ diffs: List[Dict[str, Any]] = [] paths = sorted(set(before.keys()) | set(after.keys())) totals = { "modified": 0, "added": 0, "deleted": 0, "unchanged": 0, "total_added_lines": 0, "total_removed_lines": 0, } for path in paths: b_txt = before.get(path, "") a_txt = after.get(path, "") if b_txt == a_txt: status = "unchanged" totals["unchanged"] += 1 diffs.append({ "path": path, "status": status, "unified_diff": "", "added_lines": 0, "removed_lines": 0, "hunk_count": 0, }) continue if not b_txt and a_txt: status = "added" totals["added"] += 1 elif b_txt and not a_txt: status = "deleted" totals["deleted"] += 1 else: status = "modified" totals["modified"] += 1 lines = _unified_lines(b_txt, a_txt, path) summary = _summarise_diff(lines) totals["total_added_lines"] += summary["added_lines"] totals["total_removed_lines"] += summary["removed_lines"] diffs.append({ "path": path, "status": status, "unified_diff": "\n".join(lines), "added_lines": summary["added_lines"], "removed_lines": summary["removed_lines"], "hunk_count": summary["hunk_count"], }) summary = { "file_count": len(paths), **totals, } return {"diffs": diffs, "summary": summary} # --------------------------------------------------------------------------- # HTML rendering (pink + purple, matches Studio theme) # --------------------------------------------------------------------------- def _render_line_html(line: str) -> str: """Style a single unified-diff line as coloured HTML.""" esc = html.escape(line) if line.startswith("+++") or line.startswith("---"): return f'