# Issue #05: バックアップ・ロールバック機能

## 概要

`railway update` 実行前のバックアップ作成と、問題発生時のロールバック機能を実装する。

## 現状

バックアップ・ロールバックの仕組みがないため、マイグレーション失敗時のリカバリーが困難。

## 目標

```bash
# 自動バックアップ
$ railway update
💾 バックアップ作成: .railway/backups/0.8.0_20260123_103000/
...

# ロールバック
$ railway rollback
利用可能なバックアップ:
  [1] 0.8.0_20260123_103000 (2時間前)
  [2] 0.7.0_20260120_090000 (3日前)

どのバックアップに戻しますか? [1]: 1
✅ ロールバック完了

# バックアップ一覧
$ railway backup list
  0.8.0_20260123_103000  2時間前   15KB
  0.7.0_20260120_090000  3日前     12KB

# バックアップ削除
$ railway backup clean --keep 3
🗑️  2件のバックアップを削除しました
```

## 設計

### バックアップ構造

```
my_project/
└── .railway/
    └── backups/
        └── 0.8.0_20260123_103000/
            ├── manifest.yaml       # バックアップメタ情報
            ├── TUTORIAL.md
            ├── pyproject.toml
            ├── .gitignore
            └── config/
                └── development.yaml
```

### manifest.yaml

```yaml
backup:
  version: "0.8.0"
  created_at: "2026-01-23T10:30:00+09:00"
  reason: "Before update to 0.9.0"

files:
  - path: "TUTORIAL.md"
    checksum: "sha256:abc123..."
  - path: "pyproject.toml"
    checksum: "sha256:def456..."
  - path: "config/development.yaml"
    checksum: "sha256:ghi789..."
```

### バックアップ対象

| 対象 | 理由 |
|------|------|
| `.railway/project.yaml` | バージョン情報の復元 |
| `TUTORIAL.md` | 自動生成ファイル |
| `pyproject.toml` | 依存関係 |
| `.gitignore` | フレームワーク設定 |
| `config/*.yaml` | 設定ファイル |

**除外対象:**
- `src/nodes/*` - ユーザーコード（更新対象外）
- `tests/*` - ユーザーコード
- `.venv/` - 仮想環境
- `__pycache__/` - キャッシュ

## 設計原則

### 関数型パラダイム準拠

1. **イミュータブルなバックアップ情報**: frozen dataclass で表現
2. **純粋関数によるデータ変換**: マニフェスト生成/パースは純粋関数
3. **IO操作の分離**: ファイル操作は専用関数で明示的に分離
4. **Result型パターン**: 操作結果を明示的に表現

## 実装

### 1. バックアップ型定義（イミュータブル）

```python
# railway/migrations/backup_types.py
"""バックアップ型定義。

関数型パラダイム:
- 全てのデータ型はイミュータブル (frozen=True)
- 変換関数は純粋関数
"""
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional, Sequence


@dataclass(frozen=True)
class BackupFile:
    """バックアップファイル情報（イミュータブル）。"""
    path: str
    checksum: str
    size_bytes: int


@dataclass(frozen=True)
class BackupManifest:
    """バックアップマニフェスト（イミュータブル）。"""
    version: str
    created_at: datetime
    reason: str
    files: tuple[BackupFile, ...]

    @property
    def total_size(self) -> int:
        """バックアップの合計サイズ。"""
        return sum(f.size_bytes for f in self.files)

    @property
    def file_count(self) -> int:
        """ファイル数。"""
        return len(self.files)


@dataclass(frozen=True)
class BackupInfo:
    """バックアップ情報（イミュータブル）。"""
    manifest: BackupManifest
    path: Path

    @property
    def version(self) -> str:
        return self.manifest.version

    @property
    def created_at(self) -> datetime:
        return self.manifest.created_at

    @property
    def reason(self) -> str:
        return self.manifest.reason

    @property
    def size_bytes(self) -> int:
        return self.manifest.total_size

    @property
    def name(self) -> str:
        return self.path.name


@dataclass(frozen=True)
class BackupResult:
    """バックアップ操作結果（イミュータブル）。"""
    success: bool
    backup: Optional[BackupInfo] = None
    error: Optional[str] = None


@dataclass(frozen=True)
class RestoreResult:
    """復元操作結果（イミュータブル）。"""
    success: bool
    restored_files: tuple[str, ...] = field(default_factory=tuple)
    error: Optional[str] = None
```

### 2. マニフェスト操作（純粋関数）

```python
# railway/migrations/backup_manifest.py
"""バックアップマニフェスト操作。

関数型パラダイム:
- パース/シリアライズは純粋関数
- IO操作は別レイヤーで分離
"""
from datetime import datetime
from typing import Any, Mapping, Optional, Sequence

import yaml

from railway.migrations.backup_types import BackupFile, BackupManifest


def parse_manifest(yaml_content: str) -> BackupManifest:
    """YAML文字列からマニフェストをパースする純粋関数。

    Args:
        yaml_content: YAML形式の文字列

    Returns:
        パースされたBackupManifest

    Raises:
        ValueError: パース失敗時
    """
    data = yaml.safe_load(yaml_content)
    return _dict_to_manifest(data)


def serialize_manifest(manifest: BackupManifest) -> str:
    """マニフェストをYAML文字列に変換する純粋関数。

    Args:
        manifest: シリアライズ対象

    Returns:
        YAML形式の文字列
    """
    data = _manifest_to_dict(manifest)
    return yaml.dump(data, default_flow_style=False, allow_unicode=True)


def _dict_to_manifest(data: Mapping[str, Any]) -> BackupManifest:
    """辞書からマニフェストを生成する純粋関数。"""
    backup_data = data["backup"]
    files_data = data.get("files", [])

    files = tuple(
        BackupFile(
            path=f["path"],
            checksum=f["checksum"],
            size_bytes=f.get("size_bytes", 0),
        )
        for f in files_data
    )

    return BackupManifest(
        version=backup_data["version"],
        created_at=datetime.fromisoformat(backup_data["created_at"]),
        reason=backup_data["reason"],
        files=files,
    )


def _manifest_to_dict(manifest: BackupManifest) -> dict[str, Any]:
    """マニフェストを辞書に変換する純粋関数。"""
    return {
        "backup": {
            "version": manifest.version,
            "created_at": manifest.created_at.isoformat(),
            "reason": manifest.reason,
        },
        "files": [
            {
                "path": f.path,
                "checksum": f.checksum,
                "size_bytes": f.size_bytes,
            }
            for f in manifest.files
        ],
    }


def create_manifest(
    version: str,
    reason: str,
    files: Sequence[BackupFile],
    now: Optional[datetime] = None,
) -> BackupManifest:
    """新規マニフェストを生成する純粋関数。

    Args:
        version: バックアップ対象のバージョン
        reason: バックアップ理由
        files: バックアップファイルのリスト
        now: 現在時刻（テスト用にオプショナル）

    Returns:
        新規BackupManifest
    """
    return BackupManifest(
        version=version,
        created_at=now or datetime.now().astimezone(),
        reason=reason,
        files=tuple(files),
    )
```

### 3. バックアップ操作（IO分離）

```python
# railway/migrations/backup.py
"""バックアップ操作。

関数型パラダイム:
- ロジック（マニフェスト生成）と IO（ファイル操作）を分離
- 結果はイミュータブルな Result 型で返す
"""
import hashlib
import shutil
from datetime import datetime
from pathlib import Path
from typing import Sequence

from railway.migrations.backup_types import (
    BackupFile,
    BackupInfo,
    BackupResult,
    RestoreResult,
)
from railway.migrations.backup_manifest import (
    create_manifest,
    parse_manifest,
    serialize_manifest,
)


# バックアップ対象パターン（定数）
BACKUP_PATTERNS: tuple[str, ...] = (
    ".railway/project.yaml",
    "TUTORIAL.md",
    "pyproject.toml",
    ".gitignore",
    "config/*.yaml",
)


def compute_checksum(content: bytes) -> str:
    """コンテンツのチェックサムを計算する純粋関数。

    Args:
        content: チェックサム対象のバイト列

    Returns:
        "sha256:..." 形式のチェックサム
    """
    sha256 = hashlib.sha256(content)
    return f"sha256:{sha256.hexdigest()}"


def generate_backup_name(version: str, timestamp: datetime) -> str:
    """バックアップ名を生成する純粋関数。

    Args:
        version: バージョン
        timestamp: タイムスタンプ

    Returns:
        "{version}_{YYYYMMDD_HHMMSS}" 形式の名前
    """
    return f"{version}_{timestamp.strftime('%Y%m%d_%H%M%S')}"


def collect_backup_files(
    project_path: Path,
    patterns: Sequence[str] = BACKUP_PATTERNS,
) -> list[tuple[Path, BackupFile]]:
    """バックアップ対象ファイルを収集する。

    Args:
        project_path: プロジェクトルートパス
        patterns: ファイルパターン

    Returns:
        (ソースパス, BackupFile) のリスト
    """
    files = []

    for pattern in patterns:
        for src in project_path.glob(pattern):
            if src.is_file():
                try:
                    content = src.read_bytes()
                    rel_path = src.relative_to(project_path)
                    files.append((
                        src,
                        BackupFile(
                            path=str(rel_path),
                            checksum=compute_checksum(content),
                            size_bytes=len(content),
                        ),
                    ))
                except (OSError, PermissionError):
                    continue

    return files


def create_backup(
    project_path: Path,
    version: str,
    reason: str = "Manual backup",
) -> BackupResult:
    """バックアップを作成する。

    Args:
        project_path: プロジェクトルートパス
        version: バックアップ対象のバージョン
        reason: バックアップ理由

    Returns:
        BackupResult with success status
    """
    try:
        now = datetime.now().astimezone()
        backup_name = generate_backup_name(version, now)
        backup_path = project_path / ".railway" / "backups" / backup_name

        # ディレクトリ作成
        backup_path.mkdir(parents=True, exist_ok=True)

        # ファイル収集とコピー
        file_infos = collect_backup_files(project_path)
        backup_files: list[BackupFile] = []

        for src, file_info in file_infos:
            dst = backup_path / file_info.path
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(src, dst)
            backup_files.append(file_info)

        # マニフェスト作成
        manifest = create_manifest(version, reason, backup_files, now)
        manifest_content = serialize_manifest(manifest)

        manifest_path = backup_path / "manifest.yaml"
        manifest_path.write_text(manifest_content, encoding="utf-8")

        return BackupResult(
            success=True,
            backup=BackupInfo(manifest=manifest, path=backup_path),
        )

    except Exception as e:
        return BackupResult(success=False, error=str(e))


def list_backups(project_path: Path) -> list[BackupInfo]:
    """利用可能なバックアップを一覧取得する。

    Args:
        project_path: プロジェクトルートパス

    Returns:
        作成日時の降順でソートされたBackupInfoのリスト
    """
    backup_dir = project_path / ".railway" / "backups"
    backups: list[BackupInfo] = []

    if not backup_dir.exists():
        return backups

    for backup_path in backup_dir.iterdir():
        manifest_path = backup_path / "manifest.yaml"
        if manifest_path.exists():
            try:
                content = manifest_path.read_text(encoding="utf-8")
                manifest = parse_manifest(content)
                backups.append(BackupInfo(manifest=manifest, path=backup_path))
            except Exception:
                continue

    # 作成日時の降順でソート
    backups.sort(key=lambda b: b.created_at, reverse=True)
    return backups


def restore_backup(project_path: Path, backup: BackupInfo) -> RestoreResult:
    """バックアップから復元する。

    Args:
        project_path: プロジェクトルートパス
        backup: 復元するバックアップ

    Returns:
        RestoreResult with success status
    """
    try:
        restored: list[str] = []

        for file_info in backup.manifest.files:
            src = backup.path / file_info.path
            dst = project_path / file_info.path

            if src.exists():
                dst.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src, dst)
                restored.append(file_info.path)

        return RestoreResult(
            success=True,
            restored_files=tuple(restored),
        )

    except Exception as e:
        return RestoreResult(success=False, error=str(e))


def clean_backups(project_path: Path, keep: int = 5) -> tuple[int, list[str]]:
    """古いバックアップを削除する。

    Args:
        project_path: プロジェクトルートパス
        keep: 保持するバックアップ数

    Returns:
        (削除数, 削除されたバックアップ名のリスト)
    """
    backups = list_backups(project_path)
    to_remove = backups[keep:]
    removed_names: list[str] = []

    for backup in to_remove:
        try:
            shutil.rmtree(backup.path)
            removed_names.append(backup.name)
        except Exception:
            continue

    return len(removed_names), removed_names
```

### 4. バックアップCLI

```python
# railway/cli/backup.py
"""バックアップ管理コマンド。

関数型パラダイム:
- コマンドはIO/UIの統合層
- ロジックは backup モジュールに分離
"""
import typer
from datetime import datetime

from railway.core.project_discovery import find_project_root
from railway.migrations.backup import (
    list_backups,
    restore_backup,
    clean_backups,
)

app = typer.Typer(help="バックアップ管理")


def format_age(dt: datetime) -> str:
    """日時を「〜前」形式にフォーマットする純粋関数。"""
    delta = datetime.now().astimezone() - dt
    seconds = delta.total_seconds()

    if seconds < 60:
        return "たった今"
    elif seconds < 3600:
        return f"{int(seconds / 60)}分前"
    elif seconds < 86400:
        return f"{int(seconds / 3600)}時間前"
    else:
        return f"{int(seconds / 86400)}日前"


def format_size(size_bytes: int) -> str:
    """バイト数を人間が読みやすい形式にフォーマットする純粋関数。"""
    if size_bytes < 1024:
        return f"{size_bytes}B"
    elif size_bytes < 1024 * 1024:
        return f"{size_bytes / 1024:.1f}KB"
    else:
        return f"{size_bytes / (1024 * 1024):.1f}MB"


@app.command("list")
def list_cmd():
    """利用可能なバックアップを一覧表示。"""
    project_path = find_project_root()
    if project_path is None:
        typer.echo("❌ Railwayプロジェクトが見つかりません", err=True)
        raise typer.Exit(1)

    backups = list_backups(project_path)

    if not backups:
        typer.echo("バックアップがありません")
        return

    typer.echo("\n利用可能なバックアップ:\n")
    for i, backup in enumerate(backups, 1):
        age = format_age(backup.created_at)
        size = format_size(backup.size_bytes)
        typer.echo(f"  [{i}] {backup.name}  {age}  {size}")


@app.command("restore")
def restore_cmd(
    name: str = typer.Argument(None, help="バックアップ名（省略時は選択）"),
):
    """バックアップから復元。"""
    project_path = find_project_root()
    if project_path is None:
        typer.echo("❌ Railwayプロジェクトが見つかりません", err=True)
        raise typer.Exit(1)

    backups = list_backups(project_path)

    if not backups:
        typer.echo("バックアップがありません", err=True)
        raise typer.Exit(1)

    # バックアップ選択
    if name:
        backup = next((b for b in backups if b.name == name), None)
        if backup is None:
            typer.echo(f"バックアップ '{name}' が見つかりません", err=True)
            raise typer.Exit(1)
    else:
        typer.echo("\n利用可能なバックアップ:\n")
        for i, b in enumerate(backups, 1):
            typer.echo(f"  [{i}] {b.name} ({format_age(b.created_at)})")

        choice = typer.prompt("\nどのバックアップに戻しますか?", default="1")
        try:
            idx = int(choice) - 1
            backup = backups[idx]
        except (ValueError, IndexError):
            typer.echo("無効な選択です", err=True)
            raise typer.Exit(1)

    # 確認
    if not typer.confirm(f"\n{backup.name} に戻しますか?"):
        typer.echo("中止しました")
        return

    # 復元実行
    result = restore_backup(project_path, backup)

    if result.success:
        typer.echo(f"\n✅ ロールバック完了: {backup.version}")
        typer.echo(f"   復元ファイル数: {len(result.restored_files)}")
    else:
        typer.echo(f"❌ 復元に失敗しました: {result.error}", err=True)
        raise typer.Exit(1)


@app.command("clean")
def clean_cmd(
    keep: int = typer.Option(5, "--keep", "-k", help="保持するバックアップ数"),
):
    """古いバックアップを削除。"""
    project_path = find_project_root()
    if project_path is None:
        typer.echo("❌ Railwayプロジェクトが見つかりません", err=True)
        raise typer.Exit(1)

    removed_count, removed_names = clean_backups(project_path, keep=keep)

    if removed_count > 0:
        typer.echo(f"🗑️  {removed_count}件のバックアップを削除しました")
    else:
        typer.echo("削除対象のバックアップはありません")
```

## テスト（TDD: Red → Green → Refactor）

### テストファイル構成

```
tests/unit/migrations/
├── test_backup_types.py
├── test_backup_manifest.py
└── test_backup.py
```

### Red Phase: テストを先に書く

```python
# tests/unit/migrations/test_backup.py
"""バックアップ機能のテスト。

TDD Red Phase: まずテストを書き、失敗を確認する。
"""
from datetime import datetime, timezone
from pathlib import Path

import pytest

from railway.migrations.backup_types import (
    BackupFile,
    BackupManifest,
    BackupInfo,
)
from railway.migrations.backup_manifest import (
    create_manifest,
    parse_manifest,
    serialize_manifest,
)
from railway.migrations.backup import (
    compute_checksum,
    generate_backup_name,
    collect_backup_files,
    create_backup,
    list_backups,
    restore_backup,
    clean_backups,
)


class TestBackupTypesImmutability:
    """バックアップ型のイミュータブル性テスト。"""

    def test_backup_file_is_frozen(self):
        """BackupFileは変更不可。"""
        bf = BackupFile(path="test.txt", checksum="sha256:abc", size_bytes=100)

        with pytest.raises(Exception):
            bf.path = "other.txt"

    def test_backup_manifest_is_frozen(self):
        """BackupManifestは変更不可。"""
        manifest = create_manifest("0.9.0", "test", [])

        with pytest.raises(Exception):
            manifest.version = "1.0.0"


class TestPureFunctions:
    """純粋関数のテスト。"""

    def test_compute_checksum_is_deterministic(self):
        """同じ入力には同じチェックサム。"""
        content = b"Hello, World!"

        checksum1 = compute_checksum(content)
        checksum2 = compute_checksum(content)

        assert checksum1 == checksum2
        assert checksum1.startswith("sha256:")

    def test_compute_checksum_different_for_different_content(self):
        """異なる入力には異なるチェックサム。"""
        checksum1 = compute_checksum(b"Hello")
        checksum2 = compute_checksum(b"World")

        assert checksum1 != checksum2

    def test_generate_backup_name_format(self):
        """バックアップ名のフォーマット。"""
        dt = datetime(2026, 1, 23, 10, 30, 0, tzinfo=timezone.utc)

        name = generate_backup_name("0.9.0", dt)

        assert name == "0.9.0_20260123_103000"

    def test_manifest_roundtrip(self):
        """シリアライズ→パースで復元できる。"""
        original = create_manifest(
            version="0.9.0",
            reason="Test backup",
            files=[
                BackupFile(path="test.txt", checksum="sha256:abc", size_bytes=100),
            ],
        )

        yaml_str = serialize_manifest(original)
        restored = parse_manifest(yaml_str)

        assert restored.version == original.version
        assert restored.reason == original.reason
        assert len(restored.files) == len(original.files)


class TestBackupOperations:
    """バックアップ操作のテスト。"""

    def test_collect_backup_files(self, tmp_path: Path):
        """バックアップ対象ファイルを収集する。"""
        # テストファイル作成
        (tmp_path / "TUTORIAL.md").write_text("Tutorial")
        (tmp_path / "pyproject.toml").write_text("[project]")
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "user_code.py").write_text("# User code")

        files = collect_backup_files(tmp_path)

        # TUTORIAL.md と pyproject.toml は収集される
        paths = [f[1].path for f in files]
        assert "TUTORIAL.md" in paths
        assert "pyproject.toml" in paths
        # ユーザーコードは含まれない
        assert "src/user_code.py" not in paths

    def test_create_backup_success(self, tmp_path: Path):
        """バックアップ作成が成功する。"""
        # テストファイル作成
        (tmp_path / "TUTORIAL.md").write_text("Tutorial content")

        result = create_backup(tmp_path, "0.9.0", "Test backup")

        assert result.success
        assert result.backup is not None
        assert result.backup.path.exists()
        assert (result.backup.path / "manifest.yaml").exists()
        assert (result.backup.path / "TUTORIAL.md").exists()

    def test_list_backups_returns_sorted(self, tmp_path: Path):
        """バックアップが作成日時降順でソートされる。"""
        # 複数のバックアップを作成
        create_backup(tmp_path, "0.7.0", "First")
        create_backup(tmp_path, "0.8.0", "Second")
        create_backup(tmp_path, "0.9.0", "Third")

        backups = list_backups(tmp_path)

        assert len(backups) == 3
        # 最新が最初
        assert backups[0].version == "0.9.0"
        assert backups[2].version == "0.7.0"

    def test_restore_backup(self, tmp_path: Path):
        """バックアップから復元できる。"""
        # オリジナルファイル作成
        tutorial = tmp_path / "TUTORIAL.md"
        tutorial.write_text("Original content")

        # バックアップ作成
        result = create_backup(tmp_path, "0.9.0", "Before change")
        backup = result.backup

        # ファイル変更
        tutorial.write_text("Modified content")
        assert tutorial.read_text() == "Modified content"

        # 復元
        restore_result = restore_backup(tmp_path, backup)

        assert restore_result.success
        assert tutorial.read_text() == "Original content"

    def test_clean_backups_keeps_recent(self, tmp_path: Path):
        """指定数のバックアップを保持する。"""
        # 5個のバックアップを作成
        for i in range(5):
            create_backup(tmp_path, f"0.{i}.0", f"Backup {i}")

        removed_count, _ = clean_backups(tmp_path, keep=2)

        assert removed_count == 3
        assert len(list_backups(tmp_path)) == 2


class TestBackupResultTypes:
    """結果型のテスト。"""

    def test_backup_result_is_immutable(self, tmp_path: Path):
        """BackupResultは変更不可。"""
        result = create_backup(tmp_path, "0.9.0", "Test")

        with pytest.raises(Exception):
            result.success = False

    def test_restore_result_is_immutable(self, tmp_path: Path):
        """RestoreResultは変更不可。"""
        create_backup(tmp_path, "0.9.0", "Test")
        backups = list_backups(tmp_path)
        result = restore_backup(tmp_path, backups[0])

        with pytest.raises(Exception):
            result.success = False
```

### Green Phase: 最小限の実装

上記「実装」セクションのコードを実装し、テストを通す。

### Refactor Phase: 改善

1. 差分バックアップの検討
2. バックアップ検証機能（チェックサム照合）
3. 圧縮オプションの追加

## 依存関係

- #03 railway update コマンド基本実装

## 優先度

**中** - 安全なマイグレーションに必要
