# Issue #06: 設定管理（Settings）

**Phase:** 1a
**優先度:** 高
**依存関係:** #02
**見積もり:** 1日

---

## 概要

pydantic-settingsを使った型安全な設定管理を実装する。
YAML設定ファイルの読み込みと環境変数のオーバーライドをサポートする。

---

## TDD実装手順

### Step 1: Red（テストを書く）

```python
# tests/unit/core/test_settings.py
"""Tests for Settings management."""
import pytest
from unittest.mock import patch, mock_open
import os
import tempfile
from pathlib import Path


class TestSettingsBasic:
    """Test basic Settings functionality."""

    def test_settings_creation(self):
        """Should create Settings instance."""
        from railway.core.settings import Settings

        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            config_file = config_dir / "development.yaml"
            config_file.write_text("""
app:
  name: test_app
api:
  base_url: https://api.example.com
""")
            with patch.dict(os.environ, {"RAILWAY_ENV": "development"}):
                settings = Settings(_config_dir=str(config_dir))

            assert settings is not None

    def test_settings_default_env(self):
        """Should default to development environment."""
        from railway.core.settings import Settings

        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text("app:\n  name: dev")

            settings = Settings(_config_dir=str(config_dir))
            assert settings.railway_env == "development"

    def test_settings_env_override(self):
        """Should respect RAILWAY_ENV environment variable."""
        from railway.core.settings import Settings

        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "production.yaml").write_text("app:\n  name: prod")

            with patch.dict(os.environ, {"RAILWAY_ENV": "production"}):
                settings = Settings(_config_dir=str(config_dir))

            assert settings.railway_env == "production"


class TestAPISettings:
    """Test API settings."""

    def test_api_settings_loaded(self):
        """Should load API settings from YAML."""
        from railway.core.settings import Settings

        yaml_content = """
api:
  base_url: https://api.test.com
  timeout: 60
  max_retries: 5
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            assert settings.api is not None
            assert settings.api.base_url == "https://api.test.com"
            assert settings.api.timeout == 60
            assert settings.api.max_retries == 5

    def test_api_settings_defaults(self):
        """Should use defaults for optional API settings."""
        from railway.core.settings import Settings

        yaml_content = """
api:
  base_url: https://api.test.com
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            assert settings.api.timeout == 30  # default
            assert settings.api.max_retries == 3  # default


class TestRetrySettings:
    """Test retry settings."""

    def test_retry_default_settings(self):
        """Should load default retry settings."""
        from railway.core.settings import Settings

        yaml_content = """
retry:
  default:
    max_attempts: 5
    min_wait: 1
    max_wait: 30
    multiplier: 2
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            assert settings.retry.default.max_attempts == 5
            assert settings.retry.default.min_wait == 1

    def test_retry_node_specific_settings(self):
        """Should load node-specific retry settings."""
        from railway.core.settings import Settings

        yaml_content = """
retry:
  default:
    max_attempts: 3
  nodes:
    fetch_data:
      max_attempts: 10
      min_wait: 5
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            # Get retry settings for specific node
            retry_config = settings.get_retry_settings("fetch_data")
            assert retry_config.max_attempts == 10
            assert retry_config.min_wait == 5

    def test_get_retry_settings_fallback(self):
        """Should fallback to default for unknown nodes."""
        from railway.core.settings import Settings

        yaml_content = """
retry:
  default:
    max_attempts: 3
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            retry_config = settings.get_retry_settings("unknown_node")
            assert retry_config.max_attempts == 3


class TestLoggingSettings:
    """Test logging settings."""

    def test_logging_settings_loaded(self):
        """Should load logging settings."""
        from railway.core.settings import Settings

        yaml_content = """
logging:
  level: DEBUG
  format: "{time} | {level} | {message}"
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            assert settings.logging.level == "DEBUG"
            assert "{time}" in settings.logging.format

    def test_logging_handlers(self):
        """Should load logging handlers."""
        from railway.core.settings import Settings

        yaml_content = """
logging:
  level: INFO
  handlers:
    - type: console
      level: DEBUG
    - type: file
      path: logs/app.log
      level: INFO
      rotation: "1 day"
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            settings = Settings(_config_dir=str(config_dir))

            assert len(settings.logging.handlers) == 2
            assert settings.logging.handlers[0].type == "console"
            assert settings.logging.handlers[1].type == "file"


class TestSettingsEnvOverride:
    """Test environment variable overrides."""

    def test_log_level_override(self):
        """Should override log level from environment."""
        from railway.core.settings import Settings

        yaml_content = """
logging:
  level: INFO
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text(yaml_content)

            with patch.dict(os.environ, {"LOG_LEVEL": "DEBUG"}):
                settings = Settings(_config_dir=str(config_dir))

            assert settings.logging.level == "DEBUG"


class TestSettingsMissingConfig:
    """Test behavior with missing config."""

    def test_missing_config_file(self):
        """Should handle missing config file gracefully."""
        from railway.core.settings import Settings

        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            # No config file created

            settings = Settings(_config_dir=str(config_dir))

            # Should have defaults
            assert settings.retry.default.max_attempts == 3

    def test_invalid_yaml(self):
        """Should raise error for invalid YAML."""
        from railway.core.settings import Settings

        with tempfile.TemporaryDirectory() as tmpdir:
            config_dir = Path(tmpdir) / "config"
            config_dir.mkdir()
            (config_dir / "development.yaml").write_text("invalid: yaml: content:")

            with pytest.raises(Exception):  # YAML parse error
                Settings(_config_dir=str(config_dir))
```

```bash
# 実行して失敗を確認
pytest tests/unit/core/test_settings.py -v
# Expected: FAILED (ImportError)
```

### Step 2: Green（最小限の実装）

```python
# railway/core/settings.py
"""
Settings management for Railway Framework.
"""
from pathlib import Path
from typing import Any, Dict, List
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
import yaml
import os


class APISettings(BaseModel):
    """API configuration."""
    base_url: str = ""
    timeout: int = 30
    max_retries: int = 3


class DatabaseSettings(BaseModel):
    """Database configuration."""
    host: str = "localhost"
    port: int = 5432
    name: str = "db"
    user: str | None = None
    password: str | None = None


class RetryNodeSettings(BaseModel):
    """Per-node retry settings."""
    max_attempts: int = 3
    min_wait: int = 2
    max_wait: int = 10
    multiplier: int = 1


class RetrySettings(BaseModel):
    """Retry policy configuration."""
    default: RetryNodeSettings = Field(default_factory=RetryNodeSettings)
    nodes: Dict[str, RetryNodeSettings] = Field(default_factory=dict)


class LoggingHandlerSettings(BaseModel):
    """Logging handler configuration."""
    type: str  # file, console
    level: str = "INFO"
    path: str | None = None
    rotation: str | None = None
    retention: str | None = None


class LoggingSettings(BaseModel):
    """Logging configuration."""
    level: str = "INFO"
    format: str = "{time:HH:mm:ss} | {level} | {message}"
    handlers: List[LoggingHandlerSettings] = Field(default_factory=list)


class Settings(BaseSettings):
    """
    Application settings.

    Loads configuration from:
    1. Environment variables (.env file)
    2. YAML config file (config/{env}.yaml)

    Environment variables override YAML config values.
    """

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
        extra="allow"
    )

    # Environment variables
    railway_env: str = "development"
    app_name: str = "railway_app"
    log_level: str | None = None  # Override from .env

    # Configuration from YAML
    api: APISettings = Field(default_factory=APISettings)
    database: DatabaseSettings = Field(default_factory=DatabaseSettings)
    retry: RetrySettings = Field(default_factory=RetrySettings)
    logging: LoggingSettings = Field(default_factory=LoggingSettings)

    # Internal: config directory (for testing)
    _config_dir: str | None = None

    def __init__(self, _config_dir: str | None = None, **kwargs: Any):
        """Initialize settings and load YAML config."""
        super().__init__(**kwargs)
        self._config_dir = _config_dir
        self._load_config()
        self._apply_overrides()

    def _load_config(self) -> None:
        """Load configuration from YAML file."""
        if self._config_dir:
            config_dir = Path(self._config_dir)
        else:
            # Default: look for config/ in current directory or parent
            config_dir = Path.cwd() / "config"
            if not config_dir.exists():
                config_dir = Path(__file__).parent.parent.parent / "config"

        config_file = config_dir / f"{self.railway_env}.yaml"

        if not config_file.exists():
            return

        with open(config_file, "r", encoding="utf-8") as f:
            config = yaml.safe_load(f) or {}

        # Load each section
        if "api" in config:
            self.api = APISettings(**config["api"])

        if "database" in config:
            self.database = DatabaseSettings(**config["database"])

        if "retry" in config:
            retry_config = config["retry"]
            default_retry = RetryNodeSettings(**retry_config.get("default", {}))
            nodes_retry = {
                name: RetryNodeSettings(**settings)
                for name, settings in retry_config.get("nodes", {}).items()
            }
            self.retry = RetrySettings(default=default_retry, nodes=nodes_retry)

        if "logging" in config:
            log_config = config["logging"]
            handlers = [
                LoggingHandlerSettings(**h)
                for h in log_config.get("handlers", [])
            ]
            self.logging = LoggingSettings(
                level=log_config.get("level", "INFO"),
                format=log_config.get("format", "{time:HH:mm:ss} | {level} | {message}"),
                handlers=handlers,
            )

    def _apply_overrides(self) -> None:
        """Apply environment variable overrides."""
        if self.log_level:
            self.logging.level = self.log_level

    def get_retry_settings(self, node_name: str) -> RetryNodeSettings:
        """
        Get retry settings for a specific node.

        Args:
            node_name: Name of the node

        Returns:
            RetryNodeSettings for the node, or default if not specified
        """
        return self.retry.nodes.get(node_name, self.retry.default)
```

```bash
# 実行して成功を確認
pytest tests/unit/core/test_settings.py -v
# Expected: PASSED
```

### Step 3: Refactor

- バリデーションエラーメッセージの改善
- ドキュメントの充実

---

## 完了条件

- [ ] Settingsインスタンスが作成できる
- [ ] YAML設定ファイルが読み込まれる
- [ ] 環境変数でオーバーライドできる
- [ ] API、Database、Retry、Logging設定が読み込まれる
- [ ] `get_retry_settings()`がノード別設定を返す
- [ ] 設定ファイルがない場合はデフォルト値が使われる
- [ ] テストカバレッジ90%以上

---

## 次のIssue

- #07: ロギング初期化
