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

## 概要

`railway init` でプロジェクト生成時に、使用した railway-framework のバージョン情報を記録する。

## 現状

```
my_project/
├── src/
├── tests/
├── config/
└── pyproject.toml  ← railway-framework バージョン情報なし
```

## 目標

```
my_project/
├── .railway/
│   └── project.yaml  ← バージョン情報を記録
├── src/
├── tests/
├── config/
└── pyproject.toml
```

## 設計

### .railway/project.yaml フォーマット

```yaml
# Railway Framework Project Configuration
# This file is auto-generated. Do not edit manually.

railway:
  # このプロジェクトを生成した railway-framework のバージョン
  version: "0.9.0"

  # プロジェクト作成日時 (ISO 8601)
  created_at: "2026-01-23T10:30:00+09:00"

  # 最後の更新日時 (railway update 実行時に更新)
  updated_at: "2026-01-23T10:30:00+09:00"

project:
  # プロジェクト名
  name: "my_project"

compatibility:
  # このバージョン以上の railway-framework が必要
  min_version: "0.9.0"

  # このバージョン以下の railway-framework が必要 (null = 制限なし)
  max_version: null
```

### 後方互換性

既存プロジェクト（`.railway/project.yaml` なし）では:
1. バージョン情報なしとして扱う
2. 警告を表示するが、コマンドは実行可能
3. `railway update --init` で `.railway/project.yaml` を追加可能

## 設計原則

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

1. **イミュータブルデータモデル**: Pydantic の `frozen=True` を使用
2. **純粋関数によるIO分離**: パース/シリアライズは純粋関数、IO操作は別レイヤー
3. **Result型パターン**: 成功/失敗を明示的に表現

## 実装

### 1. ProjectMetadata モデル（イミュータブル）

```python
# railway/core/project_metadata.py
"""プロジェクトメタデータの定義と操作。

関数型パラダイム:
- データモデルはイミュータブル (frozen=True)
- パース/シリアライズは純粋関数
- IO操作は専用関数で分離
"""
from datetime import datetime
from pathlib import Path
from typing import Optional

from pydantic import BaseModel, ConfigDict
import yaml


class RailwayInfo(BaseModel):
    """Railway framework information (immutable)."""
    model_config = ConfigDict(frozen=True)

    version: str
    created_at: datetime
    updated_at: datetime


class ProjectInfo(BaseModel):
    """Project information (immutable)."""
    model_config = ConfigDict(frozen=True)

    name: str


class CompatibilityInfo(BaseModel):
    """Version compatibility information (immutable)."""
    model_config = ConfigDict(frozen=True)

    min_version: str
    max_version: Optional[str] = None


class ProjectMetadata(BaseModel):
    """Complete project metadata (immutable)."""
    model_config = ConfigDict(frozen=True)

    railway: RailwayInfo
    project: ProjectInfo
    compatibility: CompatibilityInfo


# ============================================================
# 純粋関数: パース/シリアライズ（副作用なし）
# ============================================================

def parse_metadata(yaml_content: str) -> ProjectMetadata:
    """YAML文字列からProjectMetadataを生成する純粋関数。

    Args:
        yaml_content: YAML形式の文字列

    Returns:
        パースされたProjectMetadata

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


def serialize_metadata(metadata: ProjectMetadata) -> str:
    """ProjectMetadataをYAML文字列に変換する純粋関数。

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

    Returns:
        YAML形式の文字列（ヘッダーコメント付き）
    """
    header = (
        "# Railway Framework Project Configuration\n"
        "# This file is auto-generated. Do not edit manually.\n\n"
    )
    body = yaml.dump(
        metadata.model_dump(mode="json"),
        default_flow_style=False,
        allow_unicode=True,
        sort_keys=False,
    )
    return header + body


def create_metadata(
    project_name: str,
    version: str,
    now: Optional[datetime] = None,
) -> ProjectMetadata:
    """新規プロジェクト用のメタデータを生成する純粋関数。

    Args:
        project_name: プロジェクト名
        version: railway-framework バージョン
        now: 現在時刻（テスト用にオプショナル）

    Returns:
        新規ProjectMetadata
    """
    timestamp = now or datetime.now().astimezone()
    return ProjectMetadata(
        railway=RailwayInfo(
            version=version,
            created_at=timestamp,
            updated_at=timestamp,
        ),
        project=ProjectInfo(name=project_name),
        compatibility=CompatibilityInfo(
            min_version=version,
            max_version=None,
        ),
    )


def update_metadata_version(
    metadata: ProjectMetadata,
    new_version: str,
    now: Optional[datetime] = None,
) -> ProjectMetadata:
    """メタデータのバージョンを更新した新しいインスタンスを返す純粋関数。

    Args:
        metadata: 元のメタデータ
        new_version: 新しいバージョン
        now: 現在時刻（テスト用にオプショナル）

    Returns:
        更新されたProjectMetadata（元のインスタンスは変更されない）
    """
    timestamp = now or datetime.now().astimezone()
    return ProjectMetadata(
        railway=RailwayInfo(
            version=new_version,
            created_at=metadata.railway.created_at,
            updated_at=timestamp,
        ),
        project=metadata.project,
        compatibility=CompatibilityInfo(
            min_version=new_version,
            max_version=metadata.compatibility.max_version,
        ),
    )


# ============================================================
# IO関数: ファイル操作（副作用あり、分離）
# ============================================================

def get_metadata_path(project_path: Path) -> Path:
    """メタデータファイルのパスを取得する。"""
    return project_path / ".railway" / "project.yaml"


def load_metadata(project_path: Path) -> Optional[ProjectMetadata]:
    """プロジェクトからメタデータを読み込む。

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

    Returns:
        ProjectMetadata、またはファイルが存在しない場合はNone

    Raises:
        ValueError: ファイルは存在するがパース失敗時
    """
    metadata_path = get_metadata_path(project_path)
    if not metadata_path.exists():
        return None

    content = metadata_path.read_text(encoding="utf-8")
    return parse_metadata(content)


def save_metadata(project_path: Path, metadata: ProjectMetadata) -> Path:
    """メタデータをプロジェクトに保存する。

    Args:
        project_path: プロジェクトルートパス
        metadata: 保存するメタデータ

    Returns:
        保存先のパス
    """
    railway_dir = project_path / ".railway"
    railway_dir.mkdir(exist_ok=True)

    metadata_path = get_metadata_path(project_path)
    content = serialize_metadata(metadata)
    metadata_path.write_text(content, encoding="utf-8")

    return metadata_path
```

### 2. プロジェクトルート検出関数

```python
# railway/core/project_discovery.py
"""プロジェクト検出機能。"""
from pathlib import Path
from typing import Optional


def find_project_root(start_path: Optional[Path] = None) -> Optional[Path]:
    """Railwayプロジェクトのルートディレクトリを探す。

    以下のマーカーを探索:
    1. .railway/ ディレクトリ
    2. src/ と pyproject.toml の組み合わせ

    Args:
        start_path: 探索開始パス（省略時はカレントディレクトリ）

    Returns:
        プロジェクトルートパス、見つからない場合はNone
    """
    current = (start_path or Path.cwd()).resolve()

    for path in [current, *current.parents]:
        # .railway/ ディレクトリがあれば確実
        if (path / ".railway").is_dir():
            return path
        # src/ と pyproject.toml があれば候補
        if (path / "src").is_dir() and (path / "pyproject.toml").exists():
            return path

    return None
```

### 3. init コマンド修正

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

from railway import __version__
from railway.core.project_metadata import create_metadata, save_metadata


def init(project_name: str, ...):
    # ... 既存のプロジェクト生成処理 ...

    # メタデータ生成と保存（関数分離）
    metadata = create_metadata(
        project_name=project_name,
        version=__version__,
    )
    save_metadata(project_path, metadata)
```

### 4. .gitignore に追加しない

`.railway/project.yaml` はバージョン管理対象とする（チームで共有）。

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

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

```
tests/unit/core/
└── test_project_metadata.py
```

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

```python
# tests/unit/core/test_project_metadata.py
"""ProjectMetadata のテスト。

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

import pytest

from railway.core.project_metadata import (
    ProjectMetadata,
    RailwayInfo,
    ProjectInfo,
    CompatibilityInfo,
    parse_metadata,
    serialize_metadata,
    create_metadata,
    update_metadata_version,
    load_metadata,
    save_metadata,
)


class TestProjectMetadataImmutability:
    """メタデータがイミュータブルであることを検証。"""

    def test_project_metadata_is_frozen(self):
        """ProjectMetadataは変更不可であること。"""
        metadata = create_metadata("test", "0.9.0")

        with pytest.raises(Exception):  # ValidationError or TypeError
            metadata.railway = RailwayInfo(
                version="1.0.0",
                created_at=datetime.now(timezone.utc),
                updated_at=datetime.now(timezone.utc),
            )

    def test_railway_info_is_frozen(self):
        """RailwayInfoは変更不可であること。"""
        info = RailwayInfo(
            version="0.9.0",
            created_at=datetime.now(timezone.utc),
            updated_at=datetime.now(timezone.utc),
        )

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


class TestPureFunctions:
    """純粋関数のテスト（副作用なし、参照透過性）。"""

    def test_create_metadata_returns_valid_metadata(self):
        """create_metadataは有効なメタデータを返す。"""
        now = datetime(2026, 1, 23, 10, 30, 0, tzinfo=timezone.utc)

        metadata = create_metadata("my_project", "0.9.0", now=now)

        assert metadata.project.name == "my_project"
        assert metadata.railway.version == "0.9.0"
        assert metadata.railway.created_at == now
        assert metadata.railway.updated_at == now
        assert metadata.compatibility.min_version == "0.9.0"
        assert metadata.compatibility.max_version is None

    def test_create_metadata_is_referentially_transparent(self):
        """同じ引数で同じ結果を返す（参照透過性）。"""
        now = datetime(2026, 1, 23, 10, 30, 0, tzinfo=timezone.utc)

        result1 = create_metadata("test", "0.9.0", now=now)
        result2 = create_metadata("test", "0.9.0", now=now)

        assert result1 == result2

    def test_update_metadata_version_does_not_mutate_original(self):
        """update_metadata_versionは元のインスタンスを変更しない。"""
        original = create_metadata("test", "0.9.0")
        original_version = original.railway.version

        updated = update_metadata_version(original, "1.0.0")

        assert original.railway.version == original_version  # 変更なし
        assert updated.railway.version == "1.0.0"
        assert updated is not original

    def test_update_metadata_version_preserves_created_at(self):
        """update_metadata_versionはcreated_atを保持する。"""
        created = datetime(2025, 1, 1, tzinfo=timezone.utc)
        original = ProjectMetadata(
            railway=RailwayInfo(
                version="0.9.0",
                created_at=created,
                updated_at=created,
            ),
            project=ProjectInfo(name="test"),
            compatibility=CompatibilityInfo(min_version="0.9.0"),
        )

        updated = update_metadata_version(original, "1.0.0")

        assert updated.railway.created_at == created

    def test_serialize_then_parse_roundtrip(self):
        """シリアライズ→パースで元のデータを復元できる。"""
        original = create_metadata("my_project", "0.9.0")

        yaml_str = serialize_metadata(original)
        restored = parse_metadata(yaml_str)

        assert restored.project.name == original.project.name
        assert restored.railway.version == original.railway.version

    def test_serialize_includes_header_comment(self):
        """シリアライズ結果にヘッダーコメントが含まれる。"""
        metadata = create_metadata("test", "0.9.0")

        yaml_str = serialize_metadata(metadata)

        assert "# Railway Framework Project Configuration" in yaml_str
        assert "# This file is auto-generated" in yaml_str


class TestIOFunctions:
    """IO関数のテスト（副作用あり）。"""

    def test_save_creates_railway_directory(self, tmp_path: Path):
        """save_metadataは.railwayディレクトリを作成する。"""
        metadata = create_metadata("test", "0.9.0")

        save_metadata(tmp_path, metadata)

        assert (tmp_path / ".railway").is_dir()

    def test_save_creates_project_yaml(self, tmp_path: Path):
        """save_metadataはproject.yamlを作成する。"""
        metadata = create_metadata("test", "0.9.0")

        save_metadata(tmp_path, metadata)

        assert (tmp_path / ".railway" / "project.yaml").exists()

    def test_load_returns_none_for_missing_file(self, tmp_path: Path):
        """load_metadataはファイルがない場合Noneを返す。"""
        result = load_metadata(tmp_path)

        assert result is None

    def test_save_then_load_roundtrip(self, tmp_path: Path):
        """save→loadでデータを復元できる。"""
        original = create_metadata("my_project", "0.9.0")

        save_metadata(tmp_path, original)
        loaded = load_metadata(tmp_path)

        assert loaded is not None
        assert loaded.project.name == "my_project"
        assert loaded.railway.version == "0.9.0"

    def test_load_invalid_yaml_raises_error(self, tmp_path: Path):
        """不正なYAMLはエラーを発生させる。"""
        railway_dir = tmp_path / ".railway"
        railway_dir.mkdir()
        (railway_dir / "project.yaml").write_text("invalid: yaml: content:")

        with pytest.raises(Exception):
            load_metadata(tmp_path)
```

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

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

### Refactor Phase: 改善

1. エラーメッセージの改善
2. ログ出力の追加
3. ドキュメンテーション強化

## 依存関係

- なし（最初に実装）

## 優先度

**高** - 他の全ての機能の基盤
