Skip to main content

vfa_tui/ui/widgets/
dep_graph.rs

1/// Dependency graph ASCII tree widget.
2///
3/// Req 5.4: ASCII art dependency tree with expandable/collapsible nodes,
4///          upstream/downstream highlight.
5///
6/// Leverages `DependencyGraph::render_ascii_tree` for content generation
7/// and renders it into a scrollable panel.
8use ratatui::{
9    buffer::Buffer,
10    layout::Rect,
11    style::{Color, Modifier, Style},
12    text::{Line, Span},
13    widgets::{Block, Borders, Paragraph, Widget},
14    Frame,
15};
16
17use crate::federation::dep_graph::DependencyGraph;
18use crate::ui::theme::Theme;
19
20/// State for the dependency graph viewer.
21#[derive(Debug, Clone)]
22pub struct DepGraphState {
23    /// Node currently focused (highlight upstream/downstream).
24    pub focus_id: Option<String>,
25    /// Maximum render depth for the ASCII tree.
26    pub max_depth: usize,
27    /// Scroll offset (first visible line).
28    pub row_offset: usize,
29    /// Set of node IDs that are "collapsed" (children hidden).
30    pub collapsed: std::collections::HashSet<String>,
31}
32
33impl Default for DepGraphState {
34    fn default() -> Self {
35        Self {
36            focus_id: None,
37            max_depth: 5,
38            row_offset: 0,
39            collapsed: std::collections::HashSet::new(),
40        }
41    }
42}
43
44impl DepGraphState {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    pub fn scroll_down(&mut self, n: usize) {
50        self.row_offset = self.row_offset.saturating_add(n);
51    }
52
53    pub fn scroll_up(&mut self, n: usize) {
54        self.row_offset = self.row_offset.saturating_sub(n);
55    }
56
57    pub fn toggle_collapse(&mut self, node_id: &str) {
58        if self.collapsed.contains(node_id) {
59            self.collapsed.remove(node_id);
60        } else {
61            self.collapsed.insert(node_id.to_string());
62        }
63    }
64
65    pub fn set_focus(&mut self, node_id: Option<String>) {
66        self.focus_id = node_id;
67        self.row_offset = 0;
68    }
69}
70
71/// Build the rendered lines for the dependency graph viewer.
72///
73/// If `state.focus_id` is `Some(id)`, renders the tree rooted at that ID and
74/// highlights upstream/downstream nodes.
75/// Otherwise renders an overview listing all root-level nodes (nodes with no
76/// upstream parents).
77pub fn build_dep_graph_lines<'a>(
78    graph: &'a DependencyGraph,
79    state: &DepGraphState,
80    theme: &Theme,
81) -> Vec<Line<'a>> {
82    let mut lines: Vec<Line<'a>> = Vec::new();
83
84    // Determine upstream/downstream sets for highlighting
85    let (upstream_ids, downstream_ids) = if let Some(ref focus) = state.focus_id {
86        let up: std::collections::HashSet<String> = graph.upstream_ids(focus).into_iter().collect();
87        let down: std::collections::HashSet<String> =
88            graph.downstream_ids(focus).into_iter().collect();
89        (up, down)
90    } else {
91        (
92            std::collections::HashSet::new(),
93            std::collections::HashSet::new(),
94        )
95    };
96
97    // ── header ──────────────────────────────────────────────────────────────
98    let header_text = if let Some(ref focus) = state.focus_id {
99        format!(
100            "Dep Graph: {} (depth ≤{})  ↑=upstream ↓=downstream",
101            focus, state.max_depth
102        )
103    } else {
104        format!(
105            "Dependency Graph ({} nodes, {} edges)",
106            graph.nodes.len(),
107            graph.edges.len()
108        )
109    };
110
111    lines.push(Line::from(vec![Span::styled(
112        header_text,
113        Style::default().add_modifier(Modifier::BOLD),
114    )]));
115    lines.push(Line::from(vec![Span::raw("")]));
116
117    if graph.nodes.is_empty() {
118        lines.push(Line::from(vec![Span::raw("  (no nodes in graph)")]));
119        return lines.into_iter().skip(state.row_offset).collect();
120    }
121
122    if let Some(ref focus_id) = state.focus_id {
123        // Render ASCII tree rooted at focused node
124        let tree_text = graph.render_ascii_tree(focus_id, state.max_depth);
125        for raw_line in tree_text.lines() {
126            // Highlight upstream/downstream nodes by scanning for known IDs
127            let line = build_annotated_line(raw_line, &upstream_ids, &downstream_ids, theme);
128            lines.push(line);
129        }
130
131        // Legend
132        lines.push(Line::from(vec![Span::raw("")]));
133        if !upstream_ids.is_empty() {
134            let up_list: Vec<&str> = upstream_ids.iter().map(|s| s.as_str()).collect();
135            lines.push(Line::from(vec![
136                Span::raw("  ↑ upstream: "),
137                Span::styled(up_list.join(", "), upstream_style(theme)),
138            ]));
139        }
140        if !downstream_ids.is_empty() {
141            let down_list: Vec<&str> = downstream_ids.iter().map(|s| s.as_str()).collect();
142            lines.push(Line::from(vec![
143                Span::raw("  ↓ downstream: "),
144                Span::styled(down_list.join(", "), downstream_style(theme)),
145            ]));
146        }
147    } else {
148        // Overview: list all nodes with their type and edge counts
149        let mut sorted_ids: Vec<&str> = graph.nodes.keys().map(|s| s.as_str()).collect();
150        sorted_ids.sort_unstable();
151
152        for node_id in sorted_ids {
153            let up_count = graph.upstream_ids(node_id).len();
154            let down_count = graph.downstream_ids(node_id).len();
155            let collapsed_marker = if state.collapsed.contains(node_id) {
156                " [+]"
157            } else {
158                ""
159            };
160            lines.push(Line::from(vec![
161                Span::raw("  "),
162                Span::styled(
163                    node_id.to_string(),
164                    Style::default().add_modifier(Modifier::BOLD),
165                ),
166                Span::raw(format!("  ↑{up_count} ↓{down_count}{collapsed_marker}")),
167            ]));
168        }
169    }
170
171    lines.into_iter().skip(state.row_offset).collect()
172}
173
174fn upstream_style(theme: &Theme) -> Style {
175    if theme.no_color {
176        Style::default().add_modifier(Modifier::UNDERLINED)
177    } else {
178        Style::default().fg(Color::Cyan)
179    }
180}
181
182fn downstream_style(theme: &Theme) -> Style {
183    if theme.no_color {
184        Style::default().add_modifier(Modifier::BOLD)
185    } else {
186        Style::default().fg(Color::Yellow)
187    }
188}
189
190/// Annotate a single ASCII tree line with upstream/downstream highlight spans.
191fn build_annotated_line<'a>(
192    raw_line: &str,
193    upstream_ids: &std::collections::HashSet<String>,
194    downstream_ids: &std::collections::HashSet<String>,
195    theme: &Theme,
196) -> Line<'a> {
197    // Find the node ID embedded in the line (strip leading whitespace/connectors)
198    let trimmed = raw_line.trim_start_matches(' ');
199    // Remove any [cycle] suffix for matching
200    let id_candidate = trimmed.trim_end_matches(" [cycle]");
201
202    let style = if upstream_ids.contains(id_candidate) {
203        upstream_style(theme)
204    } else if downstream_ids.contains(id_candidate) {
205        downstream_style(theme)
206    } else {
207        Style::default()
208    };
209
210    Line::from(vec![Span::styled(raw_line.to_string(), style)])
211}
212
213/// Render the dependency graph into a [`Frame`].
214pub fn render_dep_graph(
215    graph: &DependencyGraph,
216    state: &DepGraphState,
217    area: Rect,
218    frame: &mut Frame,
219    theme: &Theme,
220) {
221    let lines = build_dep_graph_lines(graph, state, theme);
222    let paragraph = Paragraph::new(lines)
223        .block(
224            Block::default()
225                .borders(Borders::ALL)
226                .title("Dependencies")
227                .border_style(theme.border_style()),
228        )
229        .scroll((0, 0));
230    frame.render_widget(paragraph, area);
231}
232
233/// Render the dependency graph into a [`Buffer`] (useful for testing).
234pub fn render_dep_graph_buffer(
235    graph: &DependencyGraph,
236    state: &DepGraphState,
237    area: Rect,
238    buf: &mut Buffer,
239    theme: &Theme,
240) {
241    let lines = build_dep_graph_lines(graph, state, theme);
242    let paragraph = Paragraph::new(lines).block(
243        Block::default()
244            .borders(Borders::ALL)
245            .title("Dependencies")
246            .border_style(theme.border_style()),
247    );
248    Widget::render(paragraph, area, buf);
249}
250
251#[cfg(test)]
252fn buf_content(buf: &Buffer) -> String {
253    buf.content
254        .iter()
255        .map(|c| c.symbol().chars().next().unwrap_or(' '))
256        .collect()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::federation::dep_graph::{
263        AssetType, DependencyEdge, DependencyGraph, DependencyNode, EdgeType,
264    };
265    use crate::ui::theme::{ColorSupport, Theme};
266    use ratatui::backend::TestBackend;
267    use ratatui::buffer::Buffer;
268    use ratatui::layout::Rect;
269    use ratatui::Terminal;
270    use std::collections::HashMap;
271
272    fn make_graph() -> DependencyGraph {
273        let mut nodes = HashMap::new();
274        nodes.insert(
275            "agent-a".to_string(),
276            DependencyNode {
277                id: "agent-a".to_string(),
278                asset_type: AssetType::Agent,
279            },
280        );
281        nodes.insert(
282            "skill-x".to_string(),
283            DependencyNode {
284                id: "skill-x".to_string(),
285                asset_type: AssetType::Skill,
286            },
287        );
288        nodes.insert(
289            "role-r".to_string(),
290            DependencyNode {
291                id: "role-r".to_string(),
292                asset_type: AssetType::Role,
293            },
294        );
295        let edges = vec![
296            DependencyEdge {
297                from: "agent-a".to_string(),
298                to: "skill-x".to_string(),
299                edge_type: EdgeType::DependsOn,
300            },
301            DependencyEdge {
302                from: "role-r".to_string(),
303                to: "agent-a".to_string(),
304                edge_type: EdgeType::Contains,
305            },
306        ];
307        DependencyGraph { nodes, edges }
308    }
309
310    #[test]
311    fn dep_graph_overview_shows_node_ids() {
312        let graph = make_graph();
313        let state = DepGraphState::new();
314        let theme = Theme::with_color_support(ColorSupport::None);
315        let area = Rect::new(0, 0, 80, 20);
316        let mut buf = Buffer::empty(area);
317        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
318
319        let content = buf_content(&buf);
320        assert!(
321            content.contains("agent-a"),
322            "agent-a should appear in overview"
323        );
324        assert!(
325            content.contains("skill-x"),
326            "skill-x should appear in overview"
327        );
328        assert!(
329            content.contains("role-r"),
330            "role-r should appear in overview"
331        );
332    }
333
334    #[test]
335    fn dep_graph_focused_shows_tree() {
336        let graph = make_graph();
337        let mut state = DepGraphState::new();
338        state.set_focus(Some("agent-a".to_string()));
339        let theme = Theme::with_color_support(ColorSupport::None);
340        let area = Rect::new(0, 0, 80, 20);
341        let mut buf = Buffer::empty(area);
342        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
343
344        let content = buf_content(&buf);
345        assert!(content.contains("agent-a"), "focused node should appear");
346        assert!(
347            content.contains("skill-x"),
348            "child node should appear in tree"
349        );
350    }
351
352    #[test]
353    fn dep_graph_upstream_downstream_annotation() {
354        let graph = make_graph();
355        let mut state = DepGraphState::new();
356        state.set_focus(Some("agent-a".to_string()));
357        let theme = Theme::with_color_support(ColorSupport::None);
358        let area = Rect::new(0, 0, 100, 20);
359        let mut buf = Buffer::empty(area);
360        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
361
362        let content = buf_content(&buf);
363        // upstream of agent-a is skill-x, downstream is role-r
364        assert!(
365            content.contains("upstream"),
366            "upstream annotation should appear"
367        );
368        assert!(
369            content.contains("skill-x"),
370            "upstream node skill-x should appear"
371        );
372    }
373
374    #[test]
375    fn dep_graph_empty_shows_no_nodes_message() {
376        let graph = DependencyGraph {
377            nodes: HashMap::new(),
378            edges: vec![],
379        };
380        let state = DepGraphState::new();
381        let theme = Theme::with_color_support(ColorSupport::None);
382        let area = Rect::new(0, 0, 80, 10);
383        let mut buf = Buffer::empty(area);
384        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
385
386        let content = buf_content(&buf);
387        assert!(content.contains("no nodes"), "empty graph should show hint");
388    }
389
390    #[test]
391    fn dep_graph_collapse_marker_shown() {
392        let graph = make_graph();
393        let mut state = DepGraphState::new();
394        state.toggle_collapse("agent-a");
395        let theme = Theme::with_color_support(ColorSupport::None);
396        let area = Rect::new(0, 0, 80, 20);
397        let mut buf = Buffer::empty(area);
398        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
399
400        let content = buf_content(&buf);
401        assert!(
402            content.contains("[+]"),
403            "collapsed node should show [+] marker"
404        );
405    }
406
407    #[test]
408    fn dep_graph_toggle_collapse_removes_marker() {
409        let graph = make_graph();
410        let mut state = DepGraphState::new();
411        state.toggle_collapse("agent-a");
412        state.toggle_collapse("agent-a"); // toggle back
413        let theme = Theme::with_color_support(ColorSupport::None);
414        let area = Rect::new(0, 0, 80, 20);
415        let mut buf = Buffer::empty(area);
416        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
417
418        let content = buf_content(&buf);
419        assert!(
420            !content.contains("[+]"),
421            "un-collapsed node should not show [+] marker"
422        );
423    }
424
425    #[test]
426    fn dep_graph_via_test_backend() {
427        let graph = make_graph();
428        let state = DepGraphState::new();
429        let theme = Theme::with_color_support(ColorSupport::None);
430
431        let backend = TestBackend::new(80, 20);
432        let mut terminal = Terminal::new(backend).unwrap();
433        terminal
434            .draw(|frame| {
435                render_dep_graph(&graph, &state, frame.area(), frame, &theme);
436            })
437            .unwrap();
438
439        let buf = terminal.backend().buffer().clone();
440        let content = buf_content(&buf);
441        assert!(content.contains("agent-a"));
442    }
443
444    #[test]
445    fn dep_graph_scroll_hides_header() {
446        let graph = make_graph();
447        let mut state = DepGraphState::new();
448        let theme = Theme::with_color_support(ColorSupport::None);
449        let area = Rect::new(0, 0, 100, 20);
450
451        let mut buf = Buffer::empty(area);
452        render_dep_graph_buffer(&graph, &state, area, &mut buf, &theme);
453        let content = buf_content(&buf);
454        assert!(
455            content.contains("Dependency Graph"),
456            "header visible at offset 0"
457        );
458
459        state.scroll_down(2);
460        let mut buf2 = Buffer::empty(area);
461        render_dep_graph_buffer(&graph, &state, area, &mut buf2, &theme);
462        // After scrolling 2 lines the header is gone (it's lines 0 and 1)
463        let row1 = (0..area.width)
464            .map(|col| {
465                buf2.cell((col, 1))
466                    .map(|c| c.symbol().chars().next().unwrap_or(' '))
467                    .unwrap_or(' ')
468            })
469            .collect::<String>();
470        assert!(!row1.contains("Dep"), "header should be scrolled off");
471    }
472}