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