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