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}
105
106impl Default for FeatureConfig {
107    fn default() -> Self {
108        Self {
109            github_permalink: true,
110        }
111    }
112}
113
114/// GitHub-related configuration.
115///
116/// Missing fields fall back to [`GitHubConfig::default`].
117#[derive(Deserialize, Debug)]
118#[serde(default)]
119pub struct GitHubConfig {
120    /// Maximum number of lines to display without truncation.
121    ///
122    /// Defaults to `50`.
123    pub max_lines: usize,
124}
125
126impl Default for GitHubConfig {
127    fn default() -> Self {
128        Self { max_lines: 50 }
129    }
130}
131
132/// Errors that can occur when loading configuration.
133#[derive(thiserror::Error, Debug)]
134pub enum BabyriteConfigError {
135    /// Failed to read the configuration file from disk.
136    #[error("Failed to read configuration file.")]
137    Read,
138    /// Failed to parse the configuration file contents.
139    #[error("Failed to parse configuration file.")]
140    Parse,
141    /// Failed to set the global configuration.
142    #[error("Failed to set configuration file.")]
143    Set,
144}
145
146impl BabyriteConfig {
147    /// Initializes the global configuration.
148    ///
149    /// Loads configuration from a file if `CONFIG_FILE_PATH` is set,
150    /// otherwise uses default values.
151    pub fn init() -> Result<(), BabyriteConfigError> {
152        let config = match &EnvConfig::get().config_file_path {
153            Some(p) => {
154                let buffer = std::fs::read_to_string(p).map_err(|_| BabyriteConfigError::Read)?;
155                toml::from_str(&buffer).map_err(|_| BabyriteConfigError::Parse)?
156            }
157            None => BabyriteConfig::default(),
158        };
159        CONFIG.set(config).map_err(|_| BabyriteConfigError::Set)
160    }
161
162    /// Returns a reference to the global configuration.
163    ///
164    /// # Panics
165    ///
166    /// Panics if [`BabyriteConfig::init`] has not been called.
167    pub fn get() -> &'static BabyriteConfig {
168        CONFIG.get().expect("Failed to get configuration.")
169    }
170
171    /// Resolves the effective log output format.
172    ///
173    /// Uses `[log] format` when set; otherwise falls back to the deprecated
174    /// `json_logging` flag (`true` → JSON, `false` → compact) so existing
175    /// configuration files keep their previous behavior.
176    pub fn resolved_log_format(&self) -> LogFormat {
177        self.log.format.unwrap_or({
178            if self.json_logging {
179                LogFormat::Json
180            } else {
181                LogFormat::Compact
182            }
183        })
184    }
185}
186
187/// Deserialize a string as an `Option<String>`, treating empty strings as `None`.
188pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
189where
190    D: serde::Deserializer<'de>,
191{
192    let opt = Option::<String>::deserialize(deserializer)?;
193    Ok(opt.filter(|s| !s.is_empty()))
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn default_config() {
202        let config = BabyriteConfig::default();
203        assert!(!config.json_logging);
204        assert_eq!(config.log.level, "babyrite=info");
205        assert_eq!(config.log.format, None);
206        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
207        assert!(config.features.github_permalink);
208        assert_eq!(config.github.max_lines, 50);
209    }
210
211    #[test]
212    fn deserialize_empty_config() {
213        let config: BabyriteConfig = toml::from_str("").unwrap();
214        assert!(!config.json_logging);
215        assert_eq!(config.log.level, "babyrite=info");
216        assert_eq!(config.log.format, None);
217        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
218        assert!(config.features.github_permalink);
219        assert_eq!(config.github.max_lines, 50);
220    }
221
222    #[test]
223    fn deserialize_log_section() {
224        let toml_str = r#"
225            [log]
226            level = "babyrite=debug"
227            format = "json"
228        "#;
229        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
230        assert_eq!(config.log.level, "babyrite=debug");
231        assert_eq!(config.log.format, Some(LogFormat::Json));
232        assert_eq!(config.resolved_log_format(), LogFormat::Json);
233    }
234
235    #[test]
236    fn resolved_log_format_falls_back_to_json_logging() {
237        // `[log] format` unset but the deprecated `json_logging` is enabled:
238        // the format should resolve to JSON for backward compatibility.
239        let toml_str = r#"
240            json_logging = true
241        "#;
242        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
243        assert_eq!(config.log.format, None);
244        assert_eq!(config.resolved_log_format(), LogFormat::Json);
245    }
246
247    #[test]
248    fn log_format_overrides_json_logging() {
249        // When both are set, `[log] format` wins over the deprecated flag.
250        let toml_str = r#"
251            json_logging = true
252
253            [log]
254            format = "compact"
255        "#;
256        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
257        assert_eq!(config.resolved_log_format(), LogFormat::Compact);
258    }
259
260    #[test]
261    fn deserialize_full_config() {
262        let toml_str = r#"
263            json_logging = true
264
265            [features]
266            github_permalink = false
267
268            [github]
269            max_lines = 100
270        "#;
271        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
272        assert!(config.json_logging);
273        assert!(!config.features.github_permalink);
274        assert_eq!(config.github.max_lines, 100);
275    }
276
277    #[test]
278    fn deserialize_partial_config() {
279        let toml_str = r#"
280            json_logging = true
281        "#;
282        let config: BabyriteConfig = toml::from_str(toml_str).unwrap();
283        assert!(config.json_logging);
284        // defaults
285        assert!(config.features.github_permalink);
286        assert_eq!(config.github.max_lines, 50);
287    }
288
289    #[derive(Deserialize)]
290    struct EmptyStringAsNone {
291        #[serde(default, deserialize_with = "empty_string_as_none")]
292        value: Option<String>,
293    }
294
295    #[test]
296    fn empty_string_as_none_with_empty() {
297        let t: EmptyStringAsNone = toml::from_str(r#"value = """#).unwrap();
298        assert!(t.value.is_none());
299    }
300
301    #[test]
302    fn empty_string_as_none_with_value() {
303        let t: EmptyStringAsNone = toml::from_str(r#"value = "hello""#).unwrap();
304        assert_eq!(t.value.as_deref(), Some("hello"));
305    }
306
307    #[test]
308    fn empty_string_as_none_absent() {
309        let t: EmptyStringAsNone = toml::from_str("").unwrap();
310        assert!(t.value.is_none());
311    }
312}