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