1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum NotificationLevel {
16 All,
20 Verbose,
22 ErrorsOnly,
24 None,
26}
27
28impl NotificationLevel {
29 pub fn from_config() -> Self {
31 match crate::config::get("notifications.level") {
32 Ok(val) => match val.as_str() {
33 "all" => Self::All,
34 "verbose" => Self::Verbose,
35 "errors_only" => Self::ErrorsOnly,
36 "none" => Self::None,
37 _ => Self::All,
38 },
39 Err(_) => Self::All,
40 }
41 }
42
43 pub fn should_notify(&self, status: &str) -> bool {
51 match self {
52 Self::All => matches!(status, "done" | "in_review" | "blocked" | "failed"),
53 Self::Verbose => true,
54 Self::ErrorsOnly => matches!(status, "blocked" | "failed"),
55 Self::None => false,
56 }
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TaskNotification {
63 pub task_id: String,
64 pub title: String,
65 pub status: String,
66 pub agent: String,
67 pub duration_seconds: f64,
68 pub summary: String,
69 pub repo: Option<String>,
71 #[serde(default)]
75 pub notify_target: Option<String>,
76 #[serde(default)]
78 pub pr_number: Option<String>,
79}
80
81fn github_link(repo: Option<&str>, task_id: &str, pr_number: Option<&str>) -> Option<String> {
84 let repo = repo?;
85 if let Some(pr) = pr_number {
86 return Some(format!("https://github.com/{repo}/pull/{pr}"));
87 }
88 if !task_id.starts_with("internal:") && task_id.chars().all(|c| c.is_ascii_digit()) {
90 return Some(format!("https://github.com/{repo}/issues/{task_id}"));
91 }
92 None
93}
94
95impl TaskNotification {
96 pub fn format_telegram(&self) -> String {
102 let emoji = status_emoji(&self.status);
103 let duration = format_duration(self.duration_seconds);
104 let link_suffix = match github_link(
105 self.repo.as_deref(),
106 &self.task_id,
107 self.pr_number.as_deref(),
108 ) {
109 Some(url) => format!("\n\n<a href=\"{}\">View on GitHub</a>", html_escape(&url)),
110 None => String::new(),
111 };
112
113 format!(
114 "{emoji} <b>{title}</b>\n\
115 {status} · <code>{agent}</code> · {duration} · #{task_id}\n\
116 \n\
117 {summary}{link_suffix}",
118 emoji = emoji,
119 title = html_escape(&self.title),
120 status = html_escape(&self.status),
121 agent = html_escape(&self.agent),
122 duration = duration,
123 task_id = html_escape(&self.task_id),
124 summary = html_escape(&self.summary),
125 link_suffix = link_suffix,
126 )
127 }
128
129 pub fn format_slack(&self) -> String {
133 let emoji = status_emoji(&self.status);
134 let duration = format_duration(self.duration_seconds);
135 let link_suffix = match github_link(
136 self.repo.as_deref(),
137 &self.task_id,
138 self.pr_number.as_deref(),
139 ) {
140 Some(url) => format!("\n\n<{url}|View on GitHub>"),
141 None => String::new(),
142 };
143
144 format!(
145 "{emoji} *{title}*\n\
146 {status} · `{agent}` · {duration} · #{task_id}\n\
147 \n\
148 {summary}{link_suffix}",
149 emoji = emoji,
150 title = self.title,
151 status = self.status,
152 agent = self.agent,
153 duration = duration,
154 task_id = self.task_id,
155 summary = self.summary,
156 link_suffix = link_suffix,
157 )
158 }
159
160 pub fn format_discord(&self) -> String {
162 let emoji = status_emoji(&self.status);
163 let duration = format_duration(self.duration_seconds);
164 let link_suffix = match github_link(
165 self.repo.as_deref(),
166 &self.task_id,
167 self.pr_number.as_deref(),
168 ) {
169 Some(url) => format!("\n\n[View on GitHub]({url})"),
170 None => String::new(),
171 };
172
173 format!(
174 "{emoji} **{title}**\n\
175 {status} · `{agent}` · {duration} · #{task_id}\n\
176 \n\
177 {summary}{link_suffix}",
178 emoji = emoji,
179 title = self.title,
180 status = self.status,
181 agent = self.agent,
182 duration = duration,
183 task_id = self.task_id,
184 summary = self.summary,
185 link_suffix = link_suffix,
186 )
187 }
188
189 #[allow(dead_code)]
194 pub fn format_with_project(&self, channel: &str) -> String {
195 let project_name = self
196 .repo
197 .as_deref()
198 .and_then(|r| r.split('/').next_back())
199 .unwrap_or("unknown");
200
201 let base = match channel {
202 "telegram" => self.format_telegram(),
203 "discord" => self.format_discord(),
204 "slack" => self.format_slack(),
205 _ => self.format_telegram(), };
207
208 let project_name = if channel == "telegram" {
209 html_escape(project_name)
210 } else {
211 project_name.to_string()
212 };
213 format!("[{project_name}] {base}")
214 }
215}
216
217fn status_emoji(status: &str) -> &'static str {
218 match status {
219 "done" => "✅",
220 "in_review" => "🔍",
221 "in_progress" => "🔄",
222 "needs_review" => "⚠️",
223 "blocked" => "🚫",
224 "failed" => "❌",
225 _ => "📋",
226 }
227}
228
229fn html_escape(s: &str) -> String {
231 s.replace('&', "&")
232 .replace('<', "<")
233 .replace('>', ">")
234}
235
236pub fn format_duration(seconds: f64) -> String {
238 if seconds < 60.0 {
239 format!("{:.0}s", seconds)
240 } else if seconds < 3600.0 {
241 format!("{:.0}m {:.0}s", (seconds / 60.0).floor(), seconds % 60.0)
242 } else {
243 let hours = (seconds / 3600.0).floor();
244 let mins = ((seconds % 3600.0) / 60.0).floor();
245 format!("{:.0}h {:.0}m", hours, mins)
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn notification_level_should_notify_all_active_review_only() {
255 let level = NotificationLevel::All;
256 assert!(level.should_notify("done"));
258 assert!(level.should_notify("in_review"));
259 assert!(level.should_notify("blocked"));
260 assert!(level.should_notify("failed"));
261 assert!(!level.should_notify("needs_review"));
264 assert!(!level.should_notify("new"));
266 assert!(!level.should_notify("routed"));
267 assert!(!level.should_notify("in_progress"));
268 }
269
270 #[test]
271 fn notification_level_should_notify_verbose_fires_for_all() {
272 let level = NotificationLevel::Verbose;
273 assert!(level.should_notify("done"));
274 assert!(level.should_notify("needs_review"));
275 assert!(level.should_notify("blocked"));
276 assert!(level.should_notify("failed"));
277 assert!(level.should_notify("new"));
278 assert!(level.should_notify("routed"));
279 assert!(level.should_notify("in_progress"));
280 assert!(level.should_notify("in_review"));
281 }
282
283 #[test]
284 fn notification_level_should_notify_errors_only() {
285 let level = NotificationLevel::ErrorsOnly;
286 assert!(!level.should_notify("done"));
287 assert!(!level.should_notify("in_progress"));
288 assert!(!level.should_notify("in_review"));
289 assert!(!level.should_notify("needs_review"));
290 assert!(level.should_notify("blocked"));
291 assert!(level.should_notify("failed"));
292 }
293
294 #[test]
295 fn notification_level_should_notify_none() {
296 let level = NotificationLevel::None;
297 assert!(!level.should_notify("done"));
298 assert!(!level.should_notify("needs_review"));
299 assert!(!level.should_notify("in_review"));
300 assert!(!level.should_notify("blocked"));
301 }
302
303 #[test]
304 fn notification_level_default_is_all() {
305 let level = NotificationLevel::from_config();
307 assert_eq!(level, NotificationLevel::All);
308 }
309
310 #[test]
311 fn format_duration_seconds() {
312 assert_eq!(format_duration(30.0), "30s");
313 assert_eq!(format_duration(0.0), "0s");
314 assert_eq!(format_duration(59.9), "60s");
315 }
316
317 #[test]
318 fn format_duration_minutes() {
319 assert_eq!(format_duration(90.0), "1m 30s");
320 assert_eq!(format_duration(3599.0), "59m 59s");
321 }
322
323 #[test]
324 fn format_duration_hours() {
325 assert_eq!(format_duration(3600.0), "1h 0m");
326 assert_eq!(format_duration(7200.0), "2h 0m");
327 assert_eq!(format_duration(5400.0), "1h 30m");
328 }
329
330 #[test]
331 fn format_telegram_done() {
332 let n = TaskNotification {
333 task_id: "42".to_string(),
334 title: "Fix auth bug".to_string(),
335 status: "done".to_string(),
336 agent: "claude".to_string(),
337 duration_seconds: 120.0,
338 summary: "Fixed the OAuth flow".to_string(),
339 repo: Some("owner/my-project".to_string()),
340 notify_target: None,
341 pr_number: None,
342 };
343 let msg = n.format_telegram();
344 assert!(msg.contains("✅"));
345 assert!(
347 msg.contains("<b>Fix auth bug</b>"),
348 "title should be the headline: {msg}"
349 );
350 assert!(msg.contains("#42"));
351 assert!(msg.contains("done"));
352 assert!(msg.contains("claude"));
353 assert!(msg.contains("2m 0s"));
354 assert!(msg.contains("Fixed the OAuth flow"));
355 }
356
357 #[test]
358 fn format_telegram_escapes_markdown() {
359 let n = TaskNotification {
360 task_id: "1".to_string(),
361 title: "Fix _underscores_ and *bold*".to_string(),
362 status: "done".to_string(),
363 agent: "claude".to_string(),
364 duration_seconds: 10.0,
365 summary: "Done".to_string(),
366 repo: None,
367 notify_target: None,
368 pr_number: None,
369 };
370 let msg = n.format_telegram();
371 assert!(msg.contains("Fix _underscores_ and *bold*"));
374 assert!(msg.contains("<b>Fix _underscores_ and *bold*</b>"));
376 }
377
378 #[test]
379 fn format_telegram_escapes_status_with_underscores() {
380 let n = TaskNotification {
381 task_id: "5".to_string(),
382 title: "Test task".to_string(),
383 status: "needs_review".to_string(),
384 agent: "claude".to_string(),
385 duration_seconds: 30.0,
386 summary: "Ready for review".to_string(),
387 repo: None,
388 notify_target: None,
389 pr_number: None,
390 };
391 let msg = n.format_telegram();
392 assert!(msg.contains("needs_review"));
395 }
396
397 #[test]
398 fn format_telegram_escapes_agent_with_underscores() {
399 let n = TaskNotification {
400 task_id: "6".to_string(),
401 title: "Test".to_string(),
402 status: "done".to_string(),
403 agent: "my_agent".to_string(),
404 duration_seconds: 15.0,
405 summary: "Done".to_string(),
406 repo: None,
407 notify_target: None,
408 pr_number: None,
409 };
410 let msg = n.format_telegram();
411 assert!(msg.contains("<code>my_agent</code>"));
414 }
415
416 #[test]
417 fn format_slack_done() {
418 let n = TaskNotification {
419 task_id: "7".to_string(),
420 title: "Refactor engine".to_string(),
421 status: "done".to_string(),
422 agent: "claude".to_string(),
423 duration_seconds: 60.0,
424 summary: "Decomposed mod.rs".to_string(),
425 repo: Some("org/refactor-repo".to_string()),
426 notify_target: None,
427 pr_number: None,
428 };
429 let msg = n.format_slack();
430 assert!(msg.contains("✅"));
431 assert!(
432 msg.contains("*Refactor engine*"),
433 "title should be the headline: {msg}"
434 );
435 assert!(msg.contains("#7"));
436 assert!(msg.contains("done"));
437 assert!(msg.contains("`claude`"));
438 assert!(msg.contains("1m 0s"));
439 assert!(msg.contains("Decomposed mod.rs"));
440 }
441
442 #[test]
443 fn format_discord_needs_review() {
444 let n = TaskNotification {
445 task_id: "99".to_string(),
446 title: "Deploy service".to_string(),
447 status: "needs_review".to_string(),
448 agent: "codex".to_string(),
449 duration_seconds: 1800.0,
450 summary: "Timed out waiting for tests".to_string(),
451 repo: Some("acme/deploy-svc".to_string()),
452 notify_target: None,
453 pr_number: None,
454 };
455 let msg = n.format_discord();
456 assert!(msg.contains("⚠️"));
457 assert!(
458 msg.contains("**Deploy service**"),
459 "title should be the headline: {msg}"
460 );
461 assert!(msg.contains("#99"));
462 assert!(msg.contains("needs_review"));
463 assert!(msg.contains("codex"));
464 assert!(msg.contains("30m 0s"));
465 }
466
467 #[test]
468 fn format_with_project_prepends_repo_name() {
469 let n = TaskNotification {
470 task_id: "10".to_string(),
471 title: "Add feature".to_string(),
472 status: "done".to_string(),
473 agent: "claude".to_string(),
474 duration_seconds: 45.0,
475 summary: "Feature added".to_string(),
476 repo: Some("acme/widgets".to_string()),
477 notify_target: None,
478 pr_number: None,
479 };
480 let msg = n.format_with_project("telegram");
481 assert!(msg.starts_with("[widgets] "));
482 assert!(msg.contains("#10"));
483 assert!(msg.contains("<b>Add feature</b>"));
484 }
485
486 #[test]
487 fn format_with_project_unknown_when_no_repo() {
488 let n = TaskNotification {
489 task_id: "11".to_string(),
490 title: "Task".to_string(),
491 status: "done".to_string(),
492 agent: "claude".to_string(),
493 duration_seconds: 5.0,
494 summary: "Done".to_string(),
495 repo: None,
496 notify_target: None,
497 pr_number: None,
498 };
499 let msg = n.format_with_project("discord");
500 assert!(msg.starts_with("[unknown] "));
501 }
502
503 #[test]
504 fn status_emoji_mapping() {
505 assert_eq!(status_emoji("done"), "✅");
506 assert_eq!(status_emoji("in_review"), "🔍");
507 assert_eq!(status_emoji("in_progress"), "🔄");
508 assert_eq!(status_emoji("needs_review"), "⚠️");
509 assert_eq!(status_emoji("blocked"), "🚫");
510 assert_eq!(status_emoji("failed"), "❌");
511 assert_eq!(status_emoji("unknown"), "📋");
512 }
513
514 #[test]
515 fn format_with_project_includes_repo_name() {
516 let n = TaskNotification {
517 task_id: "42".into(),
518 title: "Fix bug".into(),
519 status: "done".into(),
520 agent: "claude".into(),
521 duration_seconds: 90.0,
522 summary: "Fixed it".into(),
523 repo: Some("owner/myproject".into()),
524 notify_target: None,
525 pr_number: None,
526 };
527 let formatted = n.format_with_project("telegram");
528 assert!(
529 formatted.starts_with("[myproject]"),
530 "should start with project name: {formatted}"
531 );
532 assert!(formatted.contains("Fix bug"));
533 }
534
535 #[test]
536 fn format_with_project_no_repo_still_works() {
537 let n = TaskNotification {
538 task_id: "42".into(),
539 title: "Fix bug".into(),
540 status: "done".into(),
541 agent: "claude".into(),
542 duration_seconds: 90.0,
543 summary: "Fixed it".into(),
544 repo: None,
545 notify_target: None,
546 pr_number: None,
547 };
548 let formatted = n.format_with_project("telegram");
549 assert!(
550 formatted.starts_with("[unknown]"),
551 "should fallback: {formatted}"
552 );
553 }
554
555 #[test]
556 fn title_appears_before_metadata_in_telegram() {
557 let n = TaskNotification {
558 task_id: "internal:151694".to_string(),
559 title: "Morning briefing: daily focus plan".to_string(),
560 status: "done".to_string(),
561 agent: "opencode".to_string(),
562 duration_seconds: 1821.0,
563 summary: "Recap delivered".to_string(),
564 repo: Some("owner/repo".to_string()),
565 notify_target: None,
566 pr_number: None,
567 };
568 let msg = n.format_telegram();
569 let title_pos = msg.find("Morning briefing").expect("title present");
570 let id_pos = msg.find("#internal:151694").expect("id present");
571 assert!(
572 title_pos < id_pos,
573 "title must appear before task id: {msg}"
574 );
575 }
576
577 #[test]
578 fn title_appears_before_metadata_in_discord() {
579 let n = TaskNotification {
580 task_id: "internal:5".to_string(),
581 title: "Deploy svc".to_string(),
582 status: "done".to_string(),
583 agent: "codex".to_string(),
584 duration_seconds: 60.0,
585 summary: "ok".to_string(),
586 repo: Some("acme/svc".to_string()),
587 notify_target: None,
588 pr_number: None,
589 };
590 let msg = n.format_discord();
591 let title_pos = msg.find("Deploy svc").expect("title present");
592 let id_pos = msg.find("#internal:5").expect("id present");
593 assert!(title_pos < id_pos, "title before id: {msg}");
594 }
595
596 #[test]
597 fn format_with_project_uses_channel_format() {
598 let n = TaskNotification {
599 task_id: "5".into(),
600 title: "Deploy".into(),
601 status: "done".into(),
602 agent: "codex".into(),
603 duration_seconds: 60.0,
604 summary: "Deployed".into(),
605 repo: Some("acme/svc".into()),
606 notify_target: None,
607 pr_number: None,
608 };
609 let tg = n.format_with_project("telegram");
610 let dc = n.format_with_project("discord");
611 assert!(tg.starts_with("[svc]"));
613 assert!(dc.starts_with("[svc]"));
614 assert!(dc.contains("**Deploy**"));
616 assert!(tg.contains("<b>Deploy</b>"));
617 assert!(tg.contains("#5"));
619 assert!(dc.contains("#5"));
620 }
621
622 #[test]
623 fn telegram_appends_pr_link_when_pr_number_present() {
624 let n = TaskNotification {
625 task_id: "internal:151696".to_string(),
626 title: "Market intelligence".to_string(),
627 status: "done".to_string(),
628 agent: "claude".to_string(),
629 duration_seconds: 39.0,
630 summary: "Trending topics".to_string(),
631 repo: Some("owner/repo".to_string()),
632 notify_target: None,
633 pr_number: Some("4242".to_string()),
634 };
635 let msg = n.format_telegram();
636 assert!(
637 msg.contains("https://github.com/owner/repo/pull/4242"),
638 "expected PR link in {msg}"
639 );
640 assert!(msg.contains("View on GitHub"));
641 }
642
643 #[test]
644 fn telegram_falls_back_to_issue_link_for_external_task_without_pr() {
645 let n = TaskNotification {
646 task_id: "1234".to_string(),
647 title: "An issue".to_string(),
648 status: "needs_review".to_string(),
649 agent: "claude".to_string(),
650 duration_seconds: 10.0,
651 summary: "Awaiting review".to_string(),
652 repo: Some("owner/repo".to_string()),
653 notify_target: None,
654 pr_number: None,
655 };
656 let msg = n.format_telegram();
657 assert!(
658 msg.contains("https://github.com/owner/repo/issues/1234"),
659 "expected issue link in {msg}"
660 );
661 }
662
663 #[test]
664 fn no_link_for_internal_task_without_pr() {
665 let n = TaskNotification {
666 task_id: "internal:99".to_string(),
667 title: "Internal task".to_string(),
668 status: "done".to_string(),
669 agent: "claude".to_string(),
670 duration_seconds: 5.0,
671 summary: "Done".to_string(),
672 repo: Some("owner/repo".to_string()),
673 notify_target: None,
674 pr_number: None,
675 };
676 let msg = n.format_telegram();
677 assert!(!msg.contains("github.com"), "should not link: {msg}");
678 }
679
680 #[test]
681 fn discord_appends_pr_link() {
682 let n = TaskNotification {
683 task_id: "internal:7".to_string(),
684 title: "T".to_string(),
685 status: "done".to_string(),
686 agent: "claude".to_string(),
687 duration_seconds: 1.0,
688 summary: "S".to_string(),
689 repo: Some("owner/repo".to_string()),
690 notify_target: None,
691 pr_number: Some("9".to_string()),
692 };
693 let msg = n.format_discord();
694 assert!(msg.contains("[View on GitHub](https://github.com/owner/repo/pull/9)"));
695 }
696
697 #[test]
698 fn slack_appends_pr_link() {
699 let n = TaskNotification {
700 task_id: "internal:7".to_string(),
701 title: "T".to_string(),
702 status: "done".to_string(),
703 agent: "claude".to_string(),
704 duration_seconds: 1.0,
705 summary: "S".to_string(),
706 repo: Some("owner/repo".to_string()),
707 notify_target: None,
708 pr_number: Some("9".to_string()),
709 };
710 let msg = n.format_slack();
711 assert!(msg.contains("<https://github.com/owner/repo/pull/9|View on GitHub>"));
712 }
713}