Skip to main content

saorsa_agent/session/
tree.rs

1//! Session tree visualization and hierarchy management.
2
3use crate::SaorsaAgentError;
4use crate::session::{SessionId, SessionMetadata, SessionNode, SessionStorage};
5use chrono::{DateTime, Utc};
6use std::collections::HashMap;
7use std::str::FromStr;
8
9/// A node in the session tree with metadata for rendering.
10#[derive(Debug, Clone)]
11pub struct TreeNode {
12    /// Session ID
13    pub id: SessionId,
14    /// Session metadata
15    pub metadata: SessionMetadata,
16    /// Tree structure
17    pub node: SessionNode,
18    /// Child tree nodes
19    pub children: Vec<TreeNode>,
20    /// Message count for this session
21    pub message_count: usize,
22}
23
24/// Options for tree rendering.
25#[derive(Debug, Clone, Default)]
26pub struct TreeRenderOptions {
27    /// Highlight this session ID
28    pub highlight_id: Option<SessionId>,
29    /// Filter by minimum date
30    pub after_date: Option<DateTime<Utc>>,
31    /// Filter by maximum date
32    pub before_date: Option<DateTime<Utc>>,
33    /// Filter by tags
34    pub tags: Vec<String>,
35}
36
37/// Build a tree of all sessions from storage.
38pub fn build_session_tree(storage: &SessionStorage) -> Result<Vec<TreeNode>, SaorsaAgentError> {
39    // Load all sessions
40    let sessions = list_all_sessions_with_metadata(storage)?;
41
42    if sessions.is_empty() {
43        return Ok(Vec::new());
44    }
45
46    // Build a map of session ID -> (metadata, node, message_count)
47    let mut session_map: HashMap<SessionId, (SessionMetadata, SessionNode, usize)> = HashMap::new();
48
49    for (id, metadata) in sessions {
50        let node = storage
51            .load_tree(&id)
52            .unwrap_or_else(|_| SessionNode::new_root(id));
53
54        let message_count = storage.load_messages(&id).map(|m| m.len()).unwrap_or(0);
55
56        session_map.insert(id, (metadata, node, message_count));
57    }
58
59    // Find root nodes (no parent)
60    let roots: Vec<SessionId> = session_map
61        .iter()
62        .filter(|(_, (_, node, _))| node.is_root())
63        .map(|(id, _)| *id)
64        .collect();
65
66    // Build tree recursively
67    let mut tree_nodes = Vec::new();
68    for root_id in roots {
69        if let Some(tree_node) = build_tree_node_recursive(root_id, &session_map) {
70            tree_nodes.push(tree_node);
71        }
72    }
73
74    Ok(tree_nodes)
75}
76
77/// Recursively build a tree node and its children.
78fn build_tree_node_recursive(
79    id: SessionId,
80    session_map: &HashMap<SessionId, (SessionMetadata, SessionNode, usize)>,
81) -> Option<TreeNode> {
82    let (metadata, node, message_count) = session_map.get(&id)?.clone();
83
84    let mut children = Vec::new();
85    for child_id in &node.child_ids {
86        if let Some(child_node) = build_tree_node_recursive(*child_id, session_map) {
87            children.push(child_node);
88        }
89    }
90
91    Some(TreeNode {
92        id,
93        metadata,
94        node,
95        children,
96        message_count,
97    })
98}
99
100/// Render the session tree as ASCII art.
101pub fn render_tree(
102    nodes: &[TreeNode],
103    options: &TreeRenderOptions,
104) -> Result<String, SaorsaAgentError> {
105    if nodes.is_empty() {
106        return Ok("No sessions found. Start a conversation to create one.".to_string());
107    }
108
109    let mut output = String::new();
110    output.push_str("Session Tree\n");
111    output.push_str("────────────\n\n");
112
113    for (i, node) in nodes.iter().enumerate() {
114        let is_last = i == nodes.len() - 1;
115        render_node_recursive(node, "", is_last, options, &mut output);
116    }
117
118    Ok(output)
119}
120
121/// Recursively render a tree node with proper ASCII art.
122fn render_node_recursive(
123    node: &TreeNode,
124    prefix: &str,
125    is_last: bool,
126    options: &TreeRenderOptions,
127    output: &mut String,
128) {
129    // Apply filters
130    if let Some(after) = options.after_date
131        && node.metadata.last_active < after
132    {
133        return;
134    }
135
136    if let Some(before) = options.before_date
137        && node.metadata.last_active > before
138    {
139        return;
140    }
141
142    if !options.tags.is_empty() {
143        let has_tag = options
144            .tags
145            .iter()
146            .any(|tag| node.metadata.tags.contains(tag));
147        if !has_tag {
148            return;
149        }
150    }
151
152    // Draw the current node
153    let connector = if is_last { "└──" } else { "├──" };
154
155    let highlight = if let Some(highlight_id) = options.highlight_id {
156        if highlight_id == node.id { "➤ " } else { "" }
157    } else {
158        ""
159    };
160
161    let title = node.metadata.title.as_deref().unwrap_or("(untitled)");
162
163    let last_active = node.metadata.last_active.format("%Y-%m-%d %H:%M");
164
165    output.push_str(&format!(
166        "{}{} {}{} │ {} │ {} msgs │ {}\n",
167        prefix,
168        connector,
169        highlight,
170        node.id.prefix(),
171        title,
172        node.message_count,
173        last_active
174    ));
175
176    // Draw children
177    let child_prefix = if is_last {
178        format!("{}    ", prefix)
179    } else {
180        format!("{}│   ", prefix)
181    };
182
183    for (i, child) in node.children.iter().enumerate() {
184        let child_is_last = i == node.children.len() - 1;
185        render_node_recursive(child, &child_prefix, child_is_last, options, output);
186    }
187}
188
189/// Find a specific session in the tree by ID.
190pub fn find_in_tree(nodes: &[TreeNode], target_id: SessionId) -> Option<TreeNode> {
191    for node in nodes {
192        if node.id == target_id {
193            return Some(node.clone());
194        }
195
196        if let Some(found) = find_in_tree(&node.children, target_id) {
197            return Some(found);
198        }
199    }
200
201    None
202}
203
204/// List all sessions with metadata (helper for tree building).
205fn list_all_sessions_with_metadata(
206    storage: &SessionStorage,
207) -> Result<Vec<(SessionId, SessionMetadata)>, SaorsaAgentError> {
208    let base_path = storage.base_path();
209
210    if !base_path.exists() {
211        return Ok(Vec::new());
212    }
213
214    let entries = std::fs::read_dir(base_path).map_err(|e| {
215        SaorsaAgentError::Session(format!("Failed to read sessions directory: {}", e))
216    })?;
217
218    let mut sessions = Vec::new();
219
220    for entry in entries {
221        let entry = entry.map_err(|e| {
222            SaorsaAgentError::Session(format!("Failed to read directory entry: {}", e))
223        })?;
224
225        let path = entry.path();
226        if path.is_dir()
227            && let Some(dir_name) = path.file_name().and_then(|s| s.to_str())
228            && let Ok(session_id) = SessionId::from_str(dir_name)
229            && let Ok(metadata) = storage.load_manifest(&session_id)
230        {
231            sessions.push((session_id, metadata));
232        }
233    }
234
235    Ok(sessions)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use tempfile::TempDir;
242
243    fn test_storage() -> (TempDir, SessionStorage) {
244        let temp_dir = match TempDir::new() {
245            Ok(dir) => dir,
246            Err(_) => panic!("Failed to create temp dir for test"),
247        };
248        let storage = SessionStorage::with_base_path(temp_dir.path().to_path_buf());
249        (temp_dir, storage)
250    }
251
252    #[test]
253    fn test_empty_tree() {
254        let (_temp, storage) = test_storage();
255        let tree = build_session_tree(&storage);
256        assert!(tree.is_ok());
257        match tree {
258            Ok(nodes) => assert!(nodes.is_empty()),
259            Err(_) => unreachable!(),
260        }
261    }
262
263    #[test]
264    fn test_single_session_tree() {
265        let (_temp, storage) = test_storage();
266
267        let id = SessionId::new();
268        let metadata = SessionMetadata::new();
269        let node = SessionNode::new_root(id);
270
271        assert!(storage.save_manifest(&id, &metadata).is_ok());
272        assert!(storage.save_tree(&id, &node).is_ok());
273
274        let tree = build_session_tree(&storage);
275        assert!(tree.is_ok());
276        match tree {
277            Ok(nodes) => {
278                assert!(nodes.len() == 1);
279                assert!(nodes[0].id == id);
280            }
281            Err(_) => unreachable!(),
282        }
283    }
284
285    #[test]
286    fn test_render_empty_tree() {
287        let nodes = Vec::new();
288        let options = TreeRenderOptions::default();
289        let result = render_tree(&nodes, &options);
290        assert!(result.is_ok());
291        match result {
292            Ok(output) => {
293                assert!(output.contains("No sessions found"));
294            }
295            Err(_) => unreachable!(),
296        }
297    }
298
299    #[test]
300    fn test_render_single_node() {
301        let id = SessionId::new();
302        let mut metadata = SessionMetadata::new();
303        metadata.title = Some("Test Session".to_string());
304        let node = SessionNode::new_root(id);
305
306        let tree_node = TreeNode {
307            id,
308            metadata,
309            node,
310            children: Vec::new(),
311            message_count: 5,
312        };
313
314        let options = TreeRenderOptions::default();
315        let result = render_tree(&[tree_node], &options);
316        assert!(result.is_ok());
317        match result {
318            Ok(output) => {
319                assert!(output.contains("Test Session"));
320                assert!(output.contains("5 msgs"));
321                assert!(output.contains(&id.prefix()));
322            }
323            Err(_) => unreachable!(),
324        }
325    }
326
327    #[test]
328    fn test_render_with_highlight() {
329        let id = SessionId::new();
330        let metadata = SessionMetadata::new();
331        let node = SessionNode::new_root(id);
332
333        let tree_node = TreeNode {
334            id,
335            metadata,
336            node,
337            children: Vec::new(),
338            message_count: 0,
339        };
340
341        let options = TreeRenderOptions {
342            highlight_id: Some(id),
343            ..Default::default()
344        };
345
346        let result = render_tree(&[tree_node], &options);
347        assert!(result.is_ok());
348        match result {
349            Ok(output) => {
350                assert!(output.contains("➤"));
351            }
352            Err(_) => unreachable!(),
353        }
354    }
355
356    #[test]
357    fn test_render_multi_level_tree() {
358        let root_id = SessionId::new();
359        let child_id = SessionId::new();
360
361        let root_meta = SessionMetadata::new();
362        let mut child_meta = SessionMetadata::new();
363        child_meta.title = Some("Child Session".to_string());
364
365        let mut root_node = SessionNode::new_root(root_id);
366        root_node.add_child(child_id);
367        let child_node = SessionNode::new_child(child_id, root_id);
368
369        let child_tree_node = TreeNode {
370            id: child_id,
371            metadata: child_meta,
372            node: child_node,
373            children: Vec::new(),
374            message_count: 3,
375        };
376
377        let root_tree_node = TreeNode {
378            id: root_id,
379            metadata: root_meta,
380            node: root_node,
381            children: vec![child_tree_node],
382            message_count: 2,
383        };
384
385        let options = TreeRenderOptions::default();
386        let result = render_tree(&[root_tree_node], &options);
387        assert!(result.is_ok());
388        match result {
389            Ok(output) => {
390                assert!(output.contains("Child Session"));
391                assert!(output.contains("│"));
392            }
393            Err(_) => unreachable!(),
394        }
395    }
396
397    #[test]
398    fn test_filter_by_date() {
399        let id = SessionId::new();
400        let mut metadata = SessionMetadata::new();
401        metadata.last_active = Utc::now();
402        let node = SessionNode::new_root(id);
403
404        let tree_node = TreeNode {
405            id,
406            metadata,
407            node,
408            children: Vec::new(),
409            message_count: 0,
410        };
411
412        // Filter to future date - should not show
413        let options = TreeRenderOptions {
414            after_date: Some(Utc::now() + chrono::Duration::hours(1)),
415            ..Default::default()
416        };
417
418        let result = render_tree(std::slice::from_ref(&tree_node), &options);
419        assert!(result.is_ok());
420        match result {
421            Ok(output) => {
422                // Should only have header, no session
423                assert!(!output.contains(&id.prefix()));
424            }
425            Err(_) => unreachable!(),
426        }
427    }
428
429    #[test]
430    fn test_filter_by_tag() {
431        let id = SessionId::new();
432        let mut metadata = SessionMetadata::new();
433        metadata.add_tag("important".to_string());
434        let node = SessionNode::new_root(id);
435
436        let tree_node = TreeNode {
437            id,
438            metadata,
439            node,
440            children: Vec::new(),
441            message_count: 0,
442        };
443
444        // Filter for non-existent tag
445        let options = TreeRenderOptions {
446            tags: vec!["other".to_string()],
447            ..Default::default()
448        };
449
450        let result = render_tree(std::slice::from_ref(&tree_node), &options);
451        assert!(result.is_ok());
452        match result {
453            Ok(output) => {
454                assert!(!output.contains(&id.prefix()));
455            }
456            Err(_) => unreachable!(),
457        }
458
459        // Filter for matching tag
460        let options2 = TreeRenderOptions {
461            tags: vec!["important".to_string()],
462            ..Default::default()
463        };
464
465        let result2 = render_tree(&[tree_node], &options2);
466        assert!(result2.is_ok());
467        match result2 {
468            Ok(output) => {
469                assert!(output.contains(&id.prefix()));
470            }
471            Err(_) => unreachable!(),
472        }
473    }
474
475    #[test]
476    fn test_find_in_tree() {
477        let root_id = SessionId::new();
478        let child_id = SessionId::new();
479
480        let root_meta = SessionMetadata::new();
481        let child_meta = SessionMetadata::new();
482
483        let mut root_node = SessionNode::new_root(root_id);
484        root_node.add_child(child_id);
485        let child_node = SessionNode::new_child(child_id, root_id);
486
487        let child_tree_node = TreeNode {
488            id: child_id,
489            metadata: child_meta,
490            node: child_node,
491            children: Vec::new(),
492            message_count: 0,
493        };
494
495        let root_tree_node = TreeNode {
496            id: root_id,
497            metadata: root_meta,
498            node: root_node,
499            children: vec![child_tree_node],
500            message_count: 0,
501        };
502
503        // Find child in tree
504        let found = find_in_tree(&[root_tree_node], child_id);
505        assert!(found.is_some());
506        match found {
507            Some(node) => assert!(node.id == child_id),
508            None => unreachable!(),
509        }
510    }
511}