Skip to main content

babyrite/
config.rs

1//! Configuration module for Babyrite.
2//!
3//! This module handles both environment variables and file-based configuration.
4
5use serde::Deserialize;
6use std::sync::OnceLock;
7
8/// Global configuration instance.
9pub static CONFIG: OnceLock<BabyriteConfig> = OnceLock::new();
10
11/// Environment variable configuration.
12#[derive(Deserialize, Debug)]
13pub struct EnvConfig {
14    /// Discord API token for bot authentication.
15    pub discord_api_token: String,
16    /// Optional path to the configuration file.
17    #[serde(default)]
18    #[serde(deserialize_with = "crate::config::empty_string_as_none")]
19    pub config_file_path: Option<String>,
20}
21
22impl EnvConfig {
23    /// Returns a reference to the environment configuration.
24    ///
25    /// Initializes the configuration from environment variables on first call.
26    pub fn get() -> &'static EnvConfig {
27        static ENV_CONFIG: OnceLock<EnvConfig> = OnceLock::new();
28        ENV_CONFIG
29            .get_or_init(|| envy::from_env().expect("Failed to load environment configuration."))
30    }
31}
32
33/// Babyrite configuration.
34///
35/// Loaded from `config.toml`. All fields have default values, so existing
36/// configuration files without the new sections will continue to work.
37#[derive(Deserialize, Debug, Default)]
38pub struct BabyriteConfig {
39    /// If enabled, logs are output in JSON format.
40    #[serde(default)]
41    pub json_logging: bool,
42    /// Feature flags for enabling/disabling specific functionality.
43    #[serde(default)]
44    pub features: FeatureConfig,
45    /// GitHub-related configuration.
46    #[serde(default)]
47    pub github: GitHubConfig,
48}
49
50/// Feature flags configuration.
51///
52/// Controls which link expansion features are enabled.
53#[derive(Deserialize, Debug)]
54pub struct FeatureConfig {
55    /// Whether GitHub Permalink expansion is enabled.
56    ///
57    /// Defaults to `true`.
58    #[serde(default = "default_true")]
59    pub github_permalink: bool,
60}
61
62impl Default for FeatureConfig {
63    fn default() -> Self {
64        Self {
65            github_permalink: default_true(),
66        }
67    }
68}
69
70/// GitHub-related configuration.
71#[derive(Deserialize, Debug)]
72pub struct GitHubConfig {
73    /// Maximum number of lines to display without truncation.
74    ///
75    /// Defaults to `50`.
76    #[serde(default = "default_max_lines")]
77    pub max_lines: usize,
78}
79
80impl Default for GitHubConfig {
81    fn default() -> Self {
82        Self {
83            max_lines: default_max_lines(),
84        }
85    }
86}
87
88fn default_true() -> bool {
89    true
90}
91
92fn default_max_lines() -> usize {
93    50
94}
95
96/// Errors that can occur when loading configuration.
97#[derive(thiserror::Error, Debug)]
98pub enum BabyriteConfigError {
99    /// Failed to read the configuration file from disk.
100    #[error("Failed to read configuration file.")]
101    Read,
102    /// Failed to parse the configuration file contents.
103    #[error("Failed to parse configuration file.")]
104    Parse,
105    /// Failed to set the global configuration.
106    #[error("Failed to set configuration file.")]
107    Set,
108}
109
110impl BabyriteConfig {
111    /// Initializes the global configuration.
112    ///
113    /// Loads configuration from a file if `CONFIG_FILE_PATH` is set,
114    /// otherwise uses default values.
115    pub fn init() -> anyhow::Result<(), BabyriteConfigError> {
116        let envs = EnvConfig::get();
117        match &envs.config_file_path {
118            Some(p) => {
119                let buffer = &std::fs::read_to_string(p).map_err(|_| BabyriteConfigError::Read)?;
120                let config: BabyriteConfig =
121                    toml::from_str(buffer).map_err(|_| BabyriteConfigError::Parse)?;
122                Ok(CONFIG.set(config).map_err(|_| BabyriteConfigError::Parse)?)
123            }
124            None => Ok(CONFIG
125                .set(BabyriteConfig::default())
126                .map_err(|_| BabyriteConfigError::Set)?),
127        }
128    }
129
130    /// Returns a reference to the global configuration.
131    ///
132    /// # Panics
133    ///
134    /// Panics if [`BabyriteConfig::init`] has not been called.
135    pub fn get() -> &'static BabyriteConfig {
136        CONFIG.get().expect("Failed to get configuration.")
137    }
138}
139
140/// Deserialize a string as an `Option<String>`, treating empty strings as `None`.
141pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
142where
143    D: serde::Deserializer<'de>,
144{
145    let opt = Option::<String>::deserialize(deserializer)?;
146    Ok(opt.filter(|s| !s.is_empty()))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn default_config() {
155        let config = BabyriteConfig::default();
156        assert!(!config.json_logging);
157        assert!(config.features.github_permalink);
158        assert_eq!(config.github.max_lines, 50);
159    }
160
161    #[test]
162    fn deserialize_empty_config() {
163        let config: BabyriteConfig = toml::from_str("").unwrap();
164        assert!(!config.json_logging);
165        assert!(config.features.github_permalink);
166        assert_eq!(config.github.max_lines, 50);
167    }
168
169    #[test]
170    fn deserialize_full_config() {
171        let toml_str = r#"
172            json_logging = true
173
174            [features]
175            github_permalink = false
176
177            [github]
178            max_lines = 100
179        "#;
180        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
181        assert!(config.json_logging);
182        assert!(!config.features.github_permalink);
183        assert_eq!(config.github.max_lines, 100);
184    }
185
186    #[test]
187    fn deserialize_partial_config() {
188        let toml_str = r#"
189            json_logging = true
190        "#;
191        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
192        assert!(config.json_logging);
193        // defaults
194        assert!(config.features.github_permalink);
195        assert_eq!(config.github.max_lines, 50);
196    }
197
198    #[test]
199    fn empty_string_as_none_with_empty() {
200        #[derive(Deserialize)]
201        struct Test {
202            #[serde(default, deserialize_with = "empty_string_as_none")]
203            value: Option<String>,
204        }
205
206        let t: Test = toml::from_str(r#"value = """#).unwrap();
207        assert!(t.value.is_none());
208    }
209
210    #[test]
211    fn empty_string_as_none_with_value() {
212        #[derive(Deserialize)]
213        struct Test {
214            #[serde(default, deserialize_with = "empty_string_as_none")]
215            value: Option<String>,
216        }
217
218        let t: Test = toml::from_str(r#"value = "hello""#).unwrap();
219        assert_eq!(t.value.as_deref(), Some("hello"));
220    }
221
222    #[test]
223    fn empty_string_as_none_absent() {
224        #[derive(Deserialize)]
225        struct Test {
226            #[serde(default, deserialize_with = "empty_string_as_none")]
227            value: Option<String>,
228        }
229
230        let t: Test = toml::from_str("").unwrap();
231        assert!(t.value.is_none());
232    }
233}