Skip to main content

rustant_core/channels/
digest.rs

1//! Channel Digest System.
2//!
3//! Collects classified messages over configurable time windows and generates
4//! summarized digests. Digests are delivered via three outputs:
5//! - TUI/REPL callback (`on_channel_digest`)
6//! - Configurable channel (send as a message)
7//! - Markdown file export to `.rustant/digests/`
8//!
9//! Digest frequency is controlled per-channel via `DigestFrequency`.
10
11use 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/// A highlight entry within a digest.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DigestHighlight {
22    /// The channel the message came from.
23    pub channel: String,
24    /// The sender's display name or ID.
25    pub sender: String,
26    /// A brief summary of the message.
27    pub summary: String,
28    /// The classified priority.
29    pub priority: MessagePriority,
30}
31
32/// An action item extracted from classified messages.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct DigestActionItem {
35    /// Description of what needs to be done.
36    pub description: String,
37    /// The channel the action came from.
38    pub source_channel: String,
39    /// The sender who triggered the action.
40    pub source_sender: String,
41    /// Optional deadline extracted from the message.
42    pub deadline: Option<DateTime<Utc>>,
43    /// Whether a reminder has been scheduled for this item.
44    pub scheduled: bool,
45}
46
47/// A generated channel digest covering a time period.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ChannelDigest {
50    /// Unique identifier for this digest.
51    pub id: Uuid,
52    /// Start of the covered period.
53    pub period_start: DateTime<Utc>,
54    /// End of the covered period.
55    pub period_end: DateTime<Utc>,
56    /// Channels covered by this digest.
57    pub channels_covered: Vec<String>,
58    /// Total number of messages in the period.
59    pub total_messages: usize,
60    /// Generated summary text.
61    pub summary: String,
62    /// Notable messages highlighted for attention.
63    pub highlights: Vec<DigestHighlight>,
64    /// Action items extracted from messages.
65    pub action_items: Vec<DigestActionItem>,
66    /// Per-channel message counts.
67    pub channel_counts: HashMap<String, usize>,
68}
69
70impl ChannelDigest {
71    /// Generate a markdown representation of the digest.
72    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/// An entry in the digest collector, grouping messages by channel.
150#[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
161/// Maximum number of digest entries held before oldest are dropped to prevent unbounded memory growth.
162const MAX_DIGEST_ENTRIES: usize = 10_000;
163
164/// Collects classified messages and generates periodic digests.
165pub struct DigestCollector {
166    /// Accumulated message entries grouped by channel.
167    entries: Vec<DigestEntry>,
168    /// When the current collection period started.
169    period_start: DateTime<Utc>,
170    /// Configured digest frequency.
171    frequency: DigestFrequency,
172    /// Directory for markdown file export.
173    digest_dir: PathBuf,
174}
175
176impl DigestCollector {
177    /// Create a new collector with the given frequency and export directory.
178    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    /// Add a classified message to the collector.
188    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        // Evict oldest entries if over capacity to prevent unbounded memory growth
223        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    /// Check if a digest should be generated based on the configured frequency.
230    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    /// Generate a digest from the collected messages and reset the collector.
241    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        // Compute per-channel counts
249        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        // Extract highlights (High/Urgent messages)
259        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        // Extract action items
272        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        // Generate summary
288        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        // Reset for next period
309        self.entries.clear();
310        self.period_start = now;
311
312        Some(digest)
313    }
314
315    /// Generate the file path for a digest export.
316    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    /// Export a digest to a markdown file.
322    ///
323    /// Returns the path the file was written to, or an error.
324    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    /// Get the number of messages collected in the current period.
334    pub fn message_count(&self) -> usize {
335        self.entries.len()
336    }
337
338    /// Get the configured digest frequency.
339    pub fn frequency(&self) -> &DigestFrequency {
340        &self.frequency
341    }
342
343    /// Get the digest export directory.
344    pub fn digest_dir(&self) -> &Path {
345        &self.digest_dir
346    }
347
348    /// Check if the collector is empty.
349    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(); // Hourly
455        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        // Add various messages
483        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); // Only the urgent one
514        assert_eq!(digest.action_items.len(), 2); // Both ActionRequired
515        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); // High + Urgent only
636    }
637
638    #[test]
639    fn test_digest_multibyte_utf8_truncation() {
640        let mut collector = test_collector();
641        // 130 CJK characters — each is 3 bytes in UTF-8, so byte length = 390
642        // but char count = 130 > 120, so it should be truncated
643        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        // Should not panic — this was the bug
653        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        // 130 emoji — each is 4 bytes in UTF-8
661        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        // Should not panic
671        let digest = collector.generate().unwrap();
672        assert_eq!(digest.highlights.len(), 1);
673        // The highlight summary should end with "..."
674        assert!(digest.highlights[0].summary.ends_with("..."));
675    }
676}