Skip to main content

brb_cli/
config.rs

1use directories::BaseDirs;
2use serde::Deserialize;
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8/// The top-level YAML configuration structure.
9#[derive(Debug, Clone, Deserialize)]
10#[serde(deny_unknown_fields)]
11pub struct Config {
12    /// Config schema version.
13    pub version: u32,
14
15    /// Channel IDs used when `--channel` is omitted.
16    #[serde(default)]
17    pub default_channels: Vec<String>,
18
19    /// Channel definitions keyed by channel ID.
20    pub channels: BTreeMap<String, ChannelConfig>,
21}
22
23/// A single channel definition.
24#[derive(Debug, Clone, Deserialize)]
25#[serde(tag = "type", rename_all = "lowercase")]
26pub enum ChannelConfig {
27    /// Local desktop notification channel.
28    Desktop(DesktopChannel),
29
30    /// Generic webhook channel.
31    Webhook(WebhookChannel),
32
33    /// External command-based custom channel.
34    Custom(CustomChannel),
35}
36
37/// Configuration for `type: desktop`.
38#[derive(Debug, Clone, Deserialize, Default)]
39#[serde(deny_unknown_fields)]
40pub struct DesktopChannel {}
41
42/// Configuration for `type: webhook`.
43#[derive(Debug, Clone, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct WebhookChannel {
46    /// Target URL for webhook delivery.
47    pub url: String,
48
49    /// HTTP method (defaults to POST).
50    #[serde(default = "default_http_method")]
51    pub method: String,
52
53    /// Optional HTTP headers.
54    #[serde(default)]
55    pub headers: BTreeMap<String, String>,
56}
57
58/// Configuration for `type: custom`.
59#[derive(Debug, Clone, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct CustomChannel {
62    /// Executable name or path.
63    pub exec: String,
64
65    /// Optional command-line arguments.
66    #[serde(default)]
67    pub args: Vec<String>,
68
69    /// Optional environment variable overrides.
70    #[serde(default)]
71    pub env: BTreeMap<String, String>,
72}
73
74/// Fully-loaded config plus where it came from.
75#[derive(Debug, Clone)]
76pub struct LoadedConfig {
77    /// Absolute file path used for loading.
78    pub path: PathBuf,
79
80    /// Parsed and validated config.
81    pub config: Config,
82}
83
84/// Result of running `brb init`.
85#[derive(Debug, Clone)]
86pub enum InitStatus {
87    /// Config file was created.
88    Created(PathBuf),
89
90    /// Config file already existed.
91    AlreadyExists(PathBuf),
92}
93
94/// Config loading/validation failures.
95#[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    /// Returns stable type label for display output.
115    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
124/// Loads config from the global `config.yml` and validates it.
125pub 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
139/// Creates a default global config file if it does not already exist.
140pub 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
156/// Returns the absolute path to the global config file.
157pub 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
162/// Validates static schema and cross-field constraints.
163pub 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
258/// Loads and validates config from an explicit file path.
259///
260/// This helper is used by integration tests.
261pub 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}