Skip to main content

oneai_workflow/
render.rs

1//! ASCII visualization for WorkflowDag and StateGraph.
2//!
3//! Provides `render_dag_ascii()` and `render_state_graph_ascii()` functions
4//! that produce human-readable text visualizations of workflow structures.
5//! These are used by the `/wf show` and `/wf graph` CLI commands.
6
7use crate::dag::WorkflowDag;
8use crate::state_graph::{EdgeCondition, NodeAction, StateGraph};
9
10/// Render a WorkflowDag as ASCII text showing parallel levels and step details.
11///
12/// Example output (conceptual):
13/// ```text
14/// Level 0:
15///   ├── read_changes → shell
16/// Level 1:
17///   ├── check_syntax → shell (depends: read_changes)
18///   ├── run_tests → shell (depends: read_changes)
19///   ├── analyze_quality → LLM (depends: read_changes)
20/// Level 2:
21///   ├── compile_report → LLM (depends: check_syntax, run_tests, analyze_quality)
22/// ```
23pub fn render_dag_ascii(dag: &WorkflowDag) -> String {
24    let mut lines = Vec::new();
25
26    for level_idx in 0..dag.levels.len() {
27        let level_ids = &dag.levels[level_idx];
28        lines.push(format!("Level {}:", level_idx));
29        for step_id in level_ids {
30            let node = dag.get_node(step_id);
31            if let Some(node) = node {
32                let tool_info = if let Some(tool) = &node.step.tool {
33                    format!("→ {}", tool)
34                } else if node.step.prompt.is_some() {
35                    "→ LLM".to_string()
36                } else {
37                    "→ (empty)".to_string()
38                };
39                let deps = if node.depends_on.is_empty() {
40                    String::new()
41                } else {
42                    format!(" (depends: {})", node.depends_on.join(", "))
43                };
44                lines.push(format!("  ├── {}{}{}", step_id, tool_info, deps));
45            }
46        }
47    }
48
49    if lines.is_empty() {
50        lines.push("(empty DAG)".to_string());
51    }
52
53    lines.join("\n")
54}
55
56/// Render a StateGraph as ASCII text showing nodes, actions, edges, and conditions.
57///
58/// Example output (conceptual):
59/// ```text
60/// Entry: think ->
61///   think [LLM]
62///     -> act [HasToolCalls]
63///     -> end [IsFinalAnswer]
64///   act [selected_tool]
65///     -> observe
66///   observe [has_more_tool_calls]
67///     -> think [has_more_tool_calls=true]
68///     -> end [has_more_tool_calls=false]
69///   end [LLM]
70/// Terminal: end
71/// ```
72pub fn render_state_graph_ascii(graph: &StateGraph) -> String {
73    let mut lines = Vec::new();
74    lines.push(format!("Entry: {} →", graph.entry_point));
75
76    // Sort node IDs for consistent display
77    let mut node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
78    node_ids.sort();
79
80    for node_id in &node_ids {
81        let node = graph.nodes.get(node_id).unwrap();
82        let action_str = match &node.action {
83            NodeAction::LlmInfer {
84                system_prompt_override,
85                include_tool_definitions,
86                ..
87            } => {
88                let tools_str = if *include_tool_definitions {
89                    " +tools"
90                } else {
91                    ""
92                };
93                if system_prompt_override.is_some() {
94                    format!("🧠 LLM (custom prompt){}", tools_str)
95                } else {
96                    format!("🧠 LLM{}", tools_str)
97                }
98            }
99            NodeAction::ToolCall { tool_name, .. } => format!("🔧 {}", tool_name),
100            NodeAction::Delegate { agent_kind, .. } => format!("🤖 →{}", agent_kind),
101            NodeAction::HumanApproval { description } => format!("✋ {}", description),
102            NodeAction::ConditionCheck { condition } => format!("🔀 {}", condition),
103            NodeAction::SwitchParadigm { paradigm } => format!("🔄 →{}", paradigm),
104        };
105        let interrupt_str = if node.interrupt { " ⏸" } else { "" };
106        lines.push(format!("  {} [{}]{}", node_id, action_str, interrupt_str));
107
108        // Show outgoing edges
109        if let Some(edges) = graph.edges.get(node_id) {
110            for edge in edges {
111                let cond_str: String = match &edge.condition {
112                    Some(EdgeCondition::HasToolCalls) => " [HasToolCalls]".to_string(),
113                    Some(EdgeCondition::IsFinalAnswer) => " [IsFinalAnswer]".to_string(),
114                    Some(EdgeCondition::RequestsDelegation) => " [RequestsDelegation]".to_string(),
115                    Some(EdgeCondition::ErrorOccurred) => " [ErrorOccurred]".to_string(),
116                    Some(EdgeCondition::StateEquals { variable, value }) => {
117                        format!(" [{}={}]", variable, value)
118                    }
119                    Some(EdgeCondition::Always) => String::new(),
120                    Some(EdgeCondition::Custom { name, .. }) => format!(" [Custom:{}]", name),
121                    Some(EdgeCondition::ParadigmEquals { paradigm }) => {
122                        format!(" [Paradigm={}]", paradigm)
123                    }
124                    Some(EdgeCondition::IterationExceeds { count }) => format!(" [Iter>{}]", count),
125                    None => String::new(),
126                };
127                lines.push(format!("    → {}{}", edge.to, cond_str));
128            }
129        }
130    }
131
132    lines.push(format!("Terminal: {}", graph.terminal_nodes.join(", ")));
133
134    // Add cycle info
135    if graph.has_cycles() {
136        lines.push("⚠ Contains cycles (expected for ReAct loops)".to_string());
137    }
138
139    lines.join("\n")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::config::StepConfig;
146    use crate::dag::DagNode;
147    use std::collections::HashMap;
148
149    fn make_step(id: &str, depends_on: Vec<&str>, tool: Option<&str>) -> StepConfig {
150        StepConfig {
151            id: id.to_string(),
152            description: format!("Step {}", id),
153            depends_on: depends_on.iter().map(|s| s.to_string()).collect(),
154            tool: tool.map(|t| t.to_string()),
155            tool_args: None,
156            prompt: None,
157            requires_approval: false,
158            timeout_secs: None,
159            retry_policy: None,
160            metadata: HashMap::new(),
161        }
162    }
163
164    #[test]
165    fn test_render_dag_ascii() {
166        let mut dag = WorkflowDag::new("test");
167        dag.add_node(DagNode {
168            id: "read".to_string(),
169            step: make_step("read", vec![], Some("shell")),
170            depends_on: vec![],
171            children: Vec::new(),
172            level: 0,
173        });
174        dag.add_node(DagNode {
175            id: "analyze".to_string(),
176            step: StepConfig {
177                id: "analyze".to_string(),
178                description: "Analyze".to_string(),
179                depends_on: vec!["read".to_string()],
180                tool: None,
181                tool_args: None,
182                prompt: Some("Analyze code".to_string()),
183                requires_approval: false,
184                timeout_secs: None,
185                retry_policy: None,
186                metadata: HashMap::new(),
187            },
188            depends_on: vec!["read".to_string()],
189            children: Vec::new(),
190            level: 0,
191        });
192        dag.build();
193
194        let viz = render_dag_ascii(&dag);
195        assert!(viz.contains("Level 0:"));
196        assert!(viz.contains("→ shell"));
197        assert!(viz.contains("→ LLM"));
198    }
199
200    #[test]
201    fn test_render_state_graph_ascii() {
202        let mut graph = StateGraph::new("react-loop", "think");
203
204        graph.add_node(crate::state_graph::GraphNode {
205            id: "think".to_string(),
206            action: crate::state_graph::NodeAction::LlmInfer {
207                system_prompt_override: None,
208                use_streaming: true,
209                include_tool_definitions: true,
210                tool_filter_override: None,
211                thinking_budget: None,
212                temperature: None,
213                max_tokens: None,
214            },
215            interrupt: false,
216            metadata: HashMap::new(),
217        });
218
219        graph.add_node(crate::state_graph::GraphNode {
220            id: "end".to_string(),
221            action: crate::state_graph::NodeAction::LlmInfer {
222                system_prompt_override: Some("Final answer".to_string()),
223                use_streaming: false,
224                include_tool_definitions: false,
225                tool_filter_override: None,
226                thinking_budget: None,
227                temperature: None,
228                max_tokens: None,
229            },
230            interrupt: false,
231            metadata: HashMap::new(),
232        });
233
234        graph.add_edge(crate::state_graph::GraphEdge {
235            from: "think".to_string(),
236            to: "end".to_string(),
237            condition: Some(crate::state_graph::EdgeCondition::IsFinalAnswer),
238            metadata: HashMap::new(),
239        });
240
241        graph.add_terminal("end".to_string());
242
243        let viz = render_state_graph_ascii(&graph);
244        assert!(viz.contains("Entry: think"));
245        assert!(viz.contains("🧠 LLM"));
246        assert!(viz.contains("Terminal: end"));
247        assert!(viz.contains("[IsFinalAnswer]"));
248    }
249}