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    ///
41    /// Deprecated: use `[log] format = "json"` instead. Kept for backward
42    /// compatibility — it is only consulted when `log.format` is unset
43    /// (see [`BabyriteConfig::resolved_log_format`]).
44    #[serde(default)]
45    pub json_logging: bool,
46    /// Logging configuration (level and format).
47    #[serde(default)]
48    pub log: LogConfig,
49    /// Feature flags for enabling/disabling specific functionality.
50    #[serde(default)]
51    pub features: FeatureConfig,
52    /// GitHub-related configuration.
53    #[serde(default)]
54    pub github: GitHubConfig,
55}
56
57/// Logging configuration.
58///
59/// Controls the log level filter and output format.
60/// Missing fields fall back to [`LogConfig::default`].
61#[derive(Deserialize, Debug)]
62#[serde(default)]
63pub struct LogConfig {
64    /// Tracing filter directive used when `RUST_LOG` is not set.
65    ///
66    /// Accepts the same syntax as `RUST_LOG` (e.g. `babyrite=debug`).
67    /// Defaults to `babyrite=info`.
68    pub level: String,
69    /// Output format. When unset, falls back to the deprecated `json_logging`
70    /// flag (see [`BabyriteConfig::resolved_log_format`]).
71    pub format: Option<LogFormat>,
72}
73
74impl Default for LogConfig {
75    fn default() -> Self {
76        Self {
77            level: "babyrite=info".to_string(),
78            format: None,
79        }
80    }
81}
82
83/// Log output format.
84#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85#[serde(rename_all = "lowercase")]
86pub enum LogFormat {
87    /// Human-readable compact format.
88    Compact,
89    /// Structured JSON format (recommended for Grafana Loki and similar).
90    Json,
91}
92
93/// Feature flags configuration.
94///
95/// Controls which link expansion features are enabled.
96/// Missing fields fall back to [`FeatureConfig::default`].
97#[derive(Deserialize, Debug)]
98#[serde(default)]
99pub struct FeatureConfig {
100    /// Whether GitHub Permalink expansion is enabled.
101    ///
102    /// Defaults to `true`.
103    pub github_permalink: bool,
104    /// Whether the mention-prefixed command system (`version`, `ping`, etc.) is enabled.
105    ///
106    /// Defaults to `true`.
107    pub commands: bool,
108}
109
110impl Default for FeatureConfig {
111    fn default() -> Self {
112        Self {
113            github_permalink: true,
114            commands: true,
115        }
116    }
117}
118
119/// GitHub-related configuration.
120///
121/// Missing fields fall back to [`GitHubConfig::default`].
122#[derive(Deserialize, Debug)]
123#[serde(default)]
124pub struct GitHubConfig {
125    /// Maximum number of lines to display without truncation.
126    ///
127    /// Defaults to `50`.
128    pub max_lines: usize,
129}
130
131impl Default for GitHubConfig {
132    fn default() -> Self {
133        Self { max_lines: 50 }
134    }
135}
136
137/// Errors that can occur when loading configuration.
138#[derive(thiserror::Error, Debug)]
139pub enum BabyriteConfigError {
140    /// Failed to read the configuration file from disk.
141    #[error("Failed to read configuration file.")]
142    Read,
143    /// Failed to parse the configuration file contents.
144    #[error("Failed to parse configuration file.")]
145    Parse,
146    /// Failed to set the global configuration.
147    #[error("Failed to set configuration file.")]
148    Set,
149}
150
151impl BabyriteConfig {
152    /// Initializes the global configuration.
153    ///
154    /// Loads configuration from a file if `CONFIG_FILE_PATH` is set,
155    /// otherwise uses default values.
156    pub fn init() -> Result<(), BabyriteConfigError> {
157        let config = match &EnvConfig::get().config_file_path {
158            Some(p) => {
159                let buffer = std::fs::read_to_string(p).map_err(|_| BabyriteConfigError::Read)?;
160                toml::from_str(&buffer).map_err(|_| BabyriteConfigError::Parse)?
161            }
162            None => BabyriteConfig::default(),
163        };
164        CONFIG.set(config).map_err(|_| BabyriteConfigError::Set)
165    }
166
167    /// Returns a reference to the global configuration.
168    ///
169    /// # Panics
170    ///
171    /// Panics if [`BabyriteConfig::init`] has not been called.
172    pub fn get() -> &'static BabyriteConfig {
173        CONFIG.get().expect("Failed to get configuration.")
174    }
175
176    /// Resolves the effective log output format.
177    ///
178    /// Uses `[log] format` when set; otherwise falls back to the deprecated
179    /// `json_logging` flag (`true` → JSON, `false` → compact) so existing
180    /// configuration files keep their previous behavior.
181    pub fn resolved_log_format(&self) -> LogFormat {
182        self.log.format.unwrap_or({
183            if self.json_logging {
184                LogFormat::Json
185            } else {
186                LogFormat::Compact
187            }
188        })
189    }
190}
191
192/// Deserialize a string as an `Option<String>`, treating empty strings as `None`.
193pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
194where
195    D: serde::Deserializer<'de>,
196{
197    let opt = Option::<String>::deserialize(deserializer)?;
198    Ok(opt.filter(|s| !s.is_empty()))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn default_config() {
207        let config = BabyriteConfig::default();
208        assert!(!config.json_logging);
209        assert_eq!(config.log.level, "babyrite=info");
210        assert_eq!(config.log.format, None);
211        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
212        assert!(config.features.github_permalink);
213        assert!(config.features.commands);
214        assert_eq!(config.github.max_lines, 50);
215    }
216
217    #[test]
218    fn deserialize_empty_config() {
219        let config: BabyriteConfig = toml::from_str("").unwrap();
220        assert!(!config.json_logging);
221        assert_eq!(config.log.level, "babyrite=info");
222        assert_eq!(config.log.format, None);
223        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
224        assert!(config.features.github_permalink);
225        assert!(config.features.commands);
226        assert_eq!(config.github.max_lines, 50);
227    }
228
229    #[test]
230    fn deserialize_log_section() {
231        let toml_str = r#"
232            [log]
233            level = "babyrite=debug"
234            format = "json"
235        "#;
236        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
237        assert_eq!(config.log.level, "babyrite=debug");
238        assert_eq!(config.log.format, Some(LogFormat::Json));
239        assert_eq!(config.resolved_log_format(), LogFormat::Json);
240    }
241
242    #[test]
243    fn resolved_log_format_falls_back_to_json_logging() {
244        // `[log] format` unset but the deprecated `json_logging` is enabled:
245        // the format should resolve to JSON for backward compatibility.
246        let toml_str = r#"
247            json_logging = true
248        "#;
249        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
250        assert_eq!(config.log.format, None);
251        assert_eq!(config.resolved_log_format(), LogFormat::Json);
252    }
253
254    #[test]
255    fn log_format_overrides_json_logging() {
256        // When both are set, `[log] format` wins over the deprecated flag.
257        let toml_str = r#"
258            json_logging = true
259
260            [log]
261            format = "compact"
262        "#;
263        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
264        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
265    }
266
267    #[test]
268    fn deserialize_full_config() {
269        let toml_str = r#"
270            json_logging = true
271
272            [features]
273            github_permalink = false
274
275            [github]
276            max_lines = 100
277        "#;
278        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
279        assert!(config.json_logging);
280        assert!(!config.features.github_permalink);
281        assert_eq!(config.github.max_lines, 100);
282    }
283
284    #[test]
285    fn deserialize_partial_config() {
286        let toml_str = r#"
287            json_logging = true
288        "#;
289        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
290        assert!(config.json_logging);
291        // defaults
292        assert!(config.features.github_permalink);
293        assert_eq!(config.github.max_lines, 50);
294    }
295
296    #[derive(Deserialize)]
297    struct EmptyStringAsNone {
298        #[serde(default, deserialize_with = "empty_string_as_none")]
299        value: Option<String>,
300    }
301
302    #[test]
303    fn empty_string_as_none_with_empty() {
304        let t: EmptyStringAsNone = toml::from_str(r#"value = """#).unwrap();
305        assert!(t.value.is_none());
306    }
307
308    #[test]
309    fn empty_string_as_none_with_value() {
310        let t: EmptyStringAsNone = toml::from_str(r#"value = "hello""#).unwrap();
311        assert_eq!(t.value.as_deref(), Some("hello"));
312    }
313
314    #[test]
315    fn empty_string_as_none_absent() {
316        let t: EmptyStringAsNone = toml::from_str("").unwrap();
317        assert!(t.value.is_none());
318    }
319}