Skip to main content

notify_core/
config.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{NotifyError, Result};
10
11#[derive(Debug, Clone)]
12pub struct ConfigLoad {
13    pub config: Config,
14    pub path: PathBuf,
15}
16
17impl ConfigLoad {
18    pub fn load(explicit_path: Option<&Path>) -> Result<Self> {
19        let path = discover_config_path(explicit_path)?;
20        let contents = fs::read_to_string(&path).map_err(|source| NotifyError::ConfigRead {
21            path: path.clone(),
22            source,
23        })?;
24        let config = toml::from_str(&contents).map_err(|source| NotifyError::ConfigParse {
25            path: path.clone(),
26            source,
27        })?;
28
29        Ok(Self { config, path })
30    }
31}
32
33pub fn discover_config_path(explicit_path: Option<&Path>) -> Result<PathBuf> {
34    if let Some(path) = explicit_path {
35        return Ok(path.to_path_buf());
36    }
37
38    let local = PathBuf::from("notify.toml");
39    if local.exists() {
40        return Ok(local);
41    }
42
43    if let Some(home) = dirs::home_dir() {
44        let path = home
45            .join(".config")
46            .join("agent-notify")
47            .join("config.toml");
48        if path.exists() {
49            return Ok(path);
50        }
51    }
52
53    Err(NotifyError::ConfigNotFound)
54}
55
56#[derive(Debug, Clone, Deserialize)]
57pub struct Config {
58    pub default_channel: Option<String>,
59    #[serde(default)]
60    pub channels: BTreeMap<String, ChannelConfig>,
61}
62
63impl Config {
64    pub fn resolve_channel_name<'a>(&'a self, requested: Option<&'a str>) -> Result<&'a str> {
65        let name = match requested {
66            Some(name) => name,
67            None => self
68                .default_channel
69                .as_deref()
70                .ok_or(NotifyError::DefaultChannelMissing)?,
71        };
72
73        if self.channels.contains_key(name) {
74            Ok(name)
75        } else {
76            Err(NotifyError::ChannelNotFound(name.to_string()))
77        }
78    }
79
80    pub fn channel(&self, name: &str) -> Result<&ChannelConfig> {
81        self.channels
82            .get(name)
83            .ok_or_else(|| NotifyError::ChannelNotFound(name.to_string()))
84    }
85
86    pub fn validation_issues(&self) -> Vec<CheckIssue> {
87        self.validation_issues_with(&ProcessEnv)
88    }
89
90    pub fn validation_issues_with<E: EnvSource>(&self, env: &E) -> Vec<CheckIssue> {
91        let mut issues = Vec::new();
92
93        match self.default_channel.as_deref() {
94            Some(name) if !self.channels.contains_key(name) => {
95                issues.push(CheckIssue::error(
96                    None,
97                    "DEFAULT_CHANNEL_NOT_FOUND",
98                    format!("default_channel \"{name}\" does not exist"),
99                ));
100            }
101            None => {
102                issues.push(CheckIssue::error(
103                    None,
104                    "DEFAULT_CHANNEL_MISSING",
105                    "default_channel is not configured",
106                ));
107            }
108            Some(_) => {}
109        }
110
111        for (name, channel) in &self.channels {
112            issues.extend(channel.validation_issues(name, env));
113        }
114
115        issues
116    }
117
118    pub fn channel_statuses(&self) -> Vec<ChannelStatus> {
119        self.channel_statuses_with(&ProcessEnv)
120    }
121
122    pub fn channel_statuses_with<E: EnvSource>(&self, env: &E) -> Vec<ChannelStatus> {
123        self.channels
124            .iter()
125            .map(|(name, channel)| {
126                let issues = channel.validation_issues(name, env);
127                let missing_env = issues
128                    .iter()
129                    .filter(|issue| issue.code == "MISSING_ENV")
130                    .map(|issue| issue.message.clone())
131                    .collect::<Vec<_>>();
132                let warnings = issues
133                    .iter()
134                    .filter(|issue| issue.level == IssueLevel::Warning)
135                    .map(|issue| issue.message.clone())
136                    .collect::<Vec<_>>();
137                let errors = issues
138                    .iter()
139                    .filter(|issue| issue.level == IssueLevel::Error)
140                    .map(|issue| issue.message.clone())
141                    .collect::<Vec<_>>();
142                let status = if errors.is_empty() {
143                    "ready"
144                } else if !missing_env.is_empty() {
145                    "missing"
146                } else {
147                    "error"
148                };
149
150                ChannelStatus {
151                    name: name.clone(),
152                    channel_type: channel.type_name().to_string(),
153                    status: status.to_string(),
154                    missing_env,
155                    warnings,
156                    errors,
157                }
158            })
159            .collect()
160    }
161}
162
163#[derive(Debug, Clone, Deserialize)]
164#[serde(tag = "type", rename_all = "kebab-case")]
165pub enum ChannelConfig {
166    Telegram(TelegramConfig),
167    DiscordWebhook(DiscordWebhookConfig),
168    DiscordBot(DiscordBotConfig),
169    Ntfy(NtfyConfig),
170    Webhook(WebhookConfig),
171    FileLog(FileLogConfig),
172}
173
174impl ChannelConfig {
175    pub fn type_name(&self) -> &'static str {
176        match self {
177            Self::Telegram(_) => "telegram",
178            Self::DiscordWebhook(_) => "discord-webhook",
179            Self::DiscordBot(_) => "discord-bot",
180            Self::Ntfy(_) => "ntfy",
181            Self::Webhook(_) => "webhook",
182            Self::FileLog(_) => "file-log",
183        }
184    }
185
186    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
187        match self {
188            Self::Telegram(config) => config.validation_issues(channel, env),
189            Self::DiscordWebhook(config) => config.validation_issues(channel, env),
190            Self::DiscordBot(config) => config.validation_issues(channel, env),
191            Self::Ntfy(config) => config.validation_issues(channel, env),
192            Self::Webhook(config) => config.validation_issues(channel, env),
193            Self::FileLog(config) => config.validation_issues(channel),
194        }
195    }
196}
197
198#[derive(Debug, Clone, Deserialize)]
199pub struct TelegramConfig {
200    pub bot_token: Option<String>,
201    pub bot_token_env: Option<String>,
202    pub chat_id: Option<String>,
203    pub chat_id_env: Option<String>,
204    pub parse_mode: Option<String>,
205}
206
207impl TelegramConfig {
208    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
209        let mut issues = Vec::new();
210        validate_secret_pair(
211            channel,
212            "bot_token",
213            self.bot_token.as_deref(),
214            self.bot_token_env.as_deref(),
215            true,
216            env,
217            &mut issues,
218        );
219        validate_secret_pair(
220            channel,
221            "chat_id",
222            self.chat_id.as_deref(),
223            self.chat_id_env.as_deref(),
224            true,
225            env,
226            &mut issues,
227        );
228
229        if let Some(parse_mode) = self.parse_mode.as_deref()
230            && !matches!(parse_mode, "plain" | "html" | "markdown-v2")
231        {
232            issues.push(CheckIssue::error(
233                Some(channel),
234                "INVALID_FIELD",
235                format!("channel \"{channel}\" has invalid parse_mode \"{parse_mode}\""),
236            ));
237        }
238
239        issues
240    }
241}
242
243#[derive(Debug, Clone, Deserialize)]
244pub struct DiscordWebhookConfig {
245    pub webhook_url: Option<String>,
246    pub webhook_url_env: Option<String>,
247    pub username: Option<String>,
248    pub avatar_url: Option<String>,
249    pub allow_mentions: Option<bool>,
250}
251
252impl DiscordWebhookConfig {
253    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
254        let mut issues = Vec::new();
255        validate_secret_pair(
256            channel,
257            "webhook_url",
258            self.webhook_url.as_deref(),
259            self.webhook_url_env.as_deref(),
260            true,
261            env,
262            &mut issues,
263        );
264        issues
265    }
266}
267
268#[derive(Debug, Clone, Deserialize)]
269pub struct DiscordBotConfig {
270    pub bot_token: Option<String>,
271    pub bot_token_env: Option<String>,
272    pub channel_id: Option<String>,
273    pub channel_id_env: Option<String>,
274    pub allow_mentions: Option<bool>,
275}
276
277impl DiscordBotConfig {
278    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
279        let mut issues = Vec::new();
280        validate_secret_pair(
281            channel,
282            "bot_token",
283            self.bot_token.as_deref(),
284            self.bot_token_env.as_deref(),
285            true,
286            env,
287            &mut issues,
288        );
289        validate_secret_pair(
290            channel,
291            "channel_id",
292            self.channel_id.as_deref(),
293            self.channel_id_env.as_deref(),
294            true,
295            env,
296            &mut issues,
297        );
298        issues
299    }
300}
301
302#[derive(Debug, Clone, Deserialize)]
303pub struct NtfyConfig {
304    pub server: Option<String>,
305    pub topic: Option<String>,
306    pub topic_env: Option<String>,
307    pub token: Option<String>,
308    pub token_env: Option<String>,
309}
310
311impl NtfyConfig {
312    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
313        let mut issues = Vec::new();
314        validate_secret_pair(
315            channel,
316            "topic",
317            self.topic.as_deref(),
318            self.topic_env.as_deref(),
319            true,
320            env,
321            &mut issues,
322        );
323        validate_secret_pair(
324            channel,
325            "token",
326            self.token.as_deref(),
327            self.token_env.as_deref(),
328            false,
329            env,
330            &mut issues,
331        );
332        issues
333    }
334}
335
336#[derive(Debug, Clone, Deserialize)]
337pub struct WebhookConfig {
338    pub url: Option<String>,
339    pub url_env: Option<String>,
340    pub auth_header: Option<String>,
341    pub auth_header_env: Option<String>,
342    pub timeout_seconds: Option<u64>,
343}
344
345impl WebhookConfig {
346    fn validation_issues<E: EnvSource>(&self, channel: &str, env: &E) -> Vec<CheckIssue> {
347        let mut issues = Vec::new();
348        validate_secret_pair(
349            channel,
350            "url",
351            self.url.as_deref(),
352            self.url_env.as_deref(),
353            true,
354            env,
355            &mut issues,
356        );
357        validate_secret_pair(
358            channel,
359            "auth_header",
360            self.auth_header.as_deref(),
361            self.auth_header_env.as_deref(),
362            false,
363            env,
364            &mut issues,
365        );
366        if matches!(self.timeout_seconds, Some(0)) {
367            issues.push(CheckIssue::error(
368                Some(channel),
369                "INVALID_FIELD",
370                format!("channel \"{channel}\" timeout_seconds must be greater than 0"),
371            ));
372        }
373        issues
374    }
375}
376
377#[derive(Debug, Clone, Deserialize)]
378pub struct FileLogConfig {
379    pub path: PathBuf,
380}
381
382impl FileLogConfig {
383    fn validation_issues(&self, channel: &str) -> Vec<CheckIssue> {
384        if self.path.as_os_str().is_empty() {
385            vec![CheckIssue::error(
386                Some(channel),
387                "MISSING_FIELD",
388                format!("channel \"{channel}\" is missing path"),
389            )]
390        } else {
391            Vec::new()
392        }
393    }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
397#[serde(rename_all = "lowercase")]
398pub enum IssueLevel {
399    Error,
400    Warning,
401}
402
403#[derive(Debug, Clone, Serialize)]
404pub struct CheckIssue {
405    pub level: IssueLevel,
406    pub channel: Option<String>,
407    pub code: String,
408    pub message: String,
409}
410
411impl CheckIssue {
412    pub fn error(channel: Option<&str>, code: &str, message: impl Into<String>) -> Self {
413        Self {
414            level: IssueLevel::Error,
415            channel: channel.map(ToOwned::to_owned),
416            code: code.to_string(),
417            message: message.into(),
418        }
419    }
420
421    pub fn warning(channel: Option<&str>, code: &str, message: impl Into<String>) -> Self {
422        Self {
423            level: IssueLevel::Warning,
424            channel: channel.map(ToOwned::to_owned),
425            code: code.to_string(),
426            message: message.into(),
427        }
428    }
429
430    pub fn is_error(&self) -> bool {
431        self.level == IssueLevel::Error
432    }
433}
434
435#[derive(Debug, Clone, Serialize)]
436pub struct ChannelStatus {
437    pub name: String,
438    #[serde(rename = "type")]
439    pub channel_type: String,
440    pub status: String,
441    pub missing_env: Vec<String>,
442    pub warnings: Vec<String>,
443    pub errors: Vec<String>,
444}
445
446pub trait EnvSource {
447    fn exists(&self, name: &str) -> bool;
448}
449
450#[derive(Debug, Clone, Copy)]
451pub struct ProcessEnv;
452
453impl EnvSource for ProcessEnv {
454    fn exists(&self, name: &str) -> bool {
455        std::env::var_os(name).is_some()
456    }
457}
458
459fn validate_secret_pair<E: EnvSource>(
460    channel: &str,
461    field: &str,
462    inline: Option<&str>,
463    env_name: Option<&str>,
464    required: bool,
465    env: &E,
466    issues: &mut Vec<CheckIssue>,
467) {
468    match (inline, env_name) {
469        (Some(_), Some(_)) => issues.push(CheckIssue::error(
470            Some(channel),
471            "SECRET_CONFLICT",
472            format!("channel \"{channel}\" {field} and {field}_env cannot be set at the same time"),
473        )),
474        (Some(_), None) => issues.push(CheckIssue::warning(
475            Some(channel),
476            "INLINE_SECRET",
477            format!("channel \"{channel}\" uses inline {field}"),
478        )),
479        (None, Some(env_name)) if !env.exists(env_name) => issues.push(CheckIssue::error(
480            Some(channel),
481            "MISSING_ENV",
482            env_name.to_string(),
483        )),
484        (None, None) if required => issues.push(CheckIssue::error(
485            Some(channel),
486            "MISSING_FIELD",
487            format!("channel \"{channel}\" is missing {field} or {field}_env"),
488        )),
489        _ => {}
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use std::{collections::BTreeSet, fs};
496
497    use tempfile::tempdir;
498
499    use super::*;
500
501    struct MapEnv(BTreeSet<String>);
502
503    impl EnvSource for MapEnv {
504        fn exists(&self, name: &str) -> bool {
505            self.0.contains(name)
506        }
507    }
508
509    #[test]
510    fn loads_config_from_explicit_path() {
511        let dir = tempdir().unwrap();
512        let path = dir.path().join("notify.toml");
513        fs::write(
514            &path,
515            r#"
516default_channel = "local"
517
518[channels.local]
519type = "file-log"
520path = "./notify-log"
521"#,
522        )
523        .unwrap();
524
525        let loaded = ConfigLoad::load(Some(&path)).unwrap();
526
527        assert_eq!(loaded.path, path);
528        assert_eq!(loaded.config.default_channel.as_deref(), Some("local"));
529        assert!(matches!(
530            loaded.config.channels.get("local"),
531            Some(ChannelConfig::FileLog(_))
532        ));
533    }
534
535    #[test]
536    fn detects_default_channel_missing() {
537        let config: Config = toml::from_str(
538            r#"
539[channels.local]
540type = "file-log"
541path = "./notify-log"
542"#,
543        )
544        .unwrap();
545
546        let issues = config.validation_issues_with(&MapEnv(BTreeSet::new()));
547
548        assert!(
549            issues
550                .iter()
551                .any(|issue| issue.code == "DEFAULT_CHANNEL_MISSING")
552        );
553    }
554
555    #[test]
556    fn detects_default_channel_not_found() {
557        let config: Config = toml::from_str(
558            r#"
559default_channel = "missing"
560
561[channels.local]
562type = "file-log"
563path = "./notify-log"
564"#,
565        )
566        .unwrap();
567
568        let issues = config.validation_issues_with(&MapEnv(BTreeSet::new()));
569
570        assert!(issues.iter().any(|issue| {
571            issue.code == "DEFAULT_CHANNEL_NOT_FOUND" && issue.level == IssueLevel::Error
572        }));
573    }
574
575    #[test]
576    fn detects_secret_conflict_and_missing_env() {
577        let config: Config = toml::from_str(
578            r#"
579default_channel = "team"
580
581[channels.team]
582type = "discord-webhook"
583webhook_url = "https://example.com"
584webhook_url_env = "NOTIFY_DISCORD_WEBHOOK_URL"
585
586[channels.phone]
587type = "ntfy"
588topic_env = "NOTIFY_NTFY_TOPIC"
589"#,
590        )
591        .unwrap();
592
593        let issues = config.validation_issues_with(&MapEnv(BTreeSet::new()));
594
595        assert!(issues.iter().any(|issue| issue.code == "SECRET_CONFLICT"));
596        assert!(issues.iter().any(|issue| issue.code == "MISSING_ENV"));
597    }
598
599    #[test]
600    fn detects_invalid_telegram_parse_mode() {
601        let config: Config = toml::from_str(
602            r#"
603default_channel = "personal"
604
605[channels.personal]
606type = "telegram"
607bot_token = "token"
608chat_id = "chat"
609parse_mode = "markdown"
610"#,
611        )
612        .unwrap();
613
614        let issues = config.validation_issues_with(&MapEnv(BTreeSet::new()));
615
616        assert!(issues.iter().any(|issue| {
617            issue.code == "INVALID_FIELD"
618                && issue.level == IssueLevel::Error
619                && issue.channel.as_deref() == Some("personal")
620        }));
621    }
622
623    #[test]
624    fn rejects_unsupported_type_during_deserialize() {
625        let result = toml::from_str::<Config>(
626            r#"
627default_channel = "mail"
628
629[channels.mail]
630type = "email"
631"#,
632        );
633
634        assert!(result.is_err());
635    }
636}