"""Backup inspection services shared by REST routes and the AI tool layer. Single source of truth for locating, listing and diffing the timestamped backups created before each file mutation. The write-side (creating a backup) stays in the route layer; only the read/inspection logic lives here so both ``/api/file/{vault}/backups``, ``/api/file/{vault}/diff`` and the tools ``list_backups`` / ``diff_backup`` behave identically. """ from __future__ import annotations import difflib import logging import os from datetime import datetime, timezone from pathlib import Path from typing import Any from backend.services.errors import ServiceError from backend.services.paths import resolve_safe_path from backend.services.vaults import get_vault_root logger = logging.getLogger("obsigate.services.backups") def get_backup_dir(vault_name: str, relative_path: str) -> Path: """Return the directory where backups for a specific file are stored. Resolves relative backup paths against the SPECIFIC vault's directory, matching the backup-creation logic exactly. """ from backend.indexer import get_vault_data backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup")) if not backup_root.is_absolute(): vault_data = get_vault_data(vault_name) if vault_data: backup_root = Path(vault_data["path"]) / backup_root return backup_root / vault_name / Path(relative_path).parent def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any]]: """List all backup files for a vault file, sorted newest first. Backup filename format: ``{original_filename}.{timestamp}.bak``. Returns a list of dicts with ``timestamp``, ``datetime``, ``size`` and ``filename``. Missing or unreadable backup directories yield an empty list. """ backup_dir = get_backup_dir(vault_name, relative_path) if not backup_dir.exists(): return [] original_name = Path(relative_path).name prefix = original_name + "." backups: list[dict[str, Any]] = [] try: dir_entries = list(backup_dir.iterdir()) except PermissionError: logger.warning(f"Permission denied reading backup dir: {backup_dir}") return [] except OSError as e: logger.error(f"Error reading backup dir {backup_dir}: {e}") return [] for f in dir_entries: try: if not f.is_file(): continue name = f.name if not name.startswith(prefix) or not name.endswith(".bak"): continue ts_part = name[len(prefix):-len(".bak")] try: ts = int(ts_part) except (ValueError, TypeError): continue try: dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() except (OSError, OverflowError, ValueError) as ts_err: logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}") continue st = f.stat() backups.append({ "timestamp": ts, "datetime": dt, "size": st.st_size, "filename": name, }) except Exception as entry_err: logger.warning(f"Skipping unreadable backup entry {f}: {entry_err}") continue backups.sort(key=lambda b: b["timestamp"], reverse=True) return backups def diff_backup( vault_name: str, path: str, version: int, compare_with: int | None = None, ) -> dict[str, Any]: """Generate a unified diff between a backup version and another version or the current file. Args: vault_name: Name of the vault. path: Relative path of the file within the vault. version: Timestamp of the backup used as the old/left side. compare_with: Optional timestamp of another backup as the new/right side. If omitted, the current file on disk is used. Returns: Dict with ``vault``, ``path``, ``version``, ``compare_with``, ``diff``, ``left_content`` and ``right_content``. Raises: ServiceError: ``not_found`` (404) when the file or a backup is missing, ``read_error`` (500) when a file cannot be read. """ root = get_vault_root(vault_name) file_path = resolve_safe_path(root, path) if not file_path.exists() or not file_path.is_file(): raise ServiceError( f"File not found: {path}", code="not_found", status=404, details={"vault": vault_name, "path": path}, ) original_name = Path(path).name def _read_backup(ts: int) -> tuple[str, str]: backup_dir = get_backup_dir(vault_name, path) backup_path = backup_dir / f"{original_name}.{ts}.bak" if not backup_path.exists(): raise ServiceError( f"Backup version {ts} not found for {path}", code="not_found", status=404, details={"vault": vault_name, "path": path, "version": ts}, ) try: content = backup_path.read_text(encoding="utf-8", errors="replace") except Exception as e: raise ServiceError( f"Failed to read backup {ts}: {e}", code="read_error", status=500, details={"vault": vault_name, "path": path, "version": ts}, ) from e dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") return content, f"{path}@{dt}" try: left_content, left_label = _read_backup(version) if compare_with is not None: right_content, right_label = _read_backup(compare_with) else: try: right_content = file_path.read_text(encoding="utf-8", errors="replace") except Exception as e: raise ServiceError( f"Failed to read current file: {e}", code="read_error", status=500, details={"vault": vault_name, "path": path}, ) from e right_label = f"{path} (current)" left_lines = left_content.splitlines(keepends=True) right_lines = right_content.splitlines(keepends=True) diff_lines = list(difflib.unified_diff( left_lines, right_lines, fromfile=left_label, tofile=right_label, )) return { "vault": vault_name, "path": path, "version": version, "compare_with": compare_with, "diff": "".join(diff_lines), "left_content": left_content, "right_content": right_content, } except ServiceError: raise except Exception as e: logger.error(f"Error generating diff for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True) raise ServiceError(f"Erreur lors de la génération du diff: {e!s}", code="read_error", status=500) from e