1use directories::BaseDirs;
2use serde::Deserialize;
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8#[derive(Debug, Clone, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct Config {
12 pub version: u32,
14
15 #[serde(default)]
17 pub default_channels: Vec<String>,
18
19 pub channels: BTreeMap<String, ChannelConfig>,
21}
22
23#[derive(Debug, Clone, Deserialize)]
25#[serde(tag = "type", rename_all = "lowercase")]
26pub enum ChannelConfig {
27 Desktop(DesktopChannel),
29
30 Webhook(WebhookChannel),
32
33 Custom(CustomChannel),
35}
36
37#[derive(Debug, Clone, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct DesktopChannel {}
41
42#[derive(Debug, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct WebhookChannel {
46 pub url: String,
48
49 #[serde(default = "default_http_method")]
51 pub method: String,
52
53 #[serde(default)]
55 pub headers: BTreeMap<String, String>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct CustomChannel {
62 pub exec: String,
64
65 #[serde(default)]
67 pub args: Vec<String>,
68
69 #[serde(default)]
71 pub env: BTreeMap<String, String>,
72}
73
74#[derive(Debug, Clone)]
76pub struct LoadedConfig {
77 pub path: PathBuf,
79
80 pub config: Config,
82}
83
84#[derive(Debug, Clone)]
86pub enum InitStatus {
87 Created(PathBuf),
89
90 AlreadyExists(PathBuf),
92}
93
94#[derive(Debug, Error)]
96pub enum ConfigError {
97 #[error("unable to determine user config directory")]
98 NoConfigDirectory,
99 #[error("config file not found: {0}")]
100 NotFound(String),
101 #[error("failed to read config file: {0}")]
102 ReadFailed(#[from] std::io::Error),
103 #[error("invalid YAML config: {0}")]
104 ParseFailed(#[from] serde_yaml::Error),
105 #[error("missing environment variable for interpolation: {0}")]
106 MissingEnvironmentVariable(String),
107 #[error("invalid environment interpolation expression in config value: {0}")]
108 InvalidInterpolation(String),
109 #[error("invalid config: {0}")]
110 InvalidConfig(String),
111}
112
113impl ChannelConfig {
114 pub fn type_name(&self) -> &'static str {
116 match self {
117 Self::Desktop(_) => "desktop",
118 Self::Webhook(_) => "webhook",
119 Self::Custom(_) => "custom",
120 }
121 }
122}
123
124pub fn load_config() -> Result<LoadedConfig, ConfigError> {
126 let path = config_file_path()?;
127 if !path.exists() {
128 return Err(ConfigError::NotFound(path.display().to_string()));
129 }
130
131 let raw = fs::read_to_string(&path)?;
132 let mut config: Config = serde_yaml::from_str(&raw)?;
133 interpolate_env_values(&mut config)?;
134 validate_config(&config)?;
135
136 Ok(LoadedConfig { path, config })
137}
138
139pub fn init_config() -> Result<InitStatus, ConfigError> {
141 let path = config_file_path()?;
142 if path.exists() {
143 return Ok(InitStatus::AlreadyExists(path));
144 }
145
146 let parent = path.parent().ok_or_else(|| {
147 ConfigError::InvalidConfig("calculated config path has no parent directory".to_string())
148 })?;
149
150 fs::create_dir_all(parent)?;
151 fs::write(&path, default_config_yaml())?;
152
153 Ok(InitStatus::Created(path))
154}
155
156pub fn config_file_path() -> Result<PathBuf, ConfigError> {
158 let base_dirs = BaseDirs::new().ok_or(ConfigError::NoConfigDirectory)?;
159 Ok(base_dirs.config_dir().join("brb").join("config.yml"))
160}
161
162pub fn validate_config(config: &Config) -> Result<(), ConfigError> {
164 if config.version != 1 {
165 return Err(ConfigError::InvalidConfig(format!(
166 "unsupported version {}; expected 1",
167 config.version
168 )));
169 }
170
171 if config.channels.is_empty() {
172 return Err(ConfigError::InvalidConfig(
173 "at least one channel must be configured".to_string(),
174 ));
175 }
176
177 if config.default_channels.is_empty() {
178 return Err(ConfigError::InvalidConfig(
179 "default_channels must include at least one channel id".to_string(),
180 ));
181 }
182
183 for channel_id in &config.default_channels {
184 if !config.channels.contains_key(channel_id) {
185 return Err(ConfigError::InvalidConfig(format!(
186 "default channel `{channel_id}` is not defined in channels"
187 )));
188 }
189 }
190
191 Ok(())
192}
193
194fn default_http_method() -> String {
195 "POST".to_string()
196}
197
198fn interpolate_env_values(config: &mut Config) -> Result<(), ConfigError> {
199 for channel in config.channels.values_mut() {
200 match channel {
201 ChannelConfig::Desktop(_) => {}
202 ChannelConfig::Webhook(webhook) => {
203 webhook.url = interpolate_env(&webhook.url)?;
204 webhook.method = interpolate_env(&webhook.method)?;
205 for value in webhook.headers.values_mut() {
206 *value = interpolate_env(value)?;
207 }
208 }
209 ChannelConfig::Custom(custom) => {
210 custom.exec = interpolate_env(&custom.exec)?;
211 for arg in &mut custom.args {
212 *arg = interpolate_env(arg)?;
213 }
214 for value in custom.env.values_mut() {
215 *value = interpolate_env(value)?;
216 }
217 }
218 }
219 }
220
221 Ok(())
222}
223
224fn interpolate_env(value: &str) -> Result<String, ConfigError> {
225 let mut output = String::new();
226 let mut rest = value;
227
228 loop {
229 let Some(start) = rest.find("${env:") else {
230 output.push_str(rest);
231 break;
232 };
233
234 output.push_str(&rest[..start]);
235 let placeholder = &rest[start + 6..];
236 let Some(end) = placeholder.find('}') else {
237 return Err(ConfigError::InvalidInterpolation(value.to_string()));
238 };
239
240 let env_name = &placeholder[..end];
241 if env_name.is_empty() {
242 return Err(ConfigError::InvalidInterpolation(value.to_string()));
243 }
244
245 let env_value = std::env::var(env_name)
246 .map_err(|_| ConfigError::MissingEnvironmentVariable(env_name.to_string()))?;
247 output.push_str(&env_value);
248 rest = &placeholder[end + 1..];
249 }
250
251 Ok(output)
252}
253
254fn default_config_yaml() -> &'static str {
255 include_str!("../assets/default-config.yml")
256}
257
258pub fn load_config_from_path(path: &Path) -> Result<Config, ConfigError> {
262 let raw = fs::read_to_string(path)?;
263 let mut config: Config = serde_yaml::from_str(&raw)?;
264 interpolate_env_values(&mut config)?;
265 validate_config(&config)?;
266 Ok(config)
267}