1use crate::session::{self, SessionSource};
2use chrono::{DateTime, Utc};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::io::{BufRead, BufReader};
6
7#[derive(Debug, Clone)]
9pub struct DagNode {
10 pub uuid: String,
11 pub parent_uuid: Option<String>,
12 #[allow(dead_code)]
13 record_type: String,
14 pub timestamp: Option<DateTime<Utc>>,
15 pub line_index: usize,
16 pub role: Option<String>,
18 pub content_preview: Option<String>,
20}
21
22#[derive(Debug, Clone)]
24pub struct TreeRow {
25 pub uuid: String,
26 pub role: String,
27 pub timestamp: DateTime<Utc>,
28 pub content_preview: String,
29 #[allow(dead_code)]
30 pub depth: usize,
31 pub graph_symbols: String,
32 pub is_on_latest_chain: bool,
33 pub is_branch_point: bool,
34 pub is_compaction: bool,
36}
37
38pub struct SessionTree {
40 nodes: HashMap<String, DagNode>,
42 children: HashMap<String, Vec<String>>,
44 #[allow(dead_code)]
46 roots: Vec<String>,
47 latest_chain: HashSet<String>,
49 pub rows: Vec<TreeRow>,
51 pub session_id: String,
53 pub file_path: String,
54 pub source: SessionSource,
55}
56
57impl SessionTree {
58 pub fn from_file(file_path: &str) -> Result<Self, String> {
60 let file = fs::File::open(file_path)
61 .map_err(|e| format!("Failed to open {}: {}", file_path, e))?;
62 let reader = BufReader::new(file);
63
64 let mut nodes: HashMap<String, DagNode> = HashMap::new();
65 let mut children: HashMap<String, Vec<String>> = HashMap::new();
66 let mut roots: Vec<String> = Vec::new();
67 let mut last_uuid: Option<String> = None;
68 let mut session_id = String::new();
69
70 for (line_idx, line_result) in reader.lines().enumerate() {
71 let line =
72 line_result.map_err(|e| format!("Read error at line {}: {}", line_idx, e))?;
73 let trimmed = line.trim();
74 if trimmed.is_empty() {
75 continue;
76 }
77
78 let json: serde_json::Value = match serde_json::from_str(trimmed) {
79 Ok(v) => v,
80 Err(_) => continue,
81 };
82
83 let uuid = match session::extract_uuid(&json) {
84 Some(u) => u,
85 None => continue,
86 };
87
88 let parent_uuid = session::extract_parent_uuid(&json);
89
90 let record_type = session::extract_record_type(&json)
91 .unwrap_or("")
92 .to_string();
93
94 let timestamp = session::extract_timestamp(&json);
95
96 let (role, content_preview) = if record_type == "user" || record_type == "assistant" {
98 let message = json.get("message");
99 let role = message
100 .and_then(|m| m.get("role"))
101 .and_then(|v| v.as_str())
102 .map(|s| s.to_string());
103
104 let preview = message
105 .and_then(|m| m.get("content"))
106 .map(|c| extract_preview(c, 120));
107
108 (role, preview)
109 } else if record_type == "summary" {
110 let summary_text = json
112 .get("summary")
113 .and_then(|v| v.as_str())
114 .unwrap_or("(auto-compacted)")
115 .to_string();
116 (Some("compaction".to_string()), Some(summary_text))
117 } else {
118 (None, None)
119 };
120
121 if session_id.is_empty() {
123 if let Some(sid) = session::extract_session_id(&json) {
124 session_id = sid;
125 }
126 }
127
128 match &parent_uuid {
130 Some(parent) => {
131 children
132 .entry(parent.clone())
133 .or_default()
134 .push(uuid.clone());
135 }
136 None => {
137 roots.push(uuid.clone());
138 }
139 }
140
141 nodes.insert(
142 uuid.clone(),
143 DagNode {
144 uuid: uuid.clone(),
145 parent_uuid,
146 record_type,
147 timestamp,
148 line_index: line_idx,
149 role,
150 content_preview,
151 },
152 );
153 last_uuid = Some(uuid);
154 }
155
156 let latest_chain = build_latest_chain(&nodes, last_uuid.as_deref());
158
159 let source = SessionSource::from_path(file_path);
160
161 let mut tree = SessionTree {
162 nodes,
163 children,
164 roots,
165 latest_chain,
166 rows: Vec::new(),
167 session_id,
168 file_path: file_path.to_string(),
169 source,
170 };
171
172 tree.flatten_to_rows();
173 Ok(tree)
174 }
175
176 pub fn branch_count(&self) -> usize {
178 self.children.values().filter(|kids| kids.len() > 1).count()
179 }
180
181 pub fn get_full_content(&self, uuid: &str) -> Option<String> {
183 let node = self.nodes.get(uuid)?;
184 let file = fs::File::open(&self.file_path).ok()?;
185 let reader = BufReader::new(file);
186
187 let line = reader.lines().nth(node.line_index)?.ok()?;
188 let json: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
189 let content_raw = json.get("message")?.get("content")?;
190 Some(crate::search::Message::extract_content(content_raw))
191 }
192
193 fn flatten_to_rows(&mut self) {
195 let (display_children, display_roots) = self.build_display_graph();
197
198 let mut rows: Vec<TreeRow> = Vec::new();
200 let mut active_columns: Vec<bool> = Vec::new();
201
202 for root in &display_roots {
203 let col = find_free_column(&active_columns);
204 if col >= active_columns.len() {
205 active_columns.push(true);
206 } else {
207 active_columns[col] = true;
208 }
209 self.dfs_flatten(
210 root,
211 col,
212 true, &display_children,
214 &mut active_columns,
215 &mut rows,
216 );
217 }
218
219 self.rows = rows;
220 }
221
222 fn build_display_graph(&self) -> (HashMap<String, Vec<String>>, Vec<String>) {
225 let mut display_children: HashMap<String, Vec<String>> = HashMap::new();
226 let mut display_roots: Vec<String> = Vec::new();
227
228 let mut displayable_parent_cache: HashMap<String, Option<String>> = HashMap::new();
231
232 let displayable_uuids: HashSet<String> = self
234 .nodes
235 .values()
236 .filter(|n| n.role.is_some() && n.content_preview.is_some())
237 .map(|n| n.uuid.clone())
238 .collect();
239
240 let mut displayable_sorted: Vec<&String> = displayable_uuids.iter().collect();
242 displayable_sorted.sort_by_key(|uuid| {
243 self.nodes
244 .get(uuid.as_str())
245 .map(|n| n.line_index)
246 .unwrap_or(0)
247 });
248
249 for uuid in displayable_sorted {
251 let display_parent = self.find_displayable_parent(
252 uuid,
253 &displayable_uuids,
254 &mut displayable_parent_cache,
255 );
256 match display_parent {
257 Some(parent_uuid) => {
258 display_children
259 .entry(parent_uuid)
260 .or_default()
261 .push(uuid.clone());
262 }
263 None => {
264 display_roots.push(uuid.clone());
265 }
266 }
267 }
268
269 for kids in display_children.values_mut() {
271 kids.sort_by_key(|uuid| self.nodes.get(uuid).map(|n| n.line_index).unwrap_or(0));
272 }
273
274 display_roots.sort_by_key(|uuid| self.nodes.get(uuid).map(|n| n.line_index).unwrap_or(0));
276
277 (display_children, display_roots)
278 }
279
280 fn find_displayable_parent(
282 &self,
283 uuid: &str,
284 displayable: &HashSet<String>,
285 cache: &mut HashMap<String, Option<String>>,
286 ) -> Option<String> {
287 let node = self.nodes.get(uuid)?;
288 let mut current_parent = node.parent_uuid.clone();
289
290 let mut visited = HashSet::new();
292 while let Some(ref parent_uuid) = current_parent {
293 if visited.contains(parent_uuid) {
294 break; }
296 visited.insert(parent_uuid.clone());
297
298 if let Some(cached) = cache.get(parent_uuid) {
299 return cached.clone();
300 }
301
302 if displayable.contains(parent_uuid) {
303 cache.insert(uuid.to_string(), Some(parent_uuid.clone()));
304 return Some(parent_uuid.clone());
305 }
306
307 current_parent = self
308 .nodes
309 .get(parent_uuid)
310 .and_then(|n| n.parent_uuid.clone());
311 }
312
313 cache.insert(uuid.to_string(), None);
314 None
315 }
316
317 fn dfs_flatten(
319 &self,
320 uuid: &str,
321 column: usize,
322 is_last_child: bool,
323 display_children: &HashMap<String, Vec<String>>,
324 active_columns: &mut Vec<bool>,
325 rows: &mut Vec<TreeRow>,
326 ) {
327 let node = match self.nodes.get(uuid) {
328 Some(n) => n,
329 None => return,
330 };
331
332 let kids = display_children.get(uuid).cloned().unwrap_or_default();
333 let is_branch_point = kids.len() > 1;
334 let is_on_latest = self.latest_chain.contains(uuid);
335
336 let graph = build_graph_symbols(column, active_columns, is_last_child, !kids.is_empty());
338
339 let is_compaction = node.role.as_deref() == Some("compaction")
340 || is_context_loss_message(&node.content_preview);
341
342 rows.push(TreeRow {
343 uuid: uuid.to_string(),
344 role: node.role.clone().unwrap_or_else(|| "?".to_string()),
345 timestamp: node.timestamp.unwrap_or_else(Utc::now),
346 content_preview: node.content_preview.clone().unwrap_or_default(),
347 depth: column,
348 graph_symbols: graph,
349 is_on_latest_chain: is_on_latest,
350 is_branch_point,
351 is_compaction,
352 });
353
354 if kids.is_empty() {
355 if column < active_columns.len() {
357 active_columns[column] = false;
358 }
359 return;
360 }
361
362 let mut sorted_kids = kids;
364 sorted_kids.sort_by(|a, b| {
365 let a_latest = self.is_descendant_of_latest(a, display_children);
366 let b_latest = self.is_descendant_of_latest(b, display_children);
367 b_latest.cmp(&a_latest).then_with(|| {
369 let a_idx = self.nodes.get(a).map(|n| n.line_index).unwrap_or(0);
370 let b_idx = self.nodes.get(b).map(|n| n.line_index).unwrap_or(0);
371 a_idx.cmp(&b_idx)
372 })
373 });
374
375 let num_kids = sorted_kids.len();
376 for (i, child) in sorted_kids.into_iter().enumerate() {
377 let is_last = i == num_kids - 1;
378 if i == 0 {
379 self.dfs_flatten(
381 &child,
382 column,
383 is_last,
384 display_children,
385 active_columns,
386 rows,
387 );
388 } else {
389 let new_col = find_free_column(active_columns);
391 if new_col >= active_columns.len() {
392 active_columns.push(true);
393 } else {
394 active_columns[new_col] = true;
395 }
396 self.dfs_flatten(
397 &child,
398 new_col,
399 is_last,
400 display_children,
401 active_columns,
402 rows,
403 );
404 }
405 }
406 }
407
408 fn is_descendant_of_latest(
410 &self,
411 uuid: &str,
412 display_children: &HashMap<String, Vec<String>>,
413 ) -> bool {
414 if self.latest_chain.contains(uuid) {
415 return true;
416 }
417 if let Some(kids) = display_children.get(uuid) {
418 for kid in kids {
419 if self.is_descendant_of_latest(kid, display_children) {
420 return true;
421 }
422 }
423 }
424 false
425 }
426}
427
428fn build_latest_chain(
430 nodes: &HashMap<String, DagNode>,
431 last_uuid: Option<&str>,
432) -> HashSet<String> {
433 let mut chain = HashSet::new();
434 let Some(tip) = last_uuid else {
435 return chain;
436 };
437
438 let mut current = Some(tip.to_string());
439 while let Some(uuid) = current {
440 chain.insert(uuid.clone());
441 current = nodes.get(&uuid).and_then(|n| n.parent_uuid.clone());
442 }
443 chain
444}
445
446fn find_free_column(active_columns: &[bool]) -> usize {
448 for (i, active) in active_columns.iter().enumerate().skip(1) {
450 if !active {
451 return i;
452 }
453 }
454 active_columns.len()
455}
456
457fn build_graph_symbols(
459 column: usize,
460 active_columns: &[bool],
461 _is_last_child: bool,
462 _has_children: bool,
463) -> String {
464 let max_col = active_columns.len().max(column + 1);
465 let mut result = String::new();
466
467 for col in 0..max_col {
468 if col == column {
469 result.push_str("* ");
470 } else if col < active_columns.len() && active_columns[col] {
471 result.push_str("| ");
472 } else {
473 result.push_str(" ");
474 }
475 }
476
477 result
478}
479
480fn extract_preview(content: &serde_json::Value, max_chars: usize) -> String {
482 let text = if let Some(s) = content.as_str() {
483 s.to_string()
484 } else if let Some(arr) = content.as_array() {
485 let mut parts = Vec::new();
486 for item in arr {
487 let item_type = item.get("type").and_then(|t| t.as_str()).unwrap_or("");
488 match item_type {
489 "text" => {
490 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
491 parts.push(t.to_string());
492 }
493 }
494 "tool_use" => {
495 if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
496 parts.push(format!("[tool: {}]", name));
497 }
498 }
499 "tool_result" => {
500 parts.push("[tool_result]".to_string());
501 }
502 _ => {}
503 }
504 }
505 parts.join(" ")
506 } else {
507 String::new()
508 };
509
510 let stripped = strip_xml_tags(&text);
512 let sanitized = stripped
513 .replace('\n', " ")
514 .replace('\r', "")
515 .replace('\t', " ");
516 let mut prev_space = false;
518 let collapsed: String = sanitized
519 .chars()
520 .filter(|c| {
521 if *c == ' ' {
522 if prev_space {
523 return false;
524 }
525 prev_space = true;
526 } else {
527 prev_space = false;
528 }
529 true
530 })
531 .collect();
532
533 if collapsed.chars().count() > max_chars {
534 collapsed.chars().take(max_chars).collect::<String>() + "..."
535 } else {
536 collapsed
537 }
538}
539
540fn strip_xml_tags(text: &str) -> String {
542 let mut result = String::with_capacity(text.len());
543 let mut in_tag = false;
544 for ch in text.chars() {
545 match ch {
546 '<' => in_tag = true,
547 '>' => {
548 in_tag = false;
549 result.push(' '); }
551 _ if !in_tag => result.push(ch),
552 _ => {}
553 }
554 }
555 result
556}
557
558fn is_context_loss_message(content_preview: &Option<String>) -> bool {
561 let Some(preview) = content_preview else {
562 return false;
563 };
564 let lower = preview.to_lowercase();
565 lower.contains("being continued from a previous conversation that ran out of context")
566 || lower.contains("/compact")
567 || lower.contains("compacted (ctrl+o to see full summary)")
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use std::io::Write;
574 use tempfile::TempDir;
575
576 fn create_linear_session(dir: &TempDir) -> std::path::PathBuf {
578 let path = dir.path().join("linear.jsonl");
579 let mut f = fs::File::create(&path).unwrap();
580 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
581 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi there!"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
582 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Fix the bug"}}]}},"uuid":"u3","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
583 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Done fixing"}}]}},"uuid":"u4","parentUuid":"u3","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
584 path
585 }
586
587 fn create_branched_session(dir: &TempDir) -> std::path::PathBuf {
589 let path = dir.path().join("branched.jsonl");
590 let mut f = fs::File::create(&path).unwrap();
591 writeln!(f, r#"{{"type":"progress","uuid":"p1","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}}"#).unwrap();
593 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"a1","parentUuid":"p1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
594 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi"}}]}},"uuid":"a2","parentUuid":"a1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
595 writeln!(f, r#"{{"type":"system","uuid":"a3","parentUuid":"a2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
597 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Branch A msg"}}]}},"uuid":"a4","parentUuid":"a3","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
598 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch A reply"}}]}},"uuid":"a5","parentUuid":"a4","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z"}}"#).unwrap();
599 writeln!(f, r#"{{"type":"system","uuid":"b3","parentUuid":"a2","sessionId":"s1","timestamp":"2025-01-01T00:03:30Z"}}"#).unwrap();
601 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Branch B msg"}}]}},"uuid":"b4","parentUuid":"b3","sessionId":"s1","timestamp":"2025-01-01T00:04:30Z"}}"#).unwrap();
602 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Branch B reply"}}]}},"uuid":"b5","parentUuid":"b4","sessionId":"s1","timestamp":"2025-01-01T00:05:30Z"}}"#).unwrap();
603 path
604 }
605
606 #[test]
607 fn test_linear_session_parse() {
608 let dir = TempDir::new().unwrap();
609 let path = create_linear_session(&dir);
610 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
611
612 assert_eq!(tree.rows.len(), 4);
613 assert_eq!(tree.session_id, "s1");
614 assert_eq!(tree.branch_count(), 0);
615 }
616
617 #[test]
618 fn test_linear_session_order() {
619 let dir = TempDir::new().unwrap();
620 let path = create_linear_session(&dir);
621 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
622
623 assert_eq!(tree.rows[0].role, "user");
624 assert!(tree.rows[0].content_preview.contains("Hello"));
625 assert_eq!(tree.rows[1].role, "assistant");
626 assert!(tree.rows[1].content_preview.contains("Hi there"));
627 assert_eq!(tree.rows[2].role, "user");
628 assert!(tree.rows[2].content_preview.contains("Fix the bug"));
629 assert_eq!(tree.rows[3].role, "assistant");
630 assert!(tree.rows[3].content_preview.contains("Done fixing"));
631 }
632
633 #[test]
634 fn test_linear_all_on_latest_chain() {
635 let dir = TempDir::new().unwrap();
636 let path = create_linear_session(&dir);
637 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
638
639 for row in &tree.rows {
640 assert!(
641 row.is_on_latest_chain,
642 "All linear messages should be on latest chain"
643 );
644 }
645 }
646
647 #[test]
648 fn test_branched_session_parse() {
649 let dir = TempDir::new().unwrap();
650 let path = create_branched_session(&dir);
651 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
652
653 assert_eq!(tree.rows.len(), 6);
655 }
656
657 #[test]
658 fn test_branched_session_has_branches() {
659 let dir = TempDir::new().unwrap();
660 let path = create_branched_session(&dir);
661 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
662
663 assert!(
665 tree.branch_count() >= 1,
666 "Should have at least one branch point"
667 );
668 }
669
670 #[test]
671 fn test_branched_latest_chain() {
672 let dir = TempDir::new().unwrap();
673 let path = create_branched_session(&dir);
674 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
675
676 let find_row = |content: &str| {
681 tree.rows
682 .iter()
683 .find(|r| r.content_preview.contains(content))
684 .unwrap()
685 };
686
687 assert!(find_row("Branch B msg").is_on_latest_chain);
688 assert!(find_row("Branch B reply").is_on_latest_chain);
689 assert!(!find_row("Branch A msg").is_on_latest_chain);
690 assert!(!find_row("Branch A reply").is_on_latest_chain);
691 assert!(find_row("Hello").is_on_latest_chain);
693 assert!(find_row("Hi").is_on_latest_chain);
694 }
695
696 #[test]
697 fn test_branched_latest_chain_first_in_order() {
698 let dir = TempDir::new().unwrap();
699 let path = create_branched_session(&dir);
700 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
701
702 let b_msg_idx = tree
708 .rows
709 .iter()
710 .position(|r| r.content_preview.contains("Branch B msg"))
711 .unwrap();
712 let a_msg_idx = tree
713 .rows
714 .iter()
715 .position(|r| r.content_preview.contains("Branch A msg"))
716 .unwrap();
717
718 assert!(
719 b_msg_idx < a_msg_idx,
720 "Latest chain (B) should appear before fork (A)"
721 );
722 }
723
724 #[test]
725 fn test_get_full_content() {
726 let dir = TempDir::new().unwrap();
727 let path = create_linear_session(&dir);
728 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
729
730 let content = tree.get_full_content("u1").unwrap();
731 assert_eq!(content, "Hello");
732 }
733
734 #[test]
735 fn test_compaction_event_visible() {
736 let dir = TempDir::new().unwrap();
737 let path = dir.path().join("compact.jsonl");
738 let mut f = fs::File::create(&path).unwrap();
739 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
740 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Hi there"}}]}},"uuid":"u2","parentUuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:02:00Z"}}"#).unwrap();
741 writeln!(f, r#"{{"type":"summary","summary":"Discussed greeting","leafUuid":"u2","uuid":"s1sum","parentUuid":"u2","sessionId":"s1","timestamp":"2025-01-01T00:03:00Z"}}"#).unwrap();
742 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Continue after compact"}}]}},"uuid":"u3","parentUuid":"s1sum","sessionId":"s1","timestamp":"2025-01-01T00:04:00Z"}}"#).unwrap();
743 writeln!(f, r#"{{"type":"assistant","message":{{"role":"assistant","content":[{{"type":"text","text":"Sure!"}}]}},"uuid":"u4","parentUuid":"u3","sessionId":"s1","timestamp":"2025-01-01T00:05:00Z"}}"#).unwrap();
744
745 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
746
747 assert_eq!(tree.rows.len(), 5);
749
750 let compact_row = tree.rows.iter().find(|r| r.is_compaction).unwrap();
752 assert_eq!(compact_row.role, "compaction");
753 assert!(compact_row.content_preview.contains("Discussed greeting"));
754
755 let user_rows: Vec<_> = tree.rows.iter().filter(|r| !r.is_compaction).collect();
757 assert_eq!(user_rows.len(), 4);
758 }
759
760 #[test]
761 fn test_compaction_without_uuid_not_displayed() {
762 let dir = TempDir::new().unwrap();
763 let path = dir.path().join("compact_no_uuid.jsonl");
764 let mut f = fs::File::create(&path).unwrap();
765 writeln!(f, r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"text","text":"Hello"}}]}},"uuid":"u1","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}}"#).unwrap();
766 writeln!(
768 f,
769 r#"{{"type":"summary","summary":"Some summary","leafUuid":"u1","sessionId":"s1"}}"#
770 )
771 .unwrap();
772
773 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
774 assert_eq!(tree.rows.len(), 1);
776 assert!(!tree.rows[0].is_compaction);
777 }
778
779 #[test]
780 fn test_empty_file() {
781 let dir = TempDir::new().unwrap();
782 let path = dir.path().join("empty.jsonl");
783 fs::write(&path, "").unwrap();
784 let tree = SessionTree::from_file(path.to_str().unwrap()).unwrap();
785 assert!(tree.rows.is_empty());
786 }
787
788 #[test]
789 fn test_extract_preview_plain_string() {
790 let content = serde_json::json!("Hello world this is a test message");
791 let preview = extract_preview(&content, 20);
792 assert_eq!(preview, "Hello world this is ...");
793 }
794
795 #[test]
796 fn test_extract_preview_array() {
797 let content = serde_json::json!([
798 {"type": "text", "text": "Part one"},
799 {"type": "tool_use", "name": "Read"},
800 ]);
801 let preview = extract_preview(&content, 100);
802 assert!(preview.contains("Part one"));
803 assert!(preview.contains("[tool: Read]"));
804 }
805
806 #[test]
807 fn test_extract_preview_collapses_whitespace() {
808 let content = serde_json::json!("Hello\n\n world\t\ttab");
809 let preview = extract_preview(&content, 100);
810 assert!(!preview.contains('\n'));
811 assert!(!preview.contains('\t'));
812 }
813
814 #[test]
815 fn test_graph_symbols_single_column() {
816 let active = vec![true];
817 let symbols = build_graph_symbols(0, &active, true, true);
818 assert!(symbols.contains('*'));
819 }
820
821 #[test]
822 fn test_graph_symbols_multiple_columns() {
823 let active = vec![true, true];
824 let symbols = build_graph_symbols(1, &active, false, true);
825 assert!(symbols.contains('|'));
826 assert!(symbols.contains('*'));
827 }
828}