1use chrono::{Local, NaiveTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::fmt;
13use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::PathBuf;
16
17use crate::config::NotifierSection;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25#[serde(tag = "event")]
26pub enum AgentEvent {
27 OrderPlaced {
28 market: String,
29 side: String,
30 size: f64,
31 price: f64,
32 },
33 PositionClosed {
34 market: String,
35 pnl: f64,
36 reason: String,
37 },
38 CycleComplete {
39 markets_scanned: u32,
40 orders_placed: u32,
41 },
42 DailyPnl {
43 total_pnl: f64,
44 win_rate: f64,
45 trades: u32,
46 },
47 Error {
48 message: String,
49 },
50 AgentStarted {
51 name: String,
52 mode: String,
53 },
54 AgentStopped {
55 name: String,
56 },
57 OrderExecuted {
58 market: String,
59 side: String,
60 size: f64,
61 price: f64,
62 order_id: String,
63 },
64 OrderBlocked {
65 market: String,
66 reason: String,
67 },
68}
69
70impl AgentEvent {
71 pub fn title(&self) -> &str {
73 match self {
74 AgentEvent::OrderPlaced { .. } => "Order Placed",
75 AgentEvent::PositionClosed { .. } => "Position Closed",
76 AgentEvent::CycleComplete { .. } => "Cycle Complete",
77 AgentEvent::DailyPnl { .. } => "Daily PnL",
78 AgentEvent::Error { .. } => "Error",
79 AgentEvent::AgentStarted { .. } => "Agent Started",
80 AgentEvent::AgentStopped { .. } => "Agent Stopped",
81 AgentEvent::OrderExecuted { .. } => "Order Executed",
82 AgentEvent::OrderBlocked { .. } => "Order Blocked",
83 }
84 }
85
86 pub fn color(&self) -> u32 {
88 match self {
89 AgentEvent::OrderPlaced { .. } => 0x3498db, AgentEvent::PositionClosed { pnl, .. } => {
91 if *pnl >= 0.0 {
92 0x2ecc71
93 } else {
94 0xe74c3c
95 } }
97 AgentEvent::CycleComplete { .. } => 0x3498db, AgentEvent::DailyPnl { total_pnl, .. } => {
99 if *total_pnl >= 0.0 {
100 0x2ecc71
101 } else {
102 0xe74c3c
103 }
104 }
105 AgentEvent::Error { .. } => 0xe74c3c, AgentEvent::AgentStarted { .. } => 0x2ecc71, AgentEvent::AgentStopped { .. } => 0x95a5a6, AgentEvent::OrderExecuted { side, .. } => {
109 if side == "buy" {
110 0x2ecc71
111 } else {
112 0xe74c3c
113 } }
115 AgentEvent::OrderBlocked { .. } => 0xe74c3c, }
117 }
118
119 pub fn embed_fields(&self) -> Vec<(&str, String, bool)> {
121 match self {
122 AgentEvent::OrderPlaced {
123 market,
124 side,
125 size,
126 price,
127 } => vec![
128 ("Market", market.clone(), true),
129 ("Side", side.clone(), true),
130 ("Size", format!("{size}"), true),
131 ("Price", format!("{price}"), true),
132 ],
133 AgentEvent::PositionClosed {
134 market,
135 pnl,
136 reason,
137 } => vec![
138 ("Market", market.clone(), true),
139 ("PnL", format!("{pnl:+.2}"), true),
140 ("Reason", reason.clone(), true),
141 ],
142 AgentEvent::CycleComplete {
143 markets_scanned,
144 orders_placed,
145 } => vec![
146 ("Markets Scanned", format!("{markets_scanned}"), true),
147 ("Orders Placed", format!("{orders_placed}"), true),
148 ],
149 AgentEvent::DailyPnl {
150 total_pnl,
151 win_rate,
152 trades,
153 } => vec![
154 ("Total PnL", format!("{total_pnl:+.2}"), true),
155 ("Win Rate", format!("{:.1}%", win_rate * 100.0), true),
156 ("Trades", format!("{trades}"), true),
157 ],
158 AgentEvent::Error { message } => vec![("Message", message.clone(), false)],
159 AgentEvent::AgentStarted { name, mode } => {
160 vec![("Agent", name.clone(), true), ("Mode", mode.clone(), true)]
161 }
162 AgentEvent::AgentStopped { name } => vec![("Agent", name.clone(), true)],
163 AgentEvent::OrderExecuted {
164 market,
165 side,
166 size,
167 price,
168 order_id,
169 } => vec![
170 ("Market", market.clone(), true),
171 ("Side", side.clone(), true),
172 ("Size", format!("{size}"), true),
173 ("Price", format!("{price:.2}"), true),
174 ("Order ID", order_id.clone(), false),
175 ],
176 AgentEvent::OrderBlocked { market, reason } => vec![
177 ("Market", market.clone(), true),
178 ("Reason", reason.clone(), false),
179 ],
180 }
181 }
182}
183
184#[derive(Debug)]
190pub enum NotifierError {
191 IoError(std::io::Error),
193 SerializeError(serde_json::Error),
195 DiscordError(String),
197}
198
199impl fmt::Display for NotifierError {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 match self {
202 NotifierError::IoError(e) => write!(f, "notifier I/O error: {e}"),
203 NotifierError::SerializeError(e) => write!(f, "notifier serialize error: {e}"),
204 NotifierError::DiscordError(e) => write!(f, "discord webhook error: {e}"),
205 }
206 }
207}
208
209impl std::error::Error for NotifierError {}
210
211impl From<std::io::Error> for NotifierError {
212 fn from(e: std::io::Error) -> Self {
213 NotifierError::IoError(e)
214 }
215}
216
217impl From<serde_json::Error> for NotifierError {
218 fn from(e: serde_json::Error) -> Self {
219 NotifierError::SerializeError(e)
220 }
221}
222
223pub struct Notifier {
229 discord_webhook: Option<String>,
230 log_dir: Option<PathBuf>,
231 quiet_start: NaiveTime,
232 quiet_end: NaiveTime,
233 http: reqwest::Client,
234}
235
236impl Notifier {
237 pub fn from_config(config: &NotifierSection) -> Self {
239 let quiet_start = NaiveTime::parse_from_str(&config.quiet_start, "%H:%M")
240 .unwrap_or_else(|_| NaiveTime::from_hms_opt(23, 0, 0).unwrap());
241 let quiet_end = NaiveTime::parse_from_str(&config.quiet_end, "%H:%M")
242 .unwrap_or_else(|_| NaiveTime::from_hms_opt(8, 0, 0).unwrap());
243
244 let log_dir = if config.log_dir.is_empty() {
245 None
246 } else {
247 Some(PathBuf::from(&config.log_dir))
248 };
249
250 Self {
251 discord_webhook: config.discord_webhook.clone(),
252 log_dir,
253 quiet_start,
254 quiet_end,
255 http: reqwest::Client::new(),
256 }
257 }
258
259 pub async fn notify(&self, event: &AgentEvent) -> Result<(), NotifierError> {
264 self.write_jsonl(event)?;
265
266 if self.discord_webhook.is_some() && !self.is_quiet_hours() {
267 self.send_discord(event).await?;
268 }
269
270 Ok(())
271 }
272
273 pub fn is_quiet_hours(&self) -> bool {
278 self.is_quiet_hours_at(Local::now().time())
279 }
280
281 pub fn is_quiet_hours_at(&self, now: NaiveTime) -> bool {
283 if self.quiet_start <= self.quiet_end {
284 now >= self.quiet_start && now < self.quiet_end
286 } else {
287 now >= self.quiet_start || now < self.quiet_end
289 }
290 }
291
292 async fn send_discord(&self, event: &AgentEvent) -> Result<(), NotifierError> {
294 let webhook_url = match &self.discord_webhook {
295 Some(url) => url,
296 None => return Ok(()),
297 };
298
299 let payload = self.build_discord_payload(event);
300
301 let resp = self
302 .http
303 .post(webhook_url)
304 .json(&payload)
305 .send()
306 .await
307 .map_err(|e| NotifierError::DiscordError(e.to_string()))?;
308
309 if !resp.status().is_success() {
310 let status = resp.status();
311 let body = resp
312 .text()
313 .await
314 .unwrap_or_else(|_| "<unreadable>".to_string());
315 return Err(NotifierError::DiscordError(format!(
316 "Discord returned {status}: {body}"
317 )));
318 }
319
320 Ok(())
321 }
322
323 pub fn build_discord_payload(&self, event: &AgentEvent) -> serde_json::Value {
325 let fields: Vec<serde_json::Value> = event
326 .embed_fields()
327 .into_iter()
328 .map(|(name, value, inline)| {
329 serde_json::json!({
330 "name": name,
331 "value": value,
332 "inline": inline,
333 })
334 })
335 .collect();
336
337 serde_json::json!({
338 "embeds": [{
339 "title": event.title(),
340 "color": event.color(),
341 "fields": fields,
342 "timestamp": Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
343 }]
344 })
345 }
346
347 fn write_jsonl(&self, event: &AgentEvent) -> Result<(), NotifierError> {
349 let log_dir = match &self.log_dir {
350 Some(d) => d,
351 None => return Ok(()),
352 };
353
354 fs::create_dir_all(log_dir)?;
355
356 let today = Utc::now().format("%Y-%m-%d").to_string();
357 let path = log_dir.join(format!("{today}.jsonl"));
358
359 let mut value = serde_json::to_value(event)?;
361 if let Some(obj) = value.as_object_mut() {
362 let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
364 let mut ordered = serde_json::Map::new();
365 ordered.insert("ts".to_string(), serde_json::Value::String(ts));
366 for (k, v) in obj.iter() {
367 ordered.insert(k.clone(), v.clone());
368 }
369 value = serde_json::Value::Object(ordered);
370 }
371
372 let line = serde_json::to_string(&value)?;
373
374 let mut file = OpenOptions::new().create(true).append(true).open(&path)?;
375 writeln!(file, "{line}")?;
376
377 Ok(())
378 }
379}
380
381#[cfg(test)]
386mod tests {
387 use super::*;
388 use std::io::BufRead;
389
390 fn test_config(dir: &str) -> NotifierSection {
391 NotifierSection {
392 enabled: true,
393 discord_webhook: Some("https://discord.com/api/webhooks/test/fake".to_string()),
394 log_dir: dir.to_string(),
395 quiet_start: "23:00".to_string(),
396 quiet_end: "08:00".to_string(),
397 }
398 }
399
400 #[test]
403 fn test_event_serialization_order_placed() {
404 let event = AgentEvent::OrderPlaced {
405 market: "BTC-PERP".to_string(),
406 side: "buy".to_string(),
407 size: 100.0,
408 price: 65000.0,
409 };
410 let json = serde_json::to_string(&event).unwrap();
411 let deser: AgentEvent = serde_json::from_str(&json).unwrap();
412 assert_eq!(event, deser);
413 assert!(json.contains("\"event\":\"OrderPlaced\""));
414 }
415
416 #[test]
417 fn test_event_serialization_position_closed() {
418 let event = AgentEvent::PositionClosed {
419 market: "ETH-PERP".to_string(),
420 pnl: -42.5,
421 reason: "stop_loss".to_string(),
422 };
423 let json = serde_json::to_string(&event).unwrap();
424 let deser: AgentEvent = serde_json::from_str(&json).unwrap();
425 assert_eq!(event, deser);
426 }
427
428 #[test]
429 fn test_event_serialization_all_variants() {
430 let events: Vec<AgentEvent> = vec![
431 AgentEvent::OrderPlaced {
432 market: "BTC".into(),
433 side: "buy".into(),
434 size: 1.0,
435 price: 100.0,
436 },
437 AgentEvent::PositionClosed {
438 market: "ETH".into(),
439 pnl: 50.0,
440 reason: "tp".into(),
441 },
442 AgentEvent::CycleComplete {
443 markets_scanned: 10,
444 orders_placed: 2,
445 },
446 AgentEvent::DailyPnl {
447 total_pnl: 123.45,
448 win_rate: 0.65,
449 trades: 20,
450 },
451 AgentEvent::Error {
452 message: "connection lost".into(),
453 },
454 AgentEvent::AgentStarted {
455 name: "alpha".into(),
456 mode: "paper".into(),
457 },
458 AgentEvent::AgentStopped {
459 name: "alpha".into(),
460 },
461 AgentEvent::OrderExecuted {
462 market: "BTC-PERP".into(),
463 side: "buy".into(),
464 size: 0.01,
465 price: 65000.0,
466 order_id: "ord-123".into(),
467 },
468 AgentEvent::OrderBlocked {
469 market: "ETH-PERP".into(),
470 reason: "max_position_exceeded".into(),
471 },
472 ];
473
474 for event in &events {
475 let json = serde_json::to_string(event).unwrap();
476 let deser: AgentEvent = serde_json::from_str(&json).unwrap();
477 assert_eq!(event, &deser);
478 }
479 }
480
481 #[test]
484 fn test_quiet_hours_wrapping_midnight() {
485 let cfg = test_config("/tmp/notifier-test-qh");
487 let notifier = Notifier::from_config(&cfg);
488
489 let t_2330 = NaiveTime::from_hms_opt(23, 30, 0).unwrap();
491 let t_0200 = NaiveTime::from_hms_opt(2, 0, 0).unwrap();
492 let t_0000 = NaiveTime::from_hms_opt(0, 0, 0).unwrap();
493 assert!(notifier.is_quiet_hours_at(t_2330));
494 assert!(notifier.is_quiet_hours_at(t_0200));
495 assert!(notifier.is_quiet_hours_at(t_0000));
496
497 let t_0800 = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
499 let t_1200 = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
500 let t_2259 = NaiveTime::from_hms_opt(22, 59, 0).unwrap();
501 assert!(!notifier.is_quiet_hours_at(t_0800));
502 assert!(!notifier.is_quiet_hours_at(t_1200));
503 assert!(!notifier.is_quiet_hours_at(t_2259));
504 }
505
506 #[test]
507 fn test_quiet_hours_same_day_range() {
508 let cfg = NotifierSection {
510 enabled: true,
511 discord_webhook: None,
512 log_dir: "logs".to_string(),
513 quiet_start: "01:00".to_string(),
514 quiet_end: "06:00".to_string(),
515 };
516 let notifier = Notifier::from_config(&cfg);
517
518 let t_0300 = NaiveTime::from_hms_opt(3, 0, 0).unwrap();
519 let t_1200 = NaiveTime::from_hms_opt(12, 0, 0).unwrap();
520 let t_0059 = NaiveTime::from_hms_opt(0, 59, 0).unwrap();
521 assert!(notifier.is_quiet_hours_at(t_0300));
522 assert!(!notifier.is_quiet_hours_at(t_1200));
523 assert!(!notifier.is_quiet_hours_at(t_0059));
524 }
525
526 #[test]
527 fn test_quiet_hours_boundary_start_inclusive_end_exclusive() {
528 let cfg = test_config("/tmp/notifier-test-boundary");
529 let notifier = Notifier::from_config(&cfg);
530
531 let t_2300 = NaiveTime::from_hms_opt(23, 0, 0).unwrap();
532 assert!(notifier.is_quiet_hours_at(t_2300)); let t_0800 = NaiveTime::from_hms_opt(8, 0, 0).unwrap();
535 assert!(!notifier.is_quiet_hours_at(t_0800)); }
537
538 #[test]
541 fn test_jsonl_write_and_read_back() {
542 let dir = tempfile::tempdir().unwrap();
543 let cfg = NotifierSection {
544 enabled: true,
545 discord_webhook: None,
546 log_dir: dir.path().to_str().unwrap().to_string(),
547 quiet_start: "23:00".to_string(),
548 quiet_end: "08:00".to_string(),
549 };
550 let notifier = Notifier::from_config(&cfg);
551
552 let events = vec![
553 AgentEvent::OrderPlaced {
554 market: "BTC-PERP".into(),
555 side: "buy".into(),
556 size: 100.0,
557 price: 65000.0,
558 },
559 AgentEvent::PositionClosed {
560 market: "ETH-PERP".into(),
561 pnl: 42.5,
562 reason: "take_profit".into(),
563 },
564 AgentEvent::Error {
565 message: "timeout".into(),
566 },
567 ];
568
569 for event in &events {
571 notifier.write_jsonl(event).unwrap();
572 }
573
574 let today = Utc::now().format("%Y-%m-%d").to_string();
576 let log_path = dir.path().join(format!("{today}.jsonl"));
577 assert!(log_path.exists());
578
579 let file = std::fs::File::open(&log_path).unwrap();
581 let reader = std::io::BufReader::new(file);
582 let lines: Vec<String> = reader.lines().map(|l| l.unwrap()).collect();
583 assert_eq!(lines.len(), 3);
584
585 for (i, line) in lines.iter().enumerate() {
587 let v: serde_json::Value = serde_json::from_str(line).unwrap();
588 assert!(v.get("ts").is_some(), "line {i} missing ts field");
589 assert!(v.get("event").is_some(), "line {i} missing event field");
590 }
591
592 let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
594 assert_eq!(first["event"], "OrderPlaced");
595 assert_eq!(first["market"], "BTC-PERP");
596 assert_eq!(first["side"], "buy");
597 assert_eq!(first["size"], 100.0);
598 assert_eq!(first["price"], 65000.0);
599 }
600
601 #[test]
602 fn test_jsonl_creates_directory_if_missing() {
603 let dir = tempfile::tempdir().unwrap();
604 let nested = dir.path().join("deep").join("nested").join("logs");
605 let cfg = NotifierSection {
606 enabled: true,
607 discord_webhook: None,
608 log_dir: nested.to_str().unwrap().to_string(),
609 quiet_start: "23:00".to_string(),
610 quiet_end: "08:00".to_string(),
611 };
612 let notifier = Notifier::from_config(&cfg);
613
614 let event = AgentEvent::AgentStarted {
615 name: "test".into(),
616 mode: "dry-run".into(),
617 };
618 notifier.write_jsonl(&event).unwrap();
619 assert!(nested.exists());
620 }
621
622 #[test]
623 fn test_jsonl_no_log_dir_is_noop() {
624 let cfg = NotifierSection {
625 enabled: true,
626 discord_webhook: None,
627 log_dir: String::new(),
628 quiet_start: "23:00".to_string(),
629 quiet_end: "08:00".to_string(),
630 };
631 let notifier = Notifier::from_config(&cfg);
632
633 let event = AgentEvent::Error {
634 message: "test".into(),
635 };
636 notifier.write_jsonl(&event).unwrap();
638 }
639
640 #[test]
643 fn test_discord_payload_order_placed() {
644 let cfg = test_config("/tmp/notifier-discord-test");
645 let notifier = Notifier::from_config(&cfg);
646
647 let event = AgentEvent::OrderPlaced {
648 market: "BTC-PERP".into(),
649 side: "buy".into(),
650 size: 100.0,
651 price: 65000.0,
652 };
653
654 let payload = notifier.build_discord_payload(&event);
655 let embed = &payload["embeds"][0];
656 assert_eq!(embed["title"], "Order Placed");
657 assert_eq!(embed["color"], 0x3498db); let fields = embed["fields"].as_array().unwrap();
660 assert_eq!(fields.len(), 4);
661 assert_eq!(fields[0]["name"], "Market");
662 assert_eq!(fields[0]["value"], "BTC-PERP");
663 assert_eq!(fields[1]["name"], "Side");
664 assert_eq!(fields[1]["value"], "buy");
665 }
666
667 #[test]
668 fn test_discord_payload_position_closed_profit() {
669 let cfg = test_config("/tmp/notifier-discord-test2");
670 let notifier = Notifier::from_config(&cfg);
671
672 let event = AgentEvent::PositionClosed {
673 market: "ETH-PERP".into(),
674 pnl: 150.0,
675 reason: "take_profit".into(),
676 };
677
678 let payload = notifier.build_discord_payload(&event);
679 let embed = &payload["embeds"][0];
680 assert_eq!(embed["color"], 0x2ecc71); }
682
683 #[test]
684 fn test_discord_payload_position_closed_loss() {
685 let cfg = test_config("/tmp/notifier-discord-test3");
686 let notifier = Notifier::from_config(&cfg);
687
688 let event = AgentEvent::PositionClosed {
689 market: "ETH-PERP".into(),
690 pnl: -50.0,
691 reason: "stop_loss".into(),
692 };
693
694 let payload = notifier.build_discord_payload(&event);
695 let embed = &payload["embeds"][0];
696 assert_eq!(embed["color"], 0xe74c3c); }
698
699 #[test]
700 fn test_discord_payload_error_event() {
701 let cfg = test_config("/tmp/notifier-discord-test4");
702 let notifier = Notifier::from_config(&cfg);
703
704 let event = AgentEvent::Error {
705 message: "connection refused".into(),
706 };
707
708 let payload = notifier.build_discord_payload(&event);
709 let embed = &payload["embeds"][0];
710 assert_eq!(embed["title"], "Error");
711 assert_eq!(embed["color"], 0xe74c3c); let fields = embed["fields"].as_array().unwrap();
714 assert_eq!(fields[0]["name"], "Message");
715 assert_eq!(fields[0]["value"], "connection refused");
716 assert!(!fields[0]["inline"].as_bool().unwrap());
717 }
718
719 #[test]
720 fn test_discord_payload_daily_pnl() {
721 let cfg = test_config("/tmp/notifier-discord-test5");
722 let notifier = Notifier::from_config(&cfg);
723
724 let event = AgentEvent::DailyPnl {
725 total_pnl: -200.0,
726 win_rate: 0.4,
727 trades: 10,
728 };
729
730 let payload = notifier.build_discord_payload(&event);
731 let embed = &payload["embeds"][0];
732 assert_eq!(embed["color"], 0xe74c3c); assert!(embed["timestamp"].is_string());
734 }
735
736 #[test]
737 fn test_discord_payload_has_timestamp() {
738 let cfg = test_config("/tmp/notifier-discord-test6");
739 let notifier = Notifier::from_config(&cfg);
740
741 let event = AgentEvent::AgentStarted {
742 name: "test-agent".into(),
743 mode: "paper".into(),
744 };
745
746 let payload = notifier.build_discord_payload(&event);
747 let ts = payload["embeds"][0]["timestamp"].as_str().unwrap();
748 assert!(ts.contains('T'));
750 assert!(ts.ends_with('Z'));
751 }
752
753 #[test]
756 fn test_event_colors() {
757 assert_eq!(
759 AgentEvent::OrderPlaced {
760 market: "X".into(),
761 side: "buy".into(),
762 size: 1.0,
763 price: 1.0,
764 }
765 .color(),
766 0x3498db
767 );
768 assert_eq!(
769 AgentEvent::CycleComplete {
770 markets_scanned: 1,
771 orders_placed: 0,
772 }
773 .color(),
774 0x3498db
775 );
776
777 assert_eq!(
779 AgentEvent::AgentStarted {
780 name: "a".into(),
781 mode: "live".into(),
782 }
783 .color(),
784 0x2ecc71
785 );
786 assert_eq!(
787 AgentEvent::PositionClosed {
788 market: "X".into(),
789 pnl: 0.0,
790 reason: "x".into(),
791 }
792 .color(),
793 0x2ecc71
794 ); assert_eq!(
798 AgentEvent::Error {
799 message: "x".into(),
800 }
801 .color(),
802 0xe74c3c
803 );
804
805 assert_eq!(
807 AgentEvent::AgentStopped { name: "a".into() }.color(),
808 0x95a5a6
809 );
810 }
811
812 #[test]
815 fn test_from_config_default() {
816 let cfg = NotifierSection::default();
817 let notifier = Notifier::from_config(&cfg);
818 assert!(notifier.discord_webhook.is_none());
819 assert!(notifier.log_dir.is_some()); assert_eq!(
821 notifier.quiet_start,
822 NaiveTime::from_hms_opt(23, 0, 0).unwrap()
823 );
824 assert_eq!(
825 notifier.quiet_end,
826 NaiveTime::from_hms_opt(8, 0, 0).unwrap()
827 );
828 }
829
830 #[test]
831 fn test_from_config_invalid_time_uses_defaults() {
832 let cfg = NotifierSection {
833 enabled: true,
834 discord_webhook: None,
835 log_dir: "logs".to_string(),
836 quiet_start: "invalid".to_string(),
837 quiet_end: "also-invalid".to_string(),
838 };
839 let notifier = Notifier::from_config(&cfg);
840 assert_eq!(
841 notifier.quiet_start,
842 NaiveTime::from_hms_opt(23, 0, 0).unwrap()
843 );
844 assert_eq!(
845 notifier.quiet_end,
846 NaiveTime::from_hms_opt(8, 0, 0).unwrap()
847 );
848 }
849
850 #[test]
853 fn test_notifier_error_display() {
854 let io_err = NotifierError::IoError(std::io::Error::new(
855 std::io::ErrorKind::PermissionDenied,
856 "access denied",
857 ));
858 assert!(io_err.to_string().contains("I/O"));
859
860 let discord_err = NotifierError::DiscordError("429 rate limited".into());
861 assert!(discord_err.to_string().contains("discord"));
862 }
863}