1use std::collections::HashMap;
8
9#[derive(Debug, Clone)]
16pub enum ConversationEntry {
17 UserMessage(String),
18 AssistantText(String),
19 ToolUse { name: String, input_summary: String },
20 ToolResult { content: String, is_error: bool },
21}
22
23#[derive(Debug, Clone)]
26pub struct Conversation {
27 pub session_id: String,
28 pub first_timestamp: Option<String>,
29 pub last_timestamp: Option<String>,
30 pub user_message_count: u32,
31 pub assistant_message_count: u32,
32 pub entries: Vec<ConversationEntry>,
33}
34
35impl Conversation {
36 pub fn new(session_id: &str) -> Self {
38 Self {
39 session_id: session_id.to_string(),
40 first_timestamp: None,
41 last_timestamp: None,
42 user_message_count: 0,
43 assistant_message_count: 0,
44 entries: Vec::new(),
45 }
46 }
47
48 pub fn total_messages(&self) -> u32 {
50 self.user_message_count + self.assistant_message_count
51 }
52}
53
54pub fn conversation_to_markdown(conv: &Conversation, log_num: u32) -> String {
60 let mut md = format!("# Conversation {log_num:03}\n\n");
61 let mut last_role: Option<&str> = None;
62
63 for entry in &conv.entries {
64 match entry {
65 ConversationEntry::UserMessage(text) => {
66 if last_role != Some("user") {
67 md.push_str("---\n\n### User\n\n");
68 }
69 md.push_str(text);
70 md.push_str("\n\n");
71 last_role = Some("user");
72 }
73 ConversationEntry::AssistantText(text) => {
74 if last_role != Some("assistant") {
75 md.push_str("---\n\n### Assistant\n\n");
76 }
77 md.push_str(text);
78 md.push_str("\n\n");
79 last_role = Some("assistant");
80 }
81 ConversationEntry::ToolUse {
82 name,
83 input_summary,
84 } => {
85 md.push_str(&format!("> **{name}**: `{input_summary}`\n\n"));
86 }
87 ConversationEntry::ToolResult { content, is_error } => {
88 let label = if *is_error { "Error" } else { "Result" };
89 let truncated = truncate(content, 2000);
90 md.push_str(&format!(
91 "<details><summary>{label}</summary>\n\n```\n{truncated}\n```\n\n</details>\n\n"
92 ));
93 }
94 }
95 }
96
97 md
98}
99
100const STOP_WORDS: &[&str] = &[
105 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
106 "do", "does", "did", "will", "would", "could", "should", "may", "might", "can", "shall", "to",
107 "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "about", "like", "through",
108 "after", "over", "between", "out", "up", "down", "off", "then", "than", "too", "very", "just",
109 "also", "not", "no", "but", "or", "and", "if", "so", "yet", "both", "this", "that", "these",
110 "those", "it", "its", "i", "you", "we", "they", "he", "she", "me", "my", "your", "our",
111 "their", "him", "her", "us", "them", "what", "which", "who", "when", "where", "how", "why",
112 "all", "each", "every", "some", "any", "most", "other", "new", "old", "first", "last", "next",
113 "now", "here", "there", "only", "one", "two", "get", "got", "make", "made", "let", "let's",
114 "use", "need", "want", "know", "think", "see", "look", "find", "give", "tell", "say", "said",
115 "go", "going", "come", "take", "thing", "things", "way", "work", "right", "good", "yeah",
116 "yes", "okay", "ok", "sure", "well", "don't", "doesn't", "didn't", "can't", "won't", "isn't",
117 "aren't", "wasn't", "file", "code", "run", "set", "add", "put", "try",
118];
119
120pub fn extract_topics(conv: &Conversation, max: usize) -> Vec<String> {
123 let mut freq: HashMap<String, u32> = HashMap::new();
124
125 let mut user_msg_count = 0;
127 for entry in &conv.entries {
128 if let ConversationEntry::UserMessage(text) = entry {
129 let cleaned = strip_channel_prefix(text);
130 for word in cleaned.split_whitespace() {
131 let clean: String = word
132 .to_lowercase()
133 .chars()
134 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
135 .collect();
136 if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
137 *freq.entry(clean).or_default() += 1;
138 }
139 }
140 user_msg_count += 1;
141 if user_msg_count >= 5 {
142 break;
143 }
144 }
145 }
146
147 for entry in &conv.entries {
149 if let ConversationEntry::ToolUse {
150 input_summary,
151 name,
152 ..
153 } = entry
154 {
155 let target = input_summary
156 .rsplit('/')
157 .next()
158 .unwrap_or(input_summary)
159 .trim_matches('`')
160 .to_lowercase();
161 if target.len() >= 3 && !target.contains('(') {
162 let stem = target.split('.').next().unwrap_or(&target);
163 if !stem.is_empty() {
164 *freq.entry(stem.to_string()).or_default() += 2;
165 }
166 }
167 let tool_lower = name.to_lowercase();
168 if !STOP_WORDS.contains(&tool_lower.as_str()) {
169 *freq.entry(tool_lower).or_default() += 1;
170 }
171 }
172 }
173
174 let mut sorted: Vec<(String, u32)> = freq.into_iter().collect();
175 sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
176 sorted.into_iter().take(max).map(|(k, _)| k).collect()
177}
178
179pub fn extract_summary(conv: &Conversation) -> String {
185 for entry in &conv.entries {
186 if let ConversationEntry::UserMessage(text) = entry {
187 let cleaned = strip_channel_prefix(text);
188 if cleaned.is_empty() {
189 continue;
190 }
191 let truncated: String = cleaned.chars().take(200).collect();
192 if truncated.len() < cleaned.len() {
193 return format!("{truncated}...");
194 }
195 return truncated;
196 }
197 }
198 "Empty session".to_string()
199}
200
201pub fn utc_now() -> String {
207 use std::time::SystemTime;
208 let now = SystemTime::now()
209 .duration_since(SystemTime::UNIX_EPOCH)
210 .unwrap_or_default()
211 .as_secs();
212 let secs_per_day = 86400u64;
213 let days = now / secs_per_day;
214 let day_secs = now % secs_per_day;
215 let hours = day_secs / 3600;
216 let minutes = (day_secs % 3600) / 60;
217 let seconds = day_secs % 60;
218
219 let mut y = 1970i64;
220 let mut remaining_days = days as i64;
221 loop {
222 let year_days = if is_leap(y) { 366 } else { 365 };
223 if remaining_days < year_days {
224 break;
225 }
226 remaining_days -= year_days;
227 y += 1;
228 }
229 let month_days = if is_leap(y) {
230 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
231 } else {
232 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
233 };
234 let mut m = 0usize;
235 for (i, &md) in month_days.iter().enumerate() {
236 if remaining_days < md {
237 m = i;
238 break;
239 }
240 remaining_days -= md;
241 }
242 let d = remaining_days + 1;
243 format!(
244 "{y:04}-{:02}-{d:02}T{hours:02}:{minutes:02}:{seconds:02}Z",
245 m + 1
246 )
247}
248
249fn is_leap(y: i64) -> bool {
250 (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
251}
252
253pub fn date_from_timestamp(ts: &str) -> String {
255 ts.split('T').next().unwrap_or(ts).to_string()
256}
257
258pub fn calculate_duration(start: &str, end: &str) -> String {
260 fn parse_timestamp(ts: &str) -> Option<u64> {
261 let t_pos = ts.find('T')?;
262 let date_part = &ts[..t_pos];
263 let time_part = ts[t_pos + 1..]
264 .trim_end_matches('Z')
265 .trim_end_matches("+00:00");
266
267 let date_parts: Vec<&str> = date_part.split('-').collect();
268 if date_parts.len() != 3 {
269 return None;
270 }
271 let year: u64 = date_parts[0].parse().ok()?;
272 let month: u64 = date_parts[1].parse().ok()?;
273 let day: u64 = date_parts[2].parse().ok()?;
274
275 let time_clean = time_part.split('.').next()?;
276 let time_parts: Vec<&str> = time_clean.split(':').collect();
277 if time_parts.len() != 3 {
278 return None;
279 }
280 let hour: u64 = time_parts[0].parse().ok()?;
281 let min: u64 = time_parts[1].parse().ok()?;
282 let sec: u64 = time_parts[2].parse().ok()?;
283
284 Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec)
285 }
286
287 match (parse_timestamp(start), parse_timestamp(end)) {
288 (Some(a), Some(b)) => {
289 let diff = b.abs_diff(a);
290 format_duration(diff)
291 }
292 _ => "unknown".to_string(),
293 }
294}
295
296fn format_duration(seconds: u64) -> String {
297 if seconds < 60 {
298 "< 1m".to_string()
299 } else if seconds < 3600 {
300 format!("{}m", seconds / 60)
301 } else {
302 let h = seconds / 3600;
303 let m = (seconds % 3600) / 60;
304 if m == 0 {
305 format!("{h}h")
306 } else {
307 format!("{h}h{m:02}m")
308 }
309 }
310}
311
312pub fn strip_channel_prefix(text: &str) -> String {
318 let mut s = text.trim().to_string();
319
320 if s.starts_with('[') {
321 if let Some(end) = s.find("]\n") {
322 s = s[end + 2..].trim().to_string();
323 } else if let Some(end) = s.find("] ") {
324 s = s[end + 2..].trim().to_string();
325 }
326 }
327
328 if let Some(rest) = s.strip_prefix("User message: ") {
329 s = rest.to_string();
330 }
331 if let Some(rest) = s.strip_prefix("User message:") {
332 s = rest.trim().to_string();
333 }
334
335 s
336}
337
338pub fn truncate(s: &str, max: usize) -> String {
340 if s.len() <= max {
341 s.to_string()
342 } else {
343 let total = s.len();
344 let mut end = max;
346 while end > 0 && !s.is_char_boundary(end) {
347 end -= 1;
348 }
349 format!("{}...\n\n[truncated, {total} chars total]", &s[..end])
350 }
351}
352
353pub fn condense_for_summary(conv: &Conversation) -> String {
356 let mut condensed = String::new();
357
358 for entry in &conv.entries {
359 match entry {
360 ConversationEntry::UserMessage(text) => {
361 condensed.push_str("User: ");
362 let t: String = text.chars().take(300).collect();
363 condensed.push_str(&t);
364 if t.len() < text.len() {
365 condensed.push('\u{2026}');
366 }
367 condensed.push('\n');
368 }
369 ConversationEntry::AssistantText(text) => {
370 condensed.push_str("Assistant: ");
371 let t: String = text.chars().take(300).collect();
372 condensed.push_str(&t);
373 if t.len() < text.len() {
374 condensed.push('\u{2026}');
375 }
376 condensed.push('\n');
377 }
378 ConversationEntry::ToolUse {
379 name,
380 input_summary,
381 } => {
382 condensed.push_str(&format!("[Tool: {name} \u{2192} {input_summary}]\n"));
383 }
384 ConversationEntry::ToolResult { .. } => {}
385 }
386 }
387
388 if condensed.len() > 4000 {
389 condensed.truncate(4000);
390 condensed.push_str("\n\u{2026} (conversation truncated)");
391 }
392
393 condensed
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn make_conv(entries: Vec<ConversationEntry>) -> Conversation {
401 let mut user_count = 0u32;
402 let mut asst_count = 0u32;
403 for e in &entries {
404 match e {
405 ConversationEntry::UserMessage(_) => user_count += 1,
406 ConversationEntry::AssistantText(_) => asst_count += 1,
407 _ => {}
408 }
409 }
410 Conversation {
411 session_id: "test".to_string(),
412 first_timestamp: None,
413 last_timestamp: None,
414 user_message_count: user_count,
415 assistant_message_count: asst_count,
416 entries,
417 }
418 }
419
420 #[test]
421 fn conversation_to_markdown_basic() {
422 let conv = make_conv(vec![
423 ConversationEntry::UserMessage("What is Rust?".to_string()),
424 ConversationEntry::AssistantText("Rust is a systems programming language.".to_string()),
425 ]);
426 let md = conversation_to_markdown(&conv, 1);
427 assert!(md.contains("# Conversation 001"));
428 assert!(md.contains("### User"));
429 assert!(md.contains("### Assistant"));
430 assert!(md.contains("What is Rust?"));
431 }
432
433 #[test]
434 fn topic_extraction() {
435 let conv = make_conv(vec![
436 ConversationEntry::UserMessage(
437 "Let's work on the authentication module for the API".to_string(),
438 ),
439 ConversationEntry::UserMessage(
440 "The authentication needs JWT tokens and rate limiting".to_string(),
441 ),
442 ConversationEntry::ToolUse {
443 name: "Read".to_string(),
444 input_summary: "/src/auth.rs".to_string(),
445 },
446 ]);
447 let topics = extract_topics(&conv, 5);
448 assert!(!topics.is_empty());
449 assert!(topics.iter().any(|t| t.contains("auth")));
450 }
451
452 #[test]
453 fn summary_extraction() {
454 let conv = make_conv(vec![
455 ConversationEntry::UserMessage("Fix the login bug in the auth module".to_string()),
456 ConversationEntry::AssistantText("Let me take a look at the auth module.".to_string()),
457 ]);
458 let summary = extract_summary(&conv);
459 assert!(summary.contains("Fix the login bug"));
460 }
461
462 #[test]
463 fn summary_strips_channel_prefix() {
464 let conv = make_conv(vec![ConversationEntry::UserMessage(
465 "[Channel: discord | Trust: VERIFIED]\nFix the login bug".to_string(),
466 )]);
467 let summary = extract_summary(&conv);
468 assert!(summary.starts_with("Fix the login bug"));
469 }
470
471 #[test]
472 fn summary_empty_session() {
473 let conv = make_conv(vec![]);
474 assert_eq!(extract_summary(&conv), "Empty session");
475 }
476
477 #[test]
478 fn duration_calculation() {
479 assert_eq!(
480 calculate_duration("2026-03-06T10:00:00Z", "2026-03-06T10:45:00Z"),
481 "45m"
482 );
483 assert_eq!(
484 calculate_duration("2026-03-06T10:00:00Z", "2026-03-06T12:30:00Z"),
485 "2h30m"
486 );
487 }
488
489 #[test]
490 fn duration_short() {
491 assert_eq!(
492 calculate_duration("2026-03-05T14:30:00.000Z", "2026-03-05T14:30:30.000Z"),
493 "< 1m"
494 );
495 }
496
497 #[test]
498 fn duration_invalid() {
499 assert_eq!(calculate_duration("garbage", "nonsense"), "unknown");
500 }
501
502 #[test]
503 fn utc_now_format() {
504 let ts = utc_now();
505 assert!(ts.contains('T'));
506 assert!(ts.ends_with('Z'));
507 assert!(ts.len() >= 19);
508 }
509
510 #[test]
511 fn empty_messages_produce_empty_topics() {
512 let conv = make_conv(vec![]);
513 let topics = extract_topics(&conv, 5);
514 assert!(topics.is_empty());
515 }
516
517 #[test]
518 fn truncate_short() {
519 assert_eq!(truncate("hello", 100), "hello");
520 }
521
522 #[test]
523 fn truncate_long() {
524 let long = "x".repeat(3000);
525 let result = truncate(&long, 2000);
526 assert!(result.len() < 3000);
527 assert!(result.contains("[truncated, 3000 chars total]"));
528 }
529
530 #[test]
531 fn condense_truncates_long_messages() {
532 let conv = make_conv(vec![ConversationEntry::UserMessage("x".repeat(500))]);
533 let condensed = condense_for_summary(&conv);
534 assert!(condensed.len() < 400);
535 assert!(condensed.contains('\u{2026}'));
536 }
537}