1use serde::Deserialize;
6use std::sync::OnceLock;
7
8pub static CONFIG: OnceLock<BabyriteConfig> = OnceLock::new();
10
11#[derive(Deserialize, Debug)]
13pub struct EnvConfig {
14 pub discord_api_token: String,
16 #[serde(default)]
18 #[serde(deserialize_with = "crate::config::empty_string_as_none")]
19 pub config_file_path: Option<String>,
20}
21
22impl EnvConfig {
23 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#[derive(Deserialize, Debug, Default)]
38pub struct BabyriteConfig {
39 #[serde(default)]
45 pub json_logging: bool,
46 #[serde(default)]
48 pub log: LogConfig,
49 #[serde(default)]
51 pub features: FeatureConfig,
52 #[serde(default)]
54 pub github: GitHubConfig,
55}
56
57#[derive(Deserialize, Debug)]
62#[serde(default)]
63pub struct LogConfig {
64 pub level: String,
69 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#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
85#[serde(rename_all = "lowercase")]
86pub enum LogFormat {
87 Compact,
89 Json,
91}
92
93#[derive(Deserialize, Debug)]
98#[serde(default)]
99pub struct FeatureConfig {
100 pub github_permalink: bool,
104 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#[derive(Deserialize, Debug)]
123#[serde(default)]
124pub struct GitHubConfig {
125 pub max_lines: usize,
129}
130
131impl Default for GitHubConfig {
132 fn default() -> Self {
133 Self { max_lines: 50 }
134 }
135}
136
137#[derive(thiserror::Error, Debug)]
139pub enum BabyriteConfigError {
140 #[error("Failed to read configuration file.")]
142 Read,
143 #[error("Failed to parse configuration file.")]
145 Parse,
146 #[error("Failed to set configuration file.")]
148 Set,
149}
150
151impl BabyriteConfig {
152 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 pub fn get() -> &'static BabyriteConfig {
173 CONFIG.get().expect("Failed to get configuration.")
174 }
175
176 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
192pub 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 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 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 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}