# Issue #02: バージョン互換性チェック

## 概要

`railway new` 系コマンド実行時に、プロジェクトのバージョンと現在のrailway-frameworkバージョンの互換性を検証する。

## 現状

```bash
$ railway new node fetch_data
✅ Created src/nodes/fetch_data.py
✅ Created tests/nodes/test_fetch_data.py

# 問題: v0.7.0 で作成したプロジェクトに v0.9.0 のテンプレートが適用される
# テンプレート形式が変わっていると整合性が崩れる
```

## 目標

```bash
$ railway new node fetch_data

⚠️  Version Mismatch Detected
    Project version:  0.7.0
    Current version:  0.9.0

    The project was created with an older version of railway-framework.
    Some templates may have changed.

    Options:
      [c] Continue anyway (may cause inconsistencies)
      [u] Run 'railway update' first (recommended)
      [a] Abort

    Choice [c/u/a]: _
```

## 設計

### 互換性レベル

| レベル | 条件 | 動作 |
|--------|------|------|
| 完全互換 | 同一メジャー・マイナー | そのまま実行 |
| マイナー差 | メジャー同一、マイナー異なる | 警告 + 確認 |
| メジャー差 | メジャー異なる | エラー + 拒否 |
| 不明 | メタデータなし | 警告 + 確認 |

### セマンティックバージョニング

```
MAJOR.MINOR.PATCH
  │     │     └── バグ修正（互換性あり）
  │     └──────── 機能追加（後方互換）
  └────────────── 破壊的変更（互換性なし）
```

## 設計原則

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

1. **Enumによる状態表現**: 副作用のない値として互換性レベルを表現
2. **純粋関数によるチェック**: 入力のみに依存、副作用なし
3. **メッセージ生成の分離**: ロジックとUI出力を分離

## 実装

### 1. 互換性チェッカー（純粋関数）

```python
# railway/core/version_checker.py
"""バージョン互換性チェック機能。

関数型パラダイム:
- 全ての関数は純粋関数（副作用なし）
- 入力のみに依存し、同じ入力には同じ出力
- Enumで状態を値として表現
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional

from packaging.version import Version


class CompatibilityLevel(Enum):
    """バージョン互換性レベル（値として表現）。"""
    FULL = "full"           # 完全互換
    MINOR_DIFF = "minor"    # マイナーバージョン差
    MAJOR_DIFF = "major"    # メジャーバージョン差
    UNKNOWN = "unknown"     # メタデータなし


@dataclass(frozen=True)
class CompatibilityResult:
    """互換性チェック結果（イミュータブル）。

    Attributes:
        level: 互換性レベル
        project_version: プロジェクトのバージョン（不明の場合None）
        current_version: 現在のrailway-frameworkバージョン
    """
    level: CompatibilityLevel
    project_version: Optional[str]
    current_version: str

    @property
    def is_compatible(self) -> bool:
        """完全互換かどうか。"""
        return self.level == CompatibilityLevel.FULL

    @property
    def requires_confirmation(self) -> bool:
        """ユーザー確認が必要かどうか。"""
        return self.level in (CompatibilityLevel.MINOR_DIFF, CompatibilityLevel.UNKNOWN)

    @property
    def is_blocked(self) -> bool:
        """実行がブロックされるかどうか。"""
        return self.level == CompatibilityLevel.MAJOR_DIFF


def check_compatibility(
    project_version: Optional[str],
    current_version: str,
) -> CompatibilityResult:
    """バージョン互換性をチェックする純粋関数。

    Args:
        project_version: プロジェクトのrailway-frameworkバージョン（None=不明）
        current_version: 現在のrailway-frameworkバージョン

    Returns:
        CompatibilityResult with level and versions

    Examples:
        >>> check_compatibility("0.9.0", "0.9.0").level
        CompatibilityLevel.FULL
        >>> check_compatibility("0.8.0", "0.9.0").level
        CompatibilityLevel.MINOR_DIFF
        >>> check_compatibility(None, "0.9.0").level
        CompatibilityLevel.UNKNOWN
    """
    if project_version is None:
        return CompatibilityResult(
            level=CompatibilityLevel.UNKNOWN,
            project_version=None,
            current_version=current_version,
        )

    proj = Version(project_version)
    curr = Version(current_version)

    if proj.major != curr.major:
        level = CompatibilityLevel.MAJOR_DIFF
    elif proj.minor != curr.minor:
        level = CompatibilityLevel.MINOR_DIFF
    else:
        level = CompatibilityLevel.FULL

    return CompatibilityResult(
        level=level,
        project_version=project_version,
        current_version=current_version,
    )


# ============================================================
# メッセージ生成（純粋関数）
# ============================================================

def get_compatibility_message(level: CompatibilityLevel) -> Optional[str]:
    """互換性レベルに応じたメッセージを取得する純粋関数。

    Args:
        level: 互換性レベル

    Returns:
        ユーザー向けメッセージ、FULLの場合はNone
    """
    messages = {
        CompatibilityLevel.FULL: None,
        CompatibilityLevel.MINOR_DIFF: (
            "マイナーバージョンが異なります。\n"
            "テンプレートが更新されている可能性があります。"
        ),
        CompatibilityLevel.MAJOR_DIFF: (
            "メジャーバージョンが異なります。\n"
            "破壊的変更が含まれている可能性があります。"
        ),
        CompatibilityLevel.UNKNOWN: (
            "プロジェクトのバージョン情報がありません。\n"
            "古いバージョンで作成されたプロジェクトの可能性があります。"
        ),
    }
    return messages[level]


def format_compatibility_warning(result: CompatibilityResult) -> str:
    """互換性警告をフォーマットする純粋関数。

    Args:
        result: 互換性チェック結果

    Returns:
        フォーマット済み警告メッセージ
    """
    version_display = result.project_version or "不明"
    message = get_compatibility_message(result.level) or ""

    return (
        f"⚠️  バージョン不一致を検出\n"
        f"   プロジェクト: {version_display}\n"
        f"   現在:         {result.current_version}\n\n"
        f"{message}"
    )


def format_compatibility_error(result: CompatibilityResult) -> str:
    """互換性エラーをフォーマットする純粋関数。

    Args:
        result: 互換性チェック結果

    Returns:
        フォーマット済みエラーメッセージ
    """
    version_display = result.project_version or "不明"
    message = get_compatibility_message(result.level) or ""

    return (
        f"❌ バージョン互換性エラー\n"
        f"   プロジェクト: {version_display}\n"
        f"   現在:         {result.current_version}\n\n"
        f"{message}\n\n"
        f"'railway update' を実行してプロジェクトを更新してください。"
    )
```

### 2. 互換性チェック実行関数

```python
# railway/cli/compatibility.py
"""CLI用互換性チェック機能。

関数型パラダイム:
- 純粋なロジックは version_checker モジュールに分離
- このモジュールはIO/UIとの統合のみ担当
"""
from functools import wraps
from pathlib import Path
from typing import Callable, Optional, TypeVar

import typer

from railway import __version__
from railway.core.project_discovery import find_project_root
from railway.core.project_metadata import load_metadata
from railway.core.version_checker import (
    check_compatibility,
    format_compatibility_warning,
    format_compatibility_error,
)

F = TypeVar("F", bound=Callable)


def check_project_compatibility(
    project_path: Optional[Path] = None,
) -> None:
    """プロジェクトの互換性をチェックし、必要に応じてユーザーに確認する。

    Args:
        project_path: プロジェクトパス（省略時は自動検出）

    Raises:
        typer.Exit: 互換性エラーまたはユーザー中止時
    """
    path = project_path or find_project_root()
    if path is None:
        return  # プロジェクト外では何もしない

    metadata = load_metadata(path)
    project_version = metadata.railway.version if metadata else None

    result = check_compatibility(project_version, __version__)

    if result.is_compatible:
        return

    if result.is_blocked:
        typer.echo(format_compatibility_error(result), err=True)
        raise typer.Exit(1)

    # 確認が必要な場合
    typer.echo(format_compatibility_warning(result))

    choice = typer.prompt(
        "\n[c] 続行 / [u] 'railway update' を実行 / [a] 中止",
        default="a",
    )

    match choice.lower():
        case "c":
            return
        case "u":
            typer.echo("'railway update' を実行してください。")
            raise typer.Exit(0)
        case _:
            typer.echo("中止しました。")
            raise typer.Exit(0)


def require_compatible_project(func: F) -> F:
    """コマンド実行前に互換性チェックを行うデコレータ。

    Args:
        func: デコレート対象の関数

    Returns:
        ラップされた関数
    """
    @wraps(func)
    def wrapper(*args, **kwargs):
        # --force オプションがあればスキップ
        if kwargs.get("force", False):
            return func(*args, **kwargs)

        check_project_compatibility()
        return func(*args, **kwargs)

    return wrapper  # type: ignore
```

### 3. new コマンドへの適用

```python
# railway/cli/new.py の修正

from railway.cli.compatibility import require_compatible_project


@app.command()
@require_compatible_project
def node(
    name: str,
    force: bool = typer.Option(
        False, "--force", "-f",
        help="互換性チェックをスキップ",
    ),
):
    """新しいノードを作成する。"""
    ...


@app.command()
@require_compatible_project
def entry(
    name: str,
    force: bool = typer.Option(
        False, "--force", "-f",
        help="互換性チェックをスキップ",
    ),
):
    """新しいエントリーポイントを作成する。"""
    ...
```

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

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

```
tests/unit/core/
└── test_version_checker.py
tests/unit/cli/
└── test_compatibility.py
```

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

```python
# tests/unit/core/test_version_checker.py
"""バージョン互換性チェックのテスト。

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

from railway.core.version_checker import (
    CompatibilityLevel,
    CompatibilityResult,
    check_compatibility,
    get_compatibility_message,
    format_compatibility_warning,
    format_compatibility_error,
)


class TestCompatibilityResult:
    """CompatibilityResultのテスト。"""

    def test_is_compatible_true_for_full(self):
        """FULLレベルはis_compatible=True。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.FULL,
            project_version="0.9.0",
            current_version="0.9.0",
        )
        assert result.is_compatible is True

    def test_is_compatible_false_for_minor_diff(self):
        """MINOR_DIFFレベルはis_compatible=False。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.MINOR_DIFF,
            project_version="0.8.0",
            current_version="0.9.0",
        )
        assert result.is_compatible is False

    def test_requires_confirmation_for_minor_diff(self):
        """MINOR_DIFFは確認が必要。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.MINOR_DIFF,
            project_version="0.8.0",
            current_version="0.9.0",
        )
        assert result.requires_confirmation is True

    def test_requires_confirmation_for_unknown(self):
        """UNKNOWNは確認が必要。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.UNKNOWN,
            project_version=None,
            current_version="0.9.0",
        )
        assert result.requires_confirmation is True

    def test_is_blocked_for_major_diff(self):
        """MAJOR_DIFFはブロック。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.MAJOR_DIFF,
            project_version="0.9.0",
            current_version="1.0.0",
        )
        assert result.is_blocked is True

    def test_result_is_immutable(self):
        """CompatibilityResultはイミュータブル。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.FULL,
            project_version="0.9.0",
            current_version="0.9.0",
        )
        with pytest.raises(Exception):
            result.level = CompatibilityLevel.MAJOR_DIFF


class TestCheckCompatibility:
    """check_compatibility関数のテスト。"""

    def test_same_version_is_full(self):
        """同じバージョンはFULL互換。"""
        result = check_compatibility("0.9.0", "0.9.0")
        assert result.level == CompatibilityLevel.FULL

    def test_patch_difference_is_full(self):
        """パッチバージョン差はFULL互換。"""
        result = check_compatibility("0.9.0", "0.9.5")
        assert result.level == CompatibilityLevel.FULL

    def test_minor_upgrade_is_minor_diff(self):
        """マイナーバージョンアップはMINOR_DIFF。"""
        result = check_compatibility("0.8.0", "0.9.0")
        assert result.level == CompatibilityLevel.MINOR_DIFF

    def test_minor_downgrade_is_minor_diff(self):
        """マイナーバージョンダウンもMINOR_DIFF。"""
        result = check_compatibility("0.10.0", "0.9.0")
        assert result.level == CompatibilityLevel.MINOR_DIFF

    def test_major_upgrade_is_major_diff(self):
        """メジャーバージョンアップはMAJOR_DIFF。"""
        result = check_compatibility("0.9.0", "1.0.0")
        assert result.level == CompatibilityLevel.MAJOR_DIFF

    def test_major_downgrade_is_major_diff(self):
        """メジャーバージョンダウンもMAJOR_DIFF。"""
        result = check_compatibility("2.0.0", "1.0.0")
        assert result.level == CompatibilityLevel.MAJOR_DIFF

    def test_none_version_is_unknown(self):
        """Noneバージョンは UNKNOWN。"""
        result = check_compatibility(None, "0.9.0")
        assert result.level == CompatibilityLevel.UNKNOWN

    def test_result_contains_versions(self):
        """結果にバージョン情報が含まれる。"""
        result = check_compatibility("0.8.0", "0.9.0")
        assert result.project_version == "0.8.0"
        assert result.current_version == "0.9.0"

    def test_is_referentially_transparent(self):
        """同じ入力には同じ結果（参照透過性）。"""
        result1 = check_compatibility("0.8.0", "0.9.0")
        result2 = check_compatibility("0.8.0", "0.9.0")
        assert result1 == result2


class TestGetCompatibilityMessage:
    """get_compatibility_message関数のテスト。"""

    def test_full_returns_none(self):
        """FULLはNoneを返す。"""
        assert get_compatibility_message(CompatibilityLevel.FULL) is None

    def test_minor_diff_returns_message(self):
        """MINOR_DIFFはメッセージを返す。"""
        message = get_compatibility_message(CompatibilityLevel.MINOR_DIFF)
        assert message is not None
        assert "マイナーバージョン" in message

    def test_major_diff_returns_message(self):
        """MAJOR_DIFFはメッセージを返す。"""
        message = get_compatibility_message(CompatibilityLevel.MAJOR_DIFF)
        assert message is not None
        assert "メジャーバージョン" in message

    def test_unknown_returns_message(self):
        """UNKNOWNはメッセージを返す。"""
        message = get_compatibility_message(CompatibilityLevel.UNKNOWN)
        assert message is not None
        assert "バージョン情報がありません" in message


class TestFormatMessages:
    """メッセージフォーマット関数のテスト。"""

    def test_format_warning_contains_versions(self):
        """警告メッセージにバージョンが含まれる。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.MINOR_DIFF,
            project_version="0.8.0",
            current_version="0.9.0",
        )
        warning = format_compatibility_warning(result)
        assert "0.8.0" in warning
        assert "0.9.0" in warning

    def test_format_warning_handles_none_version(self):
        """警告メッセージはNoneバージョンを「不明」と表示。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.UNKNOWN,
            project_version=None,
            current_version="0.9.0",
        )
        warning = format_compatibility_warning(result)
        assert "不明" in warning

    def test_format_error_contains_update_instruction(self):
        """エラーメッセージにupdate指示が含まれる。"""
        result = CompatibilityResult(
            level=CompatibilityLevel.MAJOR_DIFF,
            project_version="0.9.0",
            current_version="1.0.0",
        )
        error = format_compatibility_error(result)
        assert "railway update" in error
```

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

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

### Refactor Phase: 改善

1. エラーメッセージの国際化対応準備
2. ログ出力の追加
3. より詳細な互換性レポートの生成

## 依存関係

- #01 プロジェクトバージョン記録

## 優先度

**高** - 互換性問題の防止
