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