""" 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'
{esc}
' if line.startswith("@@"): return f'
{esc}
' if line.startswith("+"): return f'
{esc}
' if line.startswith("-"): return f'
{esc}
' return f'
{esc}
' DIFF_CSS = """ """ def render_diff_html(bundle: Dict[str, Any]) -> str: """Render the ``build_diffs`` output as a full HTML block.""" if not bundle or not bundle.get("diffs"): return DIFF_CSS + '
No file changes detected yet โ€” run a patch to see the Before/After diff.
' summary = bundle.get("summary") or {} parts: List[str] = [DIFF_CSS, '
'] # Top summary pills parts.append('
') parts.append(f'๐Ÿ“ {summary.get("file_count", 0)} files') parts.append(f'โœ๏ธ {summary.get("modified", 0)} modified') parts.append(f'โž• {summary.get("added", 0)} added') parts.append(f'๐Ÿ—‘๏ธ {summary.get("deleted", 0)} deleted') parts.append(f'+{summary.get("total_added_lines", 0)} / -{summary.get("total_removed_lines", 0)} lines') parts.append('
') diffs = bundle["diffs"] # Only show files that actually changed, up to MAX_FILES_IN_HTML. changed = [d for d in diffs if d.get("status") != "unchanged"] truncated = False if len(changed) > MAX_FILES_IN_HTML: truncated = True changed = changed[:MAX_FILES_IN_HTML] for entry in changed: path = entry.get("path", "?") status = entry.get("status", "modified") adds = entry.get("added_lines", 0) dels = entry.get("removed_lines", 0) diff_text = entry.get("unified_diff") or "" parts.append('
') parts.append( '
' f'๐Ÿ“„ {html.escape(path)}' f'{html.escape(status)} ยท +{adds} / -{dels}' '
' ) parts.append('
') if diff_text: for line in diff_text.splitlines(): parts.append(_render_line_html(line)) else: parts.append('
(no textual changes)
') parts.append('
') parts.append('
') if truncated: parts.append( '
' f'... {len(diffs) - MAX_FILES_IN_HTML} additional changed file(s) hidden โ€” ' 'download the ZIP for the complete patch.' '
' ) parts.append('
') return "".join(parts) # --------------------------------------------------------------------------- # One-shot helper the pipeline can call. # --------------------------------------------------------------------------- def build_before_after_diff( project_root: str, before_snapshot: Dict[str, str], files: Optional[Sequence[str]] = None, ) -> Dict[str, Any]: """ High-level helper โ€” the pipeline captures ``before_snapshot`` via :func:`capture_before_snapshot` *before* patching. After patching, it calls this to produce the full bundle:: { "diffs": [...], "summary": {...}, "html": "
...
", } """ tracked_paths = list(before_snapshot.keys()) if files: for f in files: f = str(f or "").strip().lstrip("/") if f and f not in tracked_paths: tracked_paths.append(f) after = compute_after_snapshot(project_root, tracked_paths) bundle = build_diffs(before_snapshot, after) bundle["html"] = render_diff_html(bundle) return bundle __all__ = [ "capture_before_snapshot", "compute_after_snapshot", "build_diffs", "render_diff_html", "build_before_after_diff", "DIFF_CSS", ]