1use 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#[derive(Debug, Clone)]
22pub struct DepGraphState {
23 pub focus_id: Option<String>,
25 pub max_depth: usize,
27 pub row_offset: usize,
29 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
71pub 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 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 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 let tree_text = graph.render_ascii_tree(focus_id, state.max_depth);
125 for raw_line in tree_text.lines() {
126 let line = build_annotated_line(raw_line, &upstream_ids, &downstream_ids, theme);
128 lines.push(line);
129 }
130
131 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 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
190fn 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 let trimmed = raw_line.trim_start_matches(' ');
199 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
213pub 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
233pub 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 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"); 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 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}