oxipage-core 0.2.0

Oxipage 코어 라이브러리 — Axum 서버 부트스트랩, SQLite 마이그레이션, 인증, Extension trait, SSR 스냅샷, 레이트리밋
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Deserialize)]
pub struct Config {
    pub site: SiteConfig,
    #[serde(default)]
    pub server: ServerConfig,
    #[serde(default)]
    pub extensions: ExtensionsConfig,
    #[serde(default)]
    pub integrations: IntegrationsConfig,
    #[serde(default)]
    pub lobby: LobbySection,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            site: SiteConfig {
                name: "Oxipage".into(),
                base_url: "http://127.0.0.1:8787".into(),
                default_lang: default_lang(),
                languages: default_languages(),
            },
            server: ServerConfig::default(),
            extensions: ExtensionsConfig::default(),
            integrations: IntegrationsConfig::default(),
            lobby: LobbySection::default(),
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct SiteConfig {
    pub name: String,
    pub base_url: String,
    #[serde(default = "default_lang")]
    pub default_lang: String,
    #[serde(default = "default_languages")]
    pub languages: Vec<String>,
}

fn default_lang() -> String {
    "ko".into()
}

fn default_languages() -> Vec<String> {
    vec!["ko".into(), "en".into()]
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
    pub host: String,
    pub port: u16,
    pub data_dir: PathBuf,
    pub api_endpoint: Option<String>,
}

impl Default for ServerConfig {
    fn default() -> Self {
        ServerConfig {
            host: "127.0.0.1".into(),
            port: 8787,
            data_dir: PathBuf::from("data"),
            api_endpoint: None,
        }
    }
}

#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExtensionsConfig {
    #[serde(default)]
    pub enabled: Vec<String>,
}

/// doc/04 §4.4 [integrations]. 값 자체가 아닌 환경변수 이름을 저장 (git 커밋 안전).
#[derive(Debug, Clone, Deserialize, Default)]
pub struct IntegrationsConfig {
    #[serde(default)]
    pub github_username: Option<String>,
    #[serde(default)]
    pub tmdb_api_key_env: Option<String>,
    #[serde(default)]
    pub aladin_ttbkey_env: Option<String>,
}

impl IntegrationsConfig {
    /// github_username을 TOML에서 읽거나 OXIPAGE_GITHUB_USERNAME 폴백.
    pub fn github_username(&self) -> Option<String> {
        self.github_username
            .clone()
            .or_else(|| std::env::var("OXIPAGE_GITHUB_USERNAME").ok())
            .filter(|s| !s.is_empty())
    }

    /// tmdb_api_key_env가 가리키는 환경변수에서 키 값을 읽거나 OXIPAGE_TMDB_KEY 폴백.
    pub fn tmdb_key(&self) -> Option<String> {
        let env_name = self.tmdb_api_key_env.as_deref().unwrap_or("OXIPAGE_TMDB_KEY");
        std::env::var(env_name).ok().filter(|s| !s.is_empty())
    }

    /// 알라딘 TTBKey. 환경변수 이름 또는 OXIPAGE_ALADIN_TTBKEY 폴백.
    pub fn aladin_key(&self) -> Option<String> {
        let env_name = self
            .aladin_ttbkey_env
            .as_deref()
            .unwrap_or("OXIPAGE_ALADIN_TTBKEY");
        std::env::var(env_name).ok().filter(|s| !s.is_empty())
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LobbySection {
    pub default_mode: String,
}

impl Default for LobbySection {
    fn default() -> Self {
        LobbySection {
            default_mode: "grid".into(),
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("failed to read config file {0}: {1}")]
    Read(PathBuf, std::io::Error),
    #[error("failed to parse config file {0}: {1}")]
    Parse(PathBuf, toml::de::Error),
}

impl Config {
    pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
        toml::from_str(s)
    }

    pub fn load(path: &Path) -> Result<Self, ConfigError> {
        let raw =
            std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
        let mut cfg: Config =
            toml::from_str(&raw).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
        cfg.apply_env_overrides();
        Ok(cfg)
    }

    pub fn apply_env_overrides(&mut self) {
        if let Ok(port) = std::env::var("OXIPAGE_PORT")
            && let Ok(port) = port.parse::<u16>()
        {
            self.server.port = port;
        }
        if let Ok(dir) = std::env::var("OXIPAGE_DATA_DIR") {
            self.server.data_dir = PathBuf::from(dir);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_minimal_config_with_defaults() {
        let cfg = Config::from_toml_str(
            r#"
[site]
name = "테스트 작업실"
base_url = "https://example.dev"
"#,
        )
        .unwrap();
        assert_eq!(cfg.site.name, "테스트 작업실");
        assert_eq!(cfg.site.default_lang, "ko");
        assert_eq!(cfg.site.languages, vec!["ko", "en"]);
        assert_eq!(cfg.server.port, 8787);
        assert_eq!(cfg.server.host, "127.0.0.1");
        assert!(cfg.extensions.enabled.is_empty());
        assert_eq!(cfg.lobby.default_mode, "grid");
    }

    #[test]
    fn parses_full_config() {
        let cfg = Config::from_toml_str(
            r#"
[site]
name = "S"
base_url = "https://b.dev"
default_lang = "en"
languages = ["en", "ko"]

[server]
port = 9999
data_dir = "/var/oxipage"

[extensions]
enabled = ["profile", "blog"]

[lobby]
default_mode = "canvas"
"#,
        )
        .unwrap();
        assert_eq!(cfg.site.default_lang, "en");
        assert_eq!(cfg.server.port, 9999);
        assert_eq!(
            cfg.server.data_dir,
            std::path::PathBuf::from("/var/oxipage")
        );
        assert_eq!(cfg.extensions.enabled, vec!["profile", "blog"]);
        assert_eq!(cfg.lobby.default_mode, "canvas");
    }

    #[test]
    fn rejects_invalid_toml() {
        assert!(Config::from_toml_str("not [valid").is_err());
    }

    #[test]
    fn env_overrides_port_and_data_dir() {
        unsafe {
            std::env::set_var("OXIPAGE_PORT", "1234");
            std::env::set_var("OXIPAGE_DATA_DIR", "/tmp/oxi-test");
        }
        let mut cfg = Config::default();
        cfg.apply_env_overrides();
        assert_eq!(cfg.server.port, 1234);
        assert_eq!(
            cfg.server.data_dir,
            std::path::PathBuf::from("/tmp/oxi-test")
        );
        unsafe {
            std::env::remove_var("OXIPAGE_PORT");
            std::env::remove_var("OXIPAGE_DATA_DIR");
        }
    }
}