Skip to main content

ccs/tree/
mod.rs

1use crate::session::{self, SessionSource};
2use chrono::{DateTime, Utc};
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::io::{BufRead, BufReader};
6
7/// A node in the session DAG. Represents every uuid-bearing record.
8#[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    /// Only populated for user/assistant
17    pub role: Option<String>,
18    /// First ~120 chars of content, sanitized
19    pub content_preview: Option<String>,
20}
21
22/// A flattened row ready for display in the tree view.
23#[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    /// True if this is an auto-compaction (summary) event
35    pub is_compaction: bool,
36}
37
38/// The full parsed session tree.
39pub struct SessionTree {
40    /// All uuid-bearing nodes
41    nodes: HashMap<String, DagNode>,
42    /// parent_uuid -> vec of child uuids (ordered by line_index)
43    children: HashMap<String, Vec<String>>,
44    /// Root uuids (no parent)
45    #[allow(dead_code)]
46    roots: Vec<String>,
47    /// Set of uuids on the latest chain
48    latest_chain: HashSet<String>,
49    /// Flattened display rows (only user/assistant messages)
50    pub rows: Vec<TreeRow>,
51    /// Session metadata
52    pub session_id: String,
53    pub file_path: String,
54    pub source: SessionSource,
55}
56
57impl SessionTree {
58    /// Parse a JSONL session file into a tree structure.
59    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            // Extract role and content preview for displayable types
97            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                // Auto-compaction event — make it displayable
111                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            // Capture session_id from first available record
122            if session_id.is_empty() {
123                if let Some(sid) = session::extract_session_id(&json) {
124                    session_id = sid;
125                }
126            }
127
128            // Track parent->child relationships
129            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        // Build latest chain
157        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    /// Number of branch points (nodes with >1 child in display graph)
177    pub fn branch_count(&self) -> usize {
178        self.children.values().filter(|kids| kids.len() > 1).count()
179    }
180
181    /// Get the full content of a message by reading its JSONL line from file.
182    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    /// Build the display graph (only user/assistant) and flatten via DFS.
194    fn flatten_to_rows(&mut self) {
195        // Step 1: Build display graph — collapse non-displayable nodes
196        let (display_children, display_roots) = self.build_display_graph();
197
198        // Step 2: DFS with column tracking
199        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, // is_last_child of root level
213                &display_children,
214                &mut active_columns,
215                &mut rows,
216            );
217        }
218
219        self.rows = rows;
220    }
221
222    /// Build a display graph that only connects user/assistant nodes,
223    /// skipping intermediate progress/system nodes.
224    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        // For each displayable node, find its displayable parent and register
229        // as a child of that parent. If no displayable parent exists, it's a root.
230        let mut displayable_parent_cache: HashMap<String, Option<String>> = HashMap::new();
231
232        // Collect all displayable node uuids (as set for O(1) lookup + sorted vec for deterministic iteration)
233        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        // Sort by line_index for deterministic processing order
241        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 each displayable node, walk up parents until we find another displayable node
250        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        // Sort children by line_index for consistent ordering
270        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        // Sort roots by line_index
275        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    /// Walk up parent chain to find the nearest displayable ancestor.
281    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        // Walk up through non-displayable nodes
291        let mut visited = HashSet::new();
292        while let Some(ref parent_uuid) = current_parent {
293            if visited.contains(parent_uuid) {
294                break; // Cycle protection
295            }
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    /// DFS traversal to build flat TreeRow list with graph symbols.
318    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        // Build graph symbols
337        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            // Leaf: free column
356            if column < active_columns.len() {
357                active_columns[column] = false;
358            }
359            return;
360        }
361
362        // Sort children: latest chain first, then by line_index
363        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            // Latest chain first (true > false when reversed)
368            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                // First child continues on same column
380                self.dfs_flatten(
381                    &child,
382                    column,
383                    is_last,
384                    display_children,
385                    active_columns,
386                    rows,
387                );
388            } else {
389                // Allocate new column for branch
390                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    /// Check if a node or any of its descendants is on the latest chain.
409    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
428/// Build the latest chain by walking backwards from the tip (last uuid in file).
429fn 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
446/// Find the first free (false) column, or return the length (meaning append).
447fn find_free_column(active_columns: &[bool]) -> usize {
448    // Start from column 1 to keep column 0 for main trunk
449    for (i, active) in active_columns.iter().enumerate().skip(1) {
450        if !active {
451            return i;
452        }
453    }
454    active_columns.len()
455}
456
457/// Build the graph gutter string for a row.
458fn 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
480/// Extract a short preview from message content (first N chars).
481fn 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    // Sanitize: strip XML tags, remove newlines, multiple spaces
511    let stripped = strip_xml_tags(&text);
512    let sanitized = stripped
513        .replace('\n', " ")
514        .replace('\r', "")
515        .replace('\t', " ");
516    // Collapse multiple spaces
517    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
540/// Strip XML/HTML-like tags from text, keeping only inner content.
541fn 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(' '); // replace tag with space
550            }
551            _ if !in_tag => result.push(ch),
552            _ => {}
553        }
554    }
555    result
556}
557
558/// Detect messages related to context loss / auto-compaction by content.
559/// These are regular user/assistant messages but carry compaction metadata.
560fn 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    /// Helper: create a simple linear session (no branches)
577    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    /// Helper: create a branched session with progress nodes
588    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        // Common: progress(p1) -> user(a1) -> assistant(a2)
592        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        // Branch A: system(a3) -> user(a4) -> assistant(a5)
596        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        // Branch B: system(b3) -> user(b4) -> assistant(b5)
600        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        // Should have 6 displayable messages: a1, a2, a4, a5, b4, b5
654        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        // a2 has two displayable children (a4 via a3, and b4 via b3) so it's a branch point
664        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        // b5 is last line, so latest chain includes: b5, b4, b3, a2, a1, p1
677        // In display rows, b4 and b5 should be on latest chain
678        // a4 and a5 should NOT be on latest chain
679
680        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        // Common ancestors
692        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        // After common messages (Hello, Hi), the latest chain branch should come first
703        // Row 0: Hello (common)
704        // Row 1: Hi (common)
705        // Then latest chain branch (B) should come before branch A
706
707        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        // Should have 5 rows: u1, u2, summary, u3, u4
748        assert_eq!(tree.rows.len(), 5);
749
750        // Find the compaction row
751        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        // Non-compaction rows should not be marked
756        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        // Summary without uuid — should be skipped entirely (no uuid = not in DAG)
767        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        // Only the user message should be visible
775        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}