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}