Skip to main content

busbar_sf_agentscript/graph/render/
ascii.rs

1//! ASCII rendering for graph visualization.
2//!
3//! Provides terminal-friendly tree and diagram output for RefGraph structures.
4
5use super::super::{RefGraph, RefNode};
6use petgraph::visit::EdgeRef;
7use std::collections::{HashMap, HashSet};
8
9/// Render the topic flow graph as ASCII art.
10///
11/// Shows how topics connect to each other via transitions, delegations, and routing.
12pub fn render_topic_flow(graph: &RefGraph) -> String {
13    let inner = graph.inner();
14
15    // Collect topic names and edges
16    let mut topics: Vec<String> = vec!["start_agent".to_string()];
17    let mut topic_idx: HashMap<String, usize> = HashMap::new();
18    topic_idx.insert("start_agent".to_string(), 0);
19
20    for name in graph.topic_names() {
21        topic_idx.insert(name.to_string(), topics.len());
22        topics.push(name.to_string());
23    }
24
25    // Collect edges by source
26    let mut edges: HashMap<usize, Vec<(usize, String)>> = HashMap::new();
27
28    for edge in inner.edge_references() {
29        let edge_type = edge.weight().label();
30        if edge_type == "transitions_to" || edge_type == "delegates" || edge_type == "routes" {
31            let source_node = graph.get_node(edge.source());
32            let target_node = graph.get_node(edge.target());
33
34            if let (Some(src), Some(tgt)) = (source_node, target_node) {
35                let src_name = get_topic_name(src);
36                let tgt_name = get_topic_name(tgt);
37
38                if let (Some(&src_id), Some(&tgt_id)) =
39                    (topic_idx.get(&src_name), topic_idx.get(&tgt_name))
40                {
41                    edges
42                        .entry(src_id)
43                        .or_default()
44                        .push((tgt_id, edge_type.to_string()));
45                }
46            }
47        }
48    }
49
50    render_ascii_tree(&topics, &edges)
51}
52
53/// Render the actions graph as ASCII art.
54///
55/// Shows topics with their action definitions and reasoning actions.
56pub fn render_actions_view(graph: &RefGraph) -> String {
57    let inner = graph.inner();
58    let mut labels: Vec<String> = Vec::new();
59    let mut node_map: HashMap<usize, usize> = HashMap::new();
60
61    for idx in inner.node_indices() {
62        if let Some(node) = graph.get_node(idx) {
63            let label = match node {
64                RefNode::StartAgent { .. } => Some("start_agent".to_string()),
65                RefNode::Topic { name, .. } => Some(format!("[{}]", name)),
66                RefNode::ActionDef { name, .. } => Some(name.clone()),
67                RefNode::ReasoningAction { name, target, .. } => {
68                    if let Some(t) = target {
69                        Some(format!("{}→{}", name, t.split("://").last().unwrap_or(t)))
70                    } else {
71                        Some(name.clone())
72                    }
73                }
74                _ => None,
75            };
76
77            if let Some(lbl) = label {
78                node_map.insert(idx.index(), labels.len());
79                labels.push(lbl);
80            }
81        }
82    }
83
84    // Collect edges
85    let mut edges: HashMap<usize, Vec<(usize, String)>> = HashMap::new();
86
87    for edge in inner.edge_references() {
88        let edge_type = edge.weight().label();
89        if edge_type == "invokes" || edge_type == "transitions_to" || edge_type == "delegates" {
90            if let (Some(&src_id), Some(&tgt_id)) =
91                (node_map.get(&edge.source().index()), node_map.get(&edge.target().index()))
92            {
93                edges
94                    .entry(src_id)
95                    .or_default()
96                    .push((tgt_id, edge_type.to_string()));
97            }
98        }
99    }
100
101    render_ascii_tree(&labels, &edges)
102}
103
104/// Render a full structured view of the graph.
105///
106/// Shows a topic-centric view with variables, actions, and transitions.
107pub fn render_full_view(graph: &RefGraph) -> String {
108    let inner = graph.inner();
109    let mut output = String::new();
110
111    struct TopicInfo {
112        actions: Vec<String>,
113        reasoning: Vec<String>,
114        transitions: Vec<String>,
115        delegates: Vec<String>,
116    }
117
118    let mut topics: HashMap<String, TopicInfo> = HashMap::new();
119    let mut start_routes: Vec<String> = Vec::new();
120    let mut variables: Vec<(String, bool)> = Vec::new();
121
122    // First pass: collect all nodes
123    for idx in inner.node_indices() {
124        if let Some(node) = graph.get_node(idx) {
125            match node {
126                RefNode::Variable { name, mutable, .. } => {
127                    variables.push((name.clone(), *mutable));
128                }
129                RefNode::Topic { name, .. } => {
130                    topics.entry(name.clone()).or_insert(TopicInfo {
131                        actions: Vec::new(),
132                        reasoning: Vec::new(),
133                        transitions: Vec::new(),
134                        delegates: Vec::new(),
135                    });
136                }
137                RefNode::ActionDef { name, topic, .. } => {
138                    if let Some(t) = topics.get_mut(topic) {
139                        t.actions.push(name.clone());
140                    }
141                }
142                RefNode::ReasoningAction {
143                    name,
144                    topic,
145                    target,
146                    ..
147                } => {
148                    if let Some(t) = topics.get_mut(topic) {
149                        let desc = if let Some(tgt) = target {
150                            format!("{} → {}", name, tgt.split("://").last().unwrap_or(tgt))
151                        } else {
152                            name.clone()
153                        };
154                        t.reasoning.push(desc);
155                    }
156                }
157                _ => {}
158            }
159        }
160    }
161
162    // Second pass: collect edges for transitions
163    for edge in inner.edge_references() {
164        let edge_type = edge.weight().label();
165        let source_node = graph.get_node(edge.source());
166        let target_node = graph.get_node(edge.target());
167
168        if let (Some(src), Some(tgt)) = (source_node, target_node) {
169            match (src, tgt, edge_type) {
170                (RefNode::StartAgent { .. }, RefNode::Topic { name, .. }, "routes") => {
171                    start_routes.push(name.clone());
172                }
173                (
174                    RefNode::Topic { name: src_name, .. },
175                    RefNode::Topic { name: tgt_name, .. },
176                    "transitions_to",
177                ) => {
178                    if let Some(t) = topics.get_mut(src_name) {
179                        if !t.transitions.contains(tgt_name) {
180                            t.transitions.push(tgt_name.clone());
181                        }
182                    }
183                }
184                (
185                    RefNode::Topic { name: src_name, .. },
186                    RefNode::Topic { name: tgt_name, .. },
187                    "delegates",
188                ) => {
189                    if let Some(t) = topics.get_mut(src_name) {
190                        if !t.delegates.contains(tgt_name) {
191                            t.delegates.push(tgt_name.clone());
192                        }
193                    }
194                }
195                _ => {}
196            }
197        }
198    }
199
200    // Render output
201    output.push_str("┌─────────────────────────────────────────────────────────────┐\n");
202    output.push_str("│  AGENT EXECUTION FLOW                                       │\n");
203    output.push_str("└─────────────────────────────────────────────────────────────┘\n\n");
204
205    // Variables summary
206    if !variables.is_empty() {
207        output.push_str("VARIABLES:\n");
208        let mutable: Vec<_> = variables
209            .iter()
210            .filter(|(_, m)| *m)
211            .map(|(n, _)| n.as_str())
212            .collect();
213        let linked: Vec<_> = variables
214            .iter()
215            .filter(|(_, m)| !*m)
216            .map(|(n, _)| n.as_str())
217            .collect();
218        if !mutable.is_empty() {
219            output.push_str(&format!("  Mutable: {}\n", mutable.join(", ")));
220        }
221        if !linked.is_empty() {
222            output.push_str(&format!("  Linked:  {}\n", linked.join(", ")));
223        }
224        output.push('\n');
225    }
226
227    // Entry point
228    output.push_str("ENTRY POINT:\n");
229    output.push_str("  start_agent\n");
230    if !start_routes.is_empty() {
231        output.push_str(&format!("    routes to: {}\n", start_routes.join(", ")));
232    }
233    output.push('\n');
234
235    // Topics
236    output.push_str("TOPICS:\n");
237    for (name, info) in &topics {
238        output.push_str(&format!("\n  ┌─ {} ─────────────────────────────\n", name));
239
240        if !info.actions.is_empty() {
241            output.push_str("  │ Actions:\n");
242            for action in &info.actions {
243                output.push_str(&format!("  │   • {}\n", action));
244            }
245        }
246
247        if !info.reasoning.is_empty() {
248            output.push_str("  │ Reasoning:\n");
249            for r in &info.reasoning {
250                output.push_str(&format!("  │   ◆ {}\n", r));
251            }
252        }
253
254        if !info.transitions.is_empty() {
255            output.push_str(&format!("  │ Transitions → {}\n", info.transitions.join(", ")));
256        }
257
258        if !info.delegates.is_empty() {
259            output.push_str(&format!("  │ Delegates ⇒ {}\n", info.delegates.join(", ")));
260        }
261
262        output.push_str("  └────────────────────────────────────────\n");
263    }
264
265    output
266}
267
268/// Render nodes and edges as an ASCII tree structure.
269pub fn render_ascii_tree(
270    labels: &[String],
271    edges: &HashMap<usize, Vec<(usize, String)>>,
272) -> String {
273    let mut output = String::new();
274    let mut visited: HashSet<usize> = HashSet::new();
275
276    // Find root nodes (nodes with no incoming edges)
277    let mut has_incoming: HashSet<usize> = HashSet::new();
278    for targets in edges.values() {
279        for (target, _) in targets {
280            has_incoming.insert(*target);
281        }
282    }
283
284    let roots: Vec<usize> = (0..labels.len())
285        .filter(|i| !has_incoming.contains(i))
286        .collect();
287
288    // If no roots found (everything has incoming), start from node 0
289    let start_nodes = if roots.is_empty() { vec![0] } else { roots };
290
291    for (i, &root) in start_nodes.iter().enumerate() {
292        if i > 0 {
293            output.push('\n');
294        }
295        render_node(&mut output, labels, edges, root, "", true, &mut visited);
296    }
297
298    output
299}
300
301fn render_node(
302    output: &mut String,
303    labels: &[String],
304    edges: &HashMap<usize, Vec<(usize, String)>>,
305    node: usize,
306    prefix: &str,
307    is_last: bool,
308    visited: &mut HashSet<usize>,
309) {
310    let connector = if prefix.is_empty() {
311        ""
312    } else if is_last {
313        "└── "
314    } else {
315        "├── "
316    };
317
318    let label = labels.get(node).map(|s| s.as_str()).unwrap_or("?");
319
320    // Check if this is a back-edge (cycle)
321    if visited.contains(&node) {
322        output.push_str(prefix);
323        output.push_str(connector);
324        output.push_str(label);
325        output.push_str(" ↩\n");
326        return;
327    }
328
329    output.push_str(prefix);
330    output.push_str(connector);
331    output.push_str(label);
332    output.push('\n');
333
334    visited.insert(node);
335
336    if let Some(children) = edges.get(&node) {
337        let new_prefix = if prefix.is_empty() {
338            "".to_string()
339        } else if is_last {
340            format!("{}    ", prefix)
341        } else {
342            format!("{}│   ", prefix)
343        };
344
345        for (i, (child, edge_type)) in children.iter().enumerate() {
346            let child_is_last = i == children.len() - 1;
347
348            // Show edge type for non-trivial edges
349            let edge_indicator = match edge_type.as_str() {
350                "transitions_to" => "→ ",
351                "delegates" => "⇒ ",
352                "routes" => "⊳ ",
353                "invokes" => "◆ ",
354                "reads" => "◇ ",
355                "writes" => "◈ ",
356                _ => "",
357            };
358
359            if !edge_indicator.is_empty() {
360                output.push_str(&new_prefix);
361                output.push_str(if child_is_last { "└" } else { "├" });
362                output.push_str(edge_indicator);
363
364                let child_label = labels.get(*child).map(|s| s.as_str()).unwrap_or("?");
365                if visited.contains(child) {
366                    output.push_str(child_label);
367                    output.push_str(" ↩\n");
368                } else {
369                    output.push_str(child_label);
370                    output.push('\n');
371
372                    // Recurse for this child's children
373                    let deeper_prefix = if child_is_last {
374                        format!("{}    ", new_prefix)
375                    } else {
376                        format!("{}│   ", new_prefix)
377                    };
378
379                    visited.insert(*child);
380                    if let Some(grandchildren) = edges.get(child) {
381                        for (j, (grandchild, _gc_edge)) in grandchildren.iter().enumerate() {
382                            let gc_is_last = j == grandchildren.len() - 1;
383                            render_node(
384                                output,
385                                labels,
386                                edges,
387                                *grandchild,
388                                &deeper_prefix,
389                                gc_is_last,
390                                visited,
391                            );
392                        }
393                    }
394                }
395            } else {
396                render_node(output, labels, edges, *child, &new_prefix, child_is_last, visited);
397            }
398        }
399    }
400}
401
402fn get_topic_name(node: &RefNode) -> String {
403    match node {
404        RefNode::StartAgent { .. } => "start_agent".to_string(),
405        RefNode::Topic { name, .. } => name.clone(),
406        _ => "unknown".to_string(),
407    }
408}