# Issue #04: マイグレーション戦略設計

## 概要

各バージョン間のマイグレーション戦略を設計し、将来のアップグレードに備える拡張可能な仕組みを構築する。

## 現状

マイグレーションの仕組みがないため、バージョンアップ時にユーザーが手動で対応する必要がある。

## 目標

1. バージョン間の変更を体系的に管理
2. 自動マイグレーションの適用範囲を明確化
3. 将来のバージョンアップに備えた拡張性

## 設計

### マイグレーション対象の分類

```
┌─────────────────────────────────────────────────────────────┐
│ 自動マイグレーション可能                                     │
├─────────────────────────────────────────────────────────────┤
│ ✅ フレームワーク生成ファイル                                │
│    - py.typed                                               │
│    - TUTORIAL.md                                            │
│    - .gitignore (フレームワーク部分)                         │
│                                                             │
│ ✅ 設定ファイル（スキーマ変更）                              │
│    - config/*.yaml (キー名変更、新規キー追加)               │
│    - pyproject.toml (依存関係追加)                          │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ 手動マイグレーション推奨（ガイダンスのみ）                   │
├─────────────────────────────────────────────────────────────┤
│ ⚠️ ユーザーコード                                           │
│    - src/nodes/*.py                                         │
│    - tests/*.py                                             │
│    - src/main.py (entry point)                              │
│                                                             │
│ ⚠️ API変更を伴う更新                                        │
│    - デコレータの引数変更                                    │
│    - 関数シグネチャの変更                                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ マイグレーション不可（警告のみ）                             │
├─────────────────────────────────────────────────────────────┤
│ ❌ 破壊的変更                                                │
│    - 削除されたAPI                                          │
│    - 互換性のない型変更                                      │
└─────────────────────────────────────────────────────────────┘
```

## 設計原則

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

1. **イミュータブルな変更定義**: 全ての変更定義は frozen dataclass
2. **純粋関数によるマージ**: 設定マージは入力を変更せず新しい辞書を返す
3. **宣言的な変更記述**: 手続き的ではなく、何を変更するかを宣言的に記述
4. **合成可能な変更**: 小さな変更を組み合わせて複雑な変更を構築

## 実装

### 1. 変更定義型（イミュータブル）

```python
# railway/migrations/changes.py
"""マイグレーション変更定義。

関数型パラダイム:
- 全てのデータ型はイミュータブル (frozen=True)
- ファクトリ関数で生成
- 変更の合成をサポート
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Mapping, Optional, Sequence
import re


class ChangeType(Enum):
    """変更の種類。"""
    FILE_CREATE = "file_create"
    FILE_UPDATE = "file_update"
    FILE_DELETE = "file_delete"
    CONFIG_MERGE = "config_merge"
    CODE_GUIDANCE = "code_guidance"


@dataclass(frozen=True)
class FileChange:
    """ファイル変更定義（イミュータブル）。"""
    change_type: ChangeType
    path: str
    description: str
    content: Optional[str] = None
    template: Optional[str] = None

    @classmethod
    def create(cls, path: str, content: str = "", description: str = "") -> "FileChange":
        """新規ファイル作成。"""
        return cls(
            change_type=ChangeType.FILE_CREATE,
            path=path,
            description=description or f"新規ファイル作成: {path}",
            content=content,
        )

    @classmethod
    def update(cls, path: str, template: str, description: str = "") -> "FileChange":
        """テンプレートからファイル更新。"""
        return cls(
            change_type=ChangeType.FILE_UPDATE,
            path=path,
            description=description or f"ファイル更新: {path}",
            template=template,
        )

    @classmethod
    def delete(cls, path: str, description: str = "") -> "FileChange":
        """ファイル削除。"""
        return cls(
            change_type=ChangeType.FILE_DELETE,
            path=path,
            description=description or f"ファイル削除: {path}",
        )


@dataclass(frozen=True)
class ConfigChange:
    """設定ファイル変更定義（イミュータブル）。

    Attributes:
        path: 設定ファイルパス
        additions: 追加するキーと値（既存キーは上書きしない）
        renames: リネームするキー（old -> new）
        deletions: 削除するキー
    """
    path: str
    additions: Mapping[str, object] = field(default_factory=dict)
    renames: Mapping[str, str] = field(default_factory=dict)
    deletions: Sequence[str] = field(default_factory=tuple)

    @property
    def description(self) -> str:
        """変更の説明を生成。"""
        parts = []
        if self.additions:
            parts.append(f"追加: {len(self.additions)}キー")
        if self.renames:
            parts.append(f"リネーム: {len(self.renames)}キー")
        if self.deletions:
            parts.append(f"削除: {len(self.deletions)}キー")
        return f"{self.path}: {', '.join(parts)}" if parts else f"{self.path}: 変更なし"


@dataclass(frozen=True)
class CodeGuidance:
    """コード変更ガイダンス（手動対応用）。

    Attributes:
        description: 変更の説明
        pattern: 検索パターン（正規表現）
        replacement: 置換テキスト
        file_patterns: 対象ファイルパターン
        auto_apply: 自動適用するか（False = 警告のみ）
    """
    description: str
    pattern: str
    replacement: str
    file_patterns: tuple[str, ...] = ("**/*.py",)
    auto_apply: bool = False

    def matches(self, content: str) -> list[tuple[int, str, str]]:
        """マッチする箇所を検索する純粋関数。

        Args:
            content: 検索対象のコンテンツ

        Returns:
            (行番号, 元の行, 置換後の行) のリスト
        """
        matches = []
        regex = re.compile(self.pattern)

        for i, line in enumerate(content.splitlines(), 1):
            if regex.search(line):
                replaced = regex.sub(self.replacement, line)
                matches.append((i, line, replaced))

        return matches


@dataclass(frozen=True)
class MigrationDefinition:
    """マイグレーション定義（イミュータブル）。

    バージョン間の全ての変更を宣言的に記述する。
    """
    from_version: str
    to_version: str
    description: str
    file_changes: tuple[FileChange, ...] = field(default_factory=tuple)
    config_changes: tuple[ConfigChange, ...] = field(default_factory=tuple)
    code_guidance: tuple[CodeGuidance, ...] = field(default_factory=tuple)
    warnings: tuple[str, ...] = field(default_factory=tuple)

    @property
    def total_changes(self) -> int:
        """変更の総数。"""
        return len(self.file_changes) + len(self.config_changes)

    @property
    def has_breaking_changes(self) -> bool:
        """破壊的変更があるか。"""
        return len(self.warnings) > 0 or len(self.code_guidance) > 0
```

### 2. 設定マージャー（純粋関数）

```python
# railway/migrations/config_merger.py
"""設定ファイルマージ機能。

関数型パラダイム:
- 全ての関数は純粋関数（入力を変更しない）
- 新しい辞書を返す
- 深いマージをサポート
"""
from typing import Any, Mapping

from railway.migrations.changes import ConfigChange


def deep_merge(
    base: Mapping[str, Any],
    overlay: Mapping[str, Any],
) -> dict[str, Any]:
    """2つの辞書を深くマージする純粋関数。

    overlay の値が base を上書きする。
    ネストされた辞書は再帰的にマージされる。

    Args:
        base: ベースとなる辞書
        overlay: 上書きする辞書

    Returns:
        マージされた新しい辞書
    """
    result: dict[str, Any] = dict(base)

    for key, value in overlay.items():
        if (
            key in result
            and isinstance(result[key], Mapping)
            and isinstance(value, Mapping)
        ):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value

    return result


def apply_additions(
    config: Mapping[str, Any],
    additions: Mapping[str, Any],
) -> dict[str, Any]:
    """設定に新しいキーを追加する純粋関数。

    既存のキーは上書きしない（ユーザー設定を尊重）。

    Args:
        config: 元の設定
        additions: 追加するキーと値

    Returns:
        新しい設定辞書
    """
    result = dict(config)

    for key, value in additions.items():
        if key not in result:
            result[key] = value
        elif isinstance(result[key], Mapping) and isinstance(value, Mapping):
            # ネストされた辞書は再帰的に追加
            result[key] = apply_additions(result[key], value)

    return result


def apply_renames(
    config: Mapping[str, Any],
    renames: Mapping[str, str],
) -> dict[str, Any]:
    """キーをリネームする純粋関数。

    ユーザーの値を保持しながらキー名を変更。

    Args:
        config: 元の設定
        renames: {old_key: new_key} のマッピング

    Returns:
        新しい設定辞書
    """
    result = dict(config)

    for old_key, new_key in renames.items():
        if old_key in result and new_key not in result:
            result[new_key] = result.pop(old_key)

    return result


def apply_deletions(
    config: Mapping[str, Any],
    deletions: Sequence[str],
) -> tuple[dict[str, Any], list[str]]:
    """キーを削除する純粋関数。

    Args:
        config: 元の設定
        deletions: 削除するキーのリスト

    Returns:
        (新しい設定辞書, 実際に削除されたキーのリスト)
    """
    result = dict(config)
    deleted = []

    for key in deletions:
        if key in result:
            del result[key]
            deleted.append(key)

    return result, deleted


def merge_config(
    original: Mapping[str, Any],
    change: ConfigChange,
) -> tuple[dict[str, Any], list[str]]:
    """ConfigChange を適用する純粋関数。

    処理順序:
    1. 新しいキーを追加（既存キーは保持）
    2. キーをリネーム（値は保持）
    3. 非推奨キーを削除

    Args:
        original: 元の設定
        change: 適用する変更

    Returns:
        (新しい設定辞書, 削除されたキーのリスト)
    """
    result = dict(original)

    # 1. 追加
    result = apply_additions(result, dict(change.additions))

    # 2. リネーム
    result = apply_renames(result, dict(change.renames))

    # 3. 削除
    result, deleted = apply_deletions(result, list(change.deletions))

    return result, deleted
```

### 3. マイグレーション定義ファイル

```python
# railway/migrations/definitions/v0_9_to_v0_10.py
"""0.9.0 → 0.10.0 マイグレーション定義。"""
from railway.migrations.changes import (
    MigrationDefinition,
    FileChange,
    ConfigChange,
    CodeGuidance,
)


migration = MigrationDefinition(
    from_version="0.9.0",
    to_version="0.10.0",
    description="バージョン管理機能の追加",

    file_changes=(
        # .railway ディレクトリ構造の追加は init 時に行う
    ),

    config_changes=(
        ConfigChange(
            path="config/development.yaml",
            additions={
                "railway": {
                    "version_check": True,
                    "auto_backup": True,
                },
            },
        ),
    ),

    code_guidance=(
        # この例では API 変更なし
    ),

    warnings=(),
)
```

### 4. コードガイダンススキャナー（純粋関数）

```python
# railway/migrations/scanner.py
"""コード変更ガイダンススキャン機能。

関数型パラダイム:
- 純粋関数によるスキャン
- 結果はイミュータブルなデータ構造
"""
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence

from railway.migrations.changes import CodeGuidance


@dataclass(frozen=True)
class ScanMatch:
    """スキャンマッチ結果（イミュータブル）。"""
    file_path: str
    line_number: int
    original_line: str
    suggested_line: str
    guidance: CodeGuidance


@dataclass(frozen=True)
class ScanResult:
    """スキャン結果（イミュータブル）。"""
    matches: tuple[ScanMatch, ...]
    scanned_files: int

    @property
    def has_matches(self) -> bool:
        return len(self.matches) > 0


def scan_file(
    file_path: Path,
    content: str,
    guidance: CodeGuidance,
) -> tuple[ScanMatch, ...]:
    """単一ファイルをスキャンする純粋関数。

    Args:
        file_path: ファイルパス
        content: ファイル内容
        guidance: 適用するガイダンス

    Returns:
        マッチ結果のタプル
    """
    matches = []

    for line_num, original, suggested in guidance.matches(content):
        matches.append(ScanMatch(
            file_path=str(file_path),
            line_number=line_num,
            original_line=original,
            suggested_line=suggested,
            guidance=guidance,
        ))

    return tuple(matches)


def scan_project(
    project_path: Path,
    guidance_list: Sequence[CodeGuidance],
) -> ScanResult:
    """プロジェクト全体をスキャンする。

    Args:
        project_path: プロジェクトルートパス
        guidance_list: 適用するガイダンスのリスト

    Returns:
        スキャン結果
    """
    all_matches: list[ScanMatch] = []
    scanned_count = 0

    for guidance in guidance_list:
        for pattern in guidance.file_patterns:
            for file_path in project_path.glob(pattern):
                # .railway ディレクトリはスキップ
                if ".railway" in file_path.parts:
                    continue

                try:
                    content = file_path.read_text(encoding="utf-8")
                    matches = scan_file(file_path, content, guidance)
                    all_matches.extend(matches)
                    scanned_count += 1
                except (OSError, UnicodeDecodeError):
                    continue

    return ScanResult(
        matches=tuple(all_matches),
        scanned_files=scanned_count,
    )
```

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

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

```
tests/unit/migrations/
├── test_changes.py
├── test_config_merger.py
└── test_scanner.py
```

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

```python
# tests/unit/migrations/test_config_merger.py
"""設定マージャーのテスト。

TDD Red Phase: まずテストを書き、失敗を確認する。
"""
import pytest

from railway.migrations.changes import ConfigChange
from railway.migrations.config_merger import (
    deep_merge,
    apply_additions,
    apply_renames,
    apply_deletions,
    merge_config,
)


class TestDeepMerge:
    """deep_merge関数のテスト。"""

    def test_simple_merge(self):
        """単純なマージ。"""
        base = {"a": 1}
        overlay = {"b": 2}

        result = deep_merge(base, overlay)

        assert result == {"a": 1, "b": 2}

    def test_overlay_overwrites(self):
        """overlay が上書きする。"""
        base = {"a": 1}
        overlay = {"a": 2}

        result = deep_merge(base, overlay)

        assert result == {"a": 2}

    def test_nested_merge(self):
        """ネストされた辞書のマージ。"""
        base = {"nested": {"a": 1, "b": 2}}
        overlay = {"nested": {"b": 3, "c": 4}}

        result = deep_merge(base, overlay)

        assert result == {"nested": {"a": 1, "b": 3, "c": 4}}

    def test_does_not_mutate_inputs(self):
        """入力を変更しない。"""
        base = {"a": 1}
        overlay = {"b": 2}
        original_base = dict(base)

        deep_merge(base, overlay)

        assert base == original_base


class TestApplyAdditions:
    """apply_additions関数のテスト。"""

    def test_adds_new_keys(self):
        """新しいキーを追加する。"""
        config = {"existing": "value"}
        additions = {"new": "added"}

        result = apply_additions(config, additions)

        assert result["new"] == "added"

    def test_preserves_existing_keys(self):
        """既存キーは上書きしない。"""
        config = {"key": "user_value"}
        additions = {"key": "default_value"}

        result = apply_additions(config, additions)

        assert result["key"] == "user_value"

    def test_nested_additions(self):
        """ネストされた追加。"""
        config = {"nested": {"existing": 1}}
        additions = {"nested": {"new": 2}}

        result = apply_additions(config, additions)

        assert result["nested"]["existing"] == 1
        assert result["nested"]["new"] == 2


class TestApplyRenames:
    """apply_renames関数のテスト。"""

    def test_renames_key(self):
        """キーをリネームする。"""
        config = {"old_name": "value"}
        renames = {"old_name": "new_name"}

        result = apply_renames(config, renames)

        assert "old_name" not in result
        assert result["new_name"] == "value"

    def test_preserves_value(self):
        """値を保持する。"""
        config = {"old": {"nested": "data"}}
        renames = {"old": "new"}

        result = apply_renames(config, renames)

        assert result["new"] == {"nested": "data"}

    def test_skips_if_target_exists(self):
        """ターゲットが既に存在する場合はスキップ。"""
        config = {"old": "old_value", "new": "new_value"}
        renames = {"old": "new"}

        result = apply_renames(config, renames)

        # 両方とも保持される
        assert result["old"] == "old_value"
        assert result["new"] == "new_value"


class TestApplyDeletions:
    """apply_deletions関数のテスト。"""

    def test_deletes_key(self):
        """キーを削除する。"""
        config = {"keep": 1, "delete": 2}
        deletions = ["delete"]

        result, deleted = apply_deletions(config, deletions)

        assert "delete" not in result
        assert "keep" in result

    def test_returns_deleted_keys(self):
        """削除されたキーを返す。"""
        config = {"a": 1, "b": 2}
        deletions = ["a", "nonexistent"]

        result, deleted = apply_deletions(config, deletions)

        assert deleted == ["a"]

    def test_ignores_nonexistent_keys(self):
        """存在しないキーは無視する。"""
        config = {"a": 1}
        deletions = ["nonexistent"]

        result, deleted = apply_deletions(config, deletions)

        assert result == {"a": 1}
        assert deleted == []


class TestMergeConfig:
    """merge_config関数のテスト。"""

    def test_full_merge(self):
        """追加・リネーム・削除の全てを適用。"""
        original = {
            "old_key": "value",
            "deprecated": "old",
            "keep": "this",
        }
        change = ConfigChange(
            path="test.yaml",
            additions={"new_key": "added"},
            renames={"old_key": "renamed_key"},
            deletions=["deprecated"],
        )

        result, deleted = merge_config(original, change)

        assert "new_key" in result
        assert result["renamed_key"] == "value"
        assert "old_key" not in result
        assert "deprecated" not in result
        assert result["keep"] == "this"
        assert deleted == ["deprecated"]

    def test_is_pure_function(self):
        """純粋関数である（入力を変更しない）。"""
        original = {"a": 1}
        original_copy = dict(original)
        change = ConfigChange(path="test.yaml", additions={"b": 2})

        merge_config(original, change)

        assert original == original_copy


# tests/unit/migrations/test_scanner.py
"""コードスキャナーのテスト。"""
from pathlib import Path

import pytest

from railway.migrations.changes import CodeGuidance
from railway.migrations.scanner import scan_file, ScanMatch


class TestScanFile:
    """scan_file関数のテスト。"""

    def test_finds_matching_pattern(self):
        """マッチするパターンを見つける。"""
        content = '@node(log_input=True)\ndef my_node(): ...'
        guidance = CodeGuidance(
            description="log_input → log_inputs",
            pattern=r"log_input=",
            replacement="log_inputs=",
        )

        matches = scan_file(Path("test.py"), content, guidance)

        assert len(matches) == 1
        assert matches[0].line_number == 1
        assert "log_input" in matches[0].original_line
        assert "log_inputs" in matches[0].suggested_line

    def test_returns_empty_for_no_match(self):
        """マッチしない場合は空を返す。"""
        content = '@node(log_outputs=True)\ndef my_node(): ...'
        guidance = CodeGuidance(
            description="test",
            pattern=r"log_input=",
            replacement="log_inputs=",
        )

        matches = scan_file(Path("test.py"), content, guidance)

        assert len(matches) == 0

    def test_result_is_immutable(self):
        """結果はイミュータブル。"""
        content = '@node(log_input=True)'
        guidance = CodeGuidance(
            description="test",
            pattern=r"log_input=",
            replacement="log_inputs=",
        )

        matches = scan_file(Path("test.py"), content, guidance)

        with pytest.raises(Exception):
            matches[0].line_number = 99
```

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

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

### Refactor Phase: 改善

1. 差分表示の改善
2. パフォーマンス最適化（大規模プロジェクト対応）
3. より詳細なレポート生成

## 依存関係

なし（このissueは他のissueの基盤となる型定義を提供）

**逆依存:**
- #03 railway update コマンド基本実装（`MigrationDefinition`, `FileChange`, `ChangeType` を使用）
- #06 Dry-run モード実装（`MigrationDefinition`, `FileChange`, `ConfigChange`, `CodeGuidance` を使用）

## 優先度

**中** - update コマンドの品質向上
