1use super::intelligence::{ClassifiedMessage, MessageType};
12use crate::config::{DigestFrequency, MessagePriority};
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use uuid::Uuid;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DigestHighlight {
22 pub channel: String,
24 pub sender: String,
26 pub summary: String,
28 pub priority: MessagePriority,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DigestActionItem {
35 pub description: String,
37 pub source_channel: String,
39 pub source_sender: String,
41 pub deadline: Option<DateTime<Utc>>,
43 pub scheduled: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ChannelDigest {
50 pub id: Uuid,
52 pub period_start: DateTime<Utc>,
54 pub period_end: DateTime<Utc>,
56 pub channels_covered: Vec<String>,
58 pub total_messages: usize,
60 pub summary: String,
62 pub highlights: Vec<DigestHighlight>,
64 pub action_items: Vec<DigestActionItem>,
66 pub channel_counts: HashMap<String, usize>,
68}
69
70impl ChannelDigest {
71 pub fn to_markdown(&self) -> String {
73 let mut md = String::new();
74
75 md.push_str(&format!(
76 "# Channel Digest — {} to {}\n\n",
77 self.period_start.format("%Y-%m-%d %H:%M"),
78 self.period_end.format("%Y-%m-%d %H:%M"),
79 ));
80
81 md.push_str("## Summary\n\n");
82 md.push_str(&format!(
83 "Received {} messages across {} channels.",
84 self.total_messages,
85 self.channels_covered.len(),
86 ));
87 if !self.highlights.is_empty() {
88 md.push_str(&format!(" {} need attention.", self.highlights.len()));
89 }
90 md.push_str("\n\n");
91
92 if !self.summary.is_empty() {
93 md.push_str(&self.summary);
94 md.push_str("\n\n");
95 }
96
97 if !self.highlights.is_empty() {
98 md.push_str("## Highlights\n\n");
99 for h in &self.highlights {
100 md.push_str(&format!(
101 "- **[{}]** {}: {} ({:?})\n",
102 crate::sanitize::escape_markdown(&h.channel),
103 crate::sanitize::escape_markdown(&h.sender),
104 crate::sanitize::escape_markdown(&h.summary),
105 h.priority,
106 ));
107 }
108 md.push('\n');
109 }
110
111 if !self.action_items.is_empty() {
112 md.push_str("## Action Items\n\n");
113 for item in &self.action_items {
114 let checkbox = if item.scheduled { "[x]" } else { "[ ]" };
115 let deadline_str = item
116 .deadline
117 .map(|d| format!(" — deadline: {}", d.format("%Y-%m-%d")))
118 .unwrap_or_default();
119 md.push_str(&format!(
120 "- {} {} ({}, {}){}\n",
121 checkbox,
122 crate::sanitize::escape_markdown(&item.description),
123 crate::sanitize::escape_markdown(&item.source_channel),
124 crate::sanitize::escape_markdown(&item.source_sender),
125 deadline_str,
126 ));
127 }
128 md.push('\n');
129 }
130
131 if !self.channel_counts.is_empty() {
132 md.push_str("## Channel Breakdown\n\n");
133 let mut counts: Vec<_> = self.channel_counts.iter().collect();
134 counts.sort_by(|a, b| b.1.cmp(a.1));
135 for (channel, count) in counts {
136 md.push_str(&format!(
137 "- **{}**: {} messages\n",
138 crate::sanitize::escape_markdown(channel),
139 count
140 ));
141 }
142 md.push('\n');
143 }
144
145 md
146 }
147}
148
149#[derive(Debug, Clone)]
151struct DigestEntry {
152 channel_name: String,
153 sender: String,
154 summary: String,
155 priority: MessagePriority,
156 message_type: MessageType,
157 #[allow(dead_code)]
158 timestamp: DateTime<Utc>,
159}
160
161const MAX_DIGEST_ENTRIES: usize = 10_000;
163
164pub struct DigestCollector {
166 entries: Vec<DigestEntry>,
168 period_start: DateTime<Utc>,
170 frequency: DigestFrequency,
172 digest_dir: PathBuf,
174}
175
176impl DigestCollector {
177 pub fn new(frequency: DigestFrequency, digest_dir: PathBuf) -> Self {
179 Self {
180 entries: Vec::new(),
181 period_start: Utc::now(),
182 frequency,
183 digest_dir,
184 }
185 }
186
187 pub fn add_message(&mut self, classified: &ClassifiedMessage, channel_name: &str) {
189 let sender = classified
190 .original
191 .sender
192 .display_name
193 .clone()
194 .unwrap_or_else(|| classified.original.sender.id.clone());
195
196 let summary = match &classified.original.content {
197 super::types::MessageContent::Text { text } => {
198 if text.chars().count() > 120 {
199 format!("{}...", text.chars().take(120).collect::<String>())
200 } else {
201 text.clone()
202 }
203 }
204 super::types::MessageContent::Command { command, args } => {
205 format!("/{} {}", command, args.join(" "))
206 }
207 super::types::MessageContent::File { filename, .. } => {
208 format!("[File: {}]", filename)
209 }
210 _ => "[media]".to_string(),
211 };
212
213 self.entries.push(DigestEntry {
214 channel_name: channel_name.to_string(),
215 sender,
216 summary,
217 priority: classified.priority,
218 message_type: classified.message_type.clone(),
219 timestamp: classified.classified_at,
220 });
221
222 if self.entries.len() > MAX_DIGEST_ENTRIES {
224 let excess = self.entries.len() - MAX_DIGEST_ENTRIES;
225 self.entries.drain(..excess);
226 }
227 }
228
229 pub fn should_generate(&self) -> bool {
231 let elapsed = Utc::now() - self.period_start;
232 match self.frequency {
233 DigestFrequency::Off => false,
234 DigestFrequency::Hourly => elapsed.num_hours() >= 1,
235 DigestFrequency::Daily => elapsed.num_hours() >= 24,
236 DigestFrequency::Weekly => elapsed.num_days() >= 7,
237 }
238 }
239
240 pub fn generate(&mut self) -> Option<ChannelDigest> {
242 if self.entries.is_empty() || self.frequency == DigestFrequency::Off {
243 return None;
244 }
245
246 let now = Utc::now();
247
248 let mut channel_counts: HashMap<String, usize> = HashMap::new();
250 for entry in &self.entries {
251 *channel_counts
252 .entry(entry.channel_name.clone())
253 .or_default() += 1;
254 }
255
256 let channels_covered: Vec<String> = channel_counts.keys().cloned().collect();
257
258 let highlights: Vec<DigestHighlight> = self
260 .entries
261 .iter()
262 .filter(|e| e.priority >= MessagePriority::High)
263 .map(|e| DigestHighlight {
264 channel: e.channel_name.clone(),
265 sender: e.sender.clone(),
266 summary: e.summary.clone(),
267 priority: e.priority,
268 })
269 .collect();
270
271 let action_items: Vec<DigestActionItem> = self
273 .entries
274 .iter()
275 .filter(|e| e.message_type == MessageType::ActionRequired)
276 .map(|e| DigestActionItem {
277 description: e.summary.clone(),
278 source_channel: e.channel_name.clone(),
279 source_sender: e.sender.clone(),
280 deadline: None,
281 scheduled: false,
282 })
283 .collect();
284
285 let total = self.entries.len();
286
287 let summary = format!(
289 "Processed {} messages across {} channels. {} highlights, {} action items.",
290 total,
291 channels_covered.len(),
292 highlights.len(),
293 action_items.len(),
294 );
295
296 let digest = ChannelDigest {
297 id: Uuid::new_v4(),
298 period_start: self.period_start,
299 period_end: now,
300 channels_covered,
301 total_messages: total,
302 summary,
303 highlights,
304 action_items,
305 channel_counts,
306 };
307
308 self.entries.clear();
310 self.period_start = now;
311
312 Some(digest)
313 }
314
315 pub fn digest_file_path(&self, digest: &ChannelDigest) -> PathBuf {
317 let filename = format!("digest_{}.md", digest.period_end.format("%Y-%m-%d_%H%M"),);
318 self.digest_dir.join(filename)
319 }
320
321 pub fn export_markdown(&self, digest: &ChannelDigest) -> Result<PathBuf, std::io::Error> {
325 let path = self.digest_file_path(digest);
326 if let Some(parent) = path.parent() {
327 std::fs::create_dir_all(parent)?;
328 }
329 std::fs::write(&path, digest.to_markdown())?;
330 Ok(path)
331 }
332
333 pub fn message_count(&self) -> usize {
335 self.entries.len()
336 }
337
338 pub fn frequency(&self) -> &DigestFrequency {
340 &self.frequency
341 }
342
343 pub fn digest_dir(&self) -> &Path {
345 &self.digest_dir
346 }
347
348 pub fn is_empty(&self) -> bool {
350 self.entries.is_empty()
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::channels::intelligence::{ClassifiedMessage, MessageType, SuggestedAction};
358 use crate::channels::types::{
359 ChannelMessage, ChannelType, ChannelUser, MessageContent, MessageId,
360 };
361 use crate::config::MessagePriority;
362 use std::collections::HashMap;
363
364 fn make_classified(
365 text: &str,
366 priority: MessagePriority,
367 msg_type: MessageType,
368 channel_type: ChannelType,
369 sender_name: &str,
370 ) -> ClassifiedMessage {
371 let msg = ChannelMessage {
372 id: MessageId::random(),
373 channel_type,
374 channel_id: "C123".to_string(),
375 sender: ChannelUser::new("user1", channel_type).with_name(sender_name),
376 content: MessageContent::Text {
377 text: text.to_string(),
378 },
379 timestamp: Utc::now(),
380 reply_to: None,
381 thread_id: None,
382 metadata: HashMap::new(),
383 };
384 ClassifiedMessage {
385 original: msg,
386 priority,
387 message_type: msg_type,
388 suggested_action: SuggestedAction::AddToDigest,
389 confidence: 0.8,
390 reasoning: "test".to_string(),
391 classified_at: Utc::now(),
392 }
393 }
394
395 fn test_collector() -> DigestCollector {
396 DigestCollector::new(
397 DigestFrequency::Hourly,
398 PathBuf::from("/tmp/rustant-test-digests"),
399 )
400 }
401
402 #[test]
403 fn test_collector_new_empty() {
404 let collector = test_collector();
405 assert!(collector.is_empty());
406 assert_eq!(collector.message_count(), 0);
407 }
408
409 #[test]
410 fn test_collector_add_message() {
411 let mut collector = test_collector();
412 let classified = make_classified(
413 "Hello world",
414 MessagePriority::Normal,
415 MessageType::Notification,
416 ChannelType::Slack,
417 "Alice",
418 );
419 collector.add_message(&classified, "slack");
420 assert_eq!(collector.message_count(), 1);
421 assert!(!collector.is_empty());
422 }
423
424 #[test]
425 fn test_collector_add_multiple_channels() {
426 let mut collector = test_collector();
427 let msg1 = make_classified(
428 "Slack message",
429 MessagePriority::Normal,
430 MessageType::Notification,
431 ChannelType::Slack,
432 "Alice",
433 );
434 let msg2 = make_classified(
435 "Email message",
436 MessagePriority::High,
437 MessageType::ActionRequired,
438 ChannelType::Email,
439 "Bob",
440 );
441 collector.add_message(&msg1, "slack");
442 collector.add_message(&msg2, "email");
443 assert_eq!(collector.message_count(), 2);
444 }
445
446 #[test]
447 fn test_collector_should_not_generate_when_off() {
448 let collector = DigestCollector::new(DigestFrequency::Off, PathBuf::from("/tmp"));
449 assert!(!collector.should_generate());
450 }
451
452 #[test]
453 fn test_collector_should_not_generate_too_soon() {
454 let collector = test_collector(); assert!(!collector.should_generate());
456 }
457
458 #[test]
459 fn test_generate_empty_returns_none() {
460 let mut collector = test_collector();
461 assert!(collector.generate().is_none());
462 }
463
464 #[test]
465 fn test_generate_off_returns_none() {
466 let mut collector = DigestCollector::new(DigestFrequency::Off, PathBuf::from("/tmp"));
467 let msg = make_classified(
468 "Hello",
469 MessagePriority::Normal,
470 MessageType::Notification,
471 ChannelType::Slack,
472 "Alice",
473 );
474 collector.add_message(&msg, "slack");
475 assert!(collector.generate().is_none());
476 }
477
478 #[test]
479 fn test_generate_digest() {
480 let mut collector = test_collector();
481
482 let msg1 = make_classified(
484 "Normal notification",
485 MessagePriority::Normal,
486 MessageType::Notification,
487 ChannelType::Slack,
488 "Alice",
489 );
490 let msg2 = make_classified(
491 "URGENT: production down!",
492 MessagePriority::Urgent,
493 MessageType::ActionRequired,
494 ChannelType::Slack,
495 "Bob",
496 );
497 let msg3 = make_classified(
498 "Please review PR #456",
499 MessagePriority::Normal,
500 MessageType::ActionRequired,
501 ChannelType::Email,
502 "Carol",
503 );
504
505 collector.add_message(&msg1, "slack");
506 collector.add_message(&msg2, "slack");
507 collector.add_message(&msg3, "email");
508
509 let digest = collector.generate().unwrap();
510
511 assert_eq!(digest.total_messages, 3);
512 assert_eq!(digest.channels_covered.len(), 2);
513 assert_eq!(digest.highlights.len(), 1); assert_eq!(digest.action_items.len(), 2); assert_eq!(*digest.channel_counts.get("slack").unwrap(), 2);
516 assert_eq!(*digest.channel_counts.get("email").unwrap(), 1);
517 }
518
519 #[test]
520 fn test_generate_resets_collector() {
521 let mut collector = test_collector();
522 let msg = make_classified(
523 "Hello",
524 MessagePriority::Normal,
525 MessageType::Notification,
526 ChannelType::Slack,
527 "Alice",
528 );
529 collector.add_message(&msg, "slack");
530 assert_eq!(collector.message_count(), 1);
531
532 let _digest = collector.generate();
533 assert_eq!(collector.message_count(), 0);
534 assert!(collector.is_empty());
535 }
536
537 #[test]
538 fn test_digest_to_markdown() {
539 let digest = ChannelDigest {
540 id: Uuid::new_v4(),
541 period_start: Utc::now() - chrono::Duration::hours(1),
542 period_end: Utc::now(),
543 channels_covered: vec!["slack".to_string(), "email".to_string()],
544 total_messages: 15,
545 summary: "Active day with multiple action items.".to_string(),
546 highlights: vec![DigestHighlight {
547 channel: "slack".to_string(),
548 sender: "Alice".to_string(),
549 summary: "Production deployment scheduled".to_string(),
550 priority: MessagePriority::High,
551 }],
552 action_items: vec![DigestActionItem {
553 description: "Review PR #456".to_string(),
554 source_channel: "email".to_string(),
555 source_sender: "Bob".to_string(),
556 deadline: None,
557 scheduled: false,
558 }],
559 channel_counts: {
560 let mut m = HashMap::new();
561 m.insert("slack".to_string(), 10);
562 m.insert("email".to_string(), 5);
563 m
564 },
565 };
566
567 let md = digest.to_markdown();
568 assert!(md.contains("# Channel Digest"));
569 assert!(md.contains("15 messages across 2 channels"));
570 assert!(md.contains("## Highlights"));
571 assert!(md.contains("Alice"));
572 assert!(md.contains("## Action Items"));
573 assert!(md.contains("Review PR \\#456"));
574 assert!(md.contains("## Channel Breakdown"));
575 }
576
577 #[test]
578 fn test_digest_file_path() {
579 let collector = test_collector();
580 let digest = ChannelDigest {
581 id: Uuid::new_v4(),
582 period_start: Utc::now(),
583 period_end: Utc::now(),
584 channels_covered: vec![],
585 total_messages: 0,
586 summary: String::new(),
587 highlights: vec![],
588 action_items: vec![],
589 channel_counts: HashMap::new(),
590 };
591 let path = collector.digest_file_path(&digest);
592 assert!(path.to_str().unwrap().contains("digest_"));
593 assert!(path.to_str().unwrap().ends_with(".md"));
594 }
595
596 #[test]
597 fn test_digest_highlights_only_high_priority() {
598 let mut collector = test_collector();
599
600 let low = make_classified(
601 "Low priority",
602 MessagePriority::Low,
603 MessageType::Notification,
604 ChannelType::Slack,
605 "Alice",
606 );
607 let normal = make_classified(
608 "Normal priority",
609 MessagePriority::Normal,
610 MessageType::Question,
611 ChannelType::Slack,
612 "Bob",
613 );
614 let high = make_classified(
615 "High priority",
616 MessagePriority::High,
617 MessageType::ActionRequired,
618 ChannelType::Email,
619 "Carol",
620 );
621 let urgent = make_classified(
622 "Urgent!",
623 MessagePriority::Urgent,
624 MessageType::ActionRequired,
625 ChannelType::Email,
626 "Dave",
627 );
628
629 collector.add_message(&low, "slack");
630 collector.add_message(&normal, "slack");
631 collector.add_message(&high, "email");
632 collector.add_message(&urgent, "email");
633
634 let digest = collector.generate().unwrap();
635 assert_eq!(digest.highlights.len(), 2); }
637
638 #[test]
639 fn test_digest_multibyte_utf8_truncation() {
640 let mut collector = test_collector();
641 let cjk_text: String = "漢".repeat(130);
644 let msg = make_classified(
645 &cjk_text,
646 MessagePriority::Normal,
647 MessageType::Notification,
648 ChannelType::Slack,
649 "Alice",
650 );
651 collector.add_message(&msg, "slack");
652 let digest = collector.generate().unwrap();
654 assert_eq!(digest.total_messages, 1);
655 }
656
657 #[test]
658 fn test_digest_emoji_truncation() {
659 let mut collector = test_collector();
660 let emoji_text: String = "🎉".repeat(130);
662 let msg = make_classified(
663 &emoji_text,
664 MessagePriority::High,
665 MessageType::ActionRequired,
666 ChannelType::Email,
667 "Bob",
668 );
669 collector.add_message(&msg, "email");
670 let digest = collector.generate().unwrap();
672 assert_eq!(digest.highlights.len(), 1);
673 assert!(digest.highlights[0].summary.ends_with("..."));
675 }
676}