Skip to main content

batuta/tui/graph/
types.rs

1//! Core types for graph visualization
2//!
3//! Contains `NodeShape`, `NodeStatus`, `Position`, `Node`, and `Edge` types.
4
5// ============================================================================
6// GRAPH-001: Core Types
7// ============================================================================
8
9/// Maximum nodes for TUI rendering (Muri prevention per peer review #3)
10pub const MAX_TUI_NODES: usize = 500;
11
12/// Default visible nodes (Mieruka per peer review #9)
13pub const DEFAULT_VISIBLE_NODES: usize = 20;
14
15/// Node shape for accessibility (Respect for People per peer review #6)
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum NodeShape {
18    /// Circle: default node
19    #[default]
20    Circle,
21    /// Diamond: input/source
22    Diamond,
23    /// Square: transform/process
24    Square,
25    /// Triangle: output/sink
26    Triangle,
27    /// Star: highlighted/selected
28    Star,
29}
30
31impl NodeShape {
32    /// Get Unicode character for shape
33    #[must_use]
34    pub fn unicode(&self) -> char {
35        match self {
36            Self::Circle => '●',
37            Self::Diamond => '◆',
38            Self::Square => '■',
39            Self::Triangle => '▲',
40            Self::Star => '★',
41        }
42    }
43
44    /// Get ASCII fallback character (Standardized Work per peer review #5)
45    #[must_use]
46    pub fn ascii(&self) -> char {
47        match self {
48            Self::Circle => 'o',
49            Self::Diamond => '<',
50            Self::Square => '#',
51            Self::Triangle => '^',
52            Self::Star => '*',
53        }
54    }
55}
56
57/// Node status with accessible visual encoding
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum NodeStatus {
60    /// Healthy/success - green + circle
61    #[default]
62    Healthy,
63    /// Warning/pending - yellow + triangle
64    Warning,
65    /// Error/critical - red + diamond (not just red per peer review #6)
66    Error,
67    /// Info/selected - cyan + star
68    Info,
69    /// Neutral/unknown - gray + square
70    Neutral,
71}
72
73impl NodeStatus {
74    /// Get shape for status (accessibility - not just color)
75    #[must_use]
76    pub fn shape(&self) -> NodeShape {
77        match self {
78            Self::Healthy => NodeShape::Circle,
79            Self::Warning => NodeShape::Triangle,
80            Self::Error => NodeShape::Diamond,
81            Self::Info => NodeShape::Star,
82            Self::Neutral => NodeShape::Square,
83        }
84    }
85
86    /// Get ANSI color code
87    #[must_use]
88    pub fn color_code(&self) -> &'static str {
89        match self {
90            Self::Healthy => "\x1b[32m", // Green
91            Self::Warning => "\x1b[33m", // Yellow
92            Self::Error => "\x1b[31m",   // Red
93            Self::Info => "\x1b[36m",    // Cyan
94            Self::Neutral => "\x1b[90m", // Gray
95        }
96    }
97}
98
99/// 2D position for layout
100#[derive(Debug, Clone, Copy, PartialEq, Default)]
101pub struct Position {
102    pub x: f32,
103    pub y: f32,
104}
105
106impl Position {
107    /// Create new position
108    #[must_use]
109    pub fn new(x: f32, y: f32) -> Self {
110        Self { x, y }
111    }
112
113    /// Euclidean distance to another position
114    #[must_use]
115    pub fn distance(&self, other: &Self) -> f32 {
116        let dx = self.x - other.x;
117        let dy = self.y - other.y;
118        (dx * dx + dy * dy).sqrt()
119    }
120}
121
122/// Node in the graph
123#[derive(Debug, Clone)]
124pub struct Node<T> {
125    /// Node identifier
126    pub id: String,
127    /// Node data
128    pub data: T,
129    /// Visual status
130    pub status: NodeStatus,
131    /// Display label
132    pub label: Option<String>,
133    /// Computed position (after layout)
134    pub position: Position,
135    /// Node importance (for Mieruka filtering)
136    pub importance: f32,
137}
138
139impl<T> Node<T> {
140    /// Create new node
141    pub fn new(id: impl Into<String>, data: T) -> Self {
142        Self {
143            id: id.into(),
144            data,
145            status: NodeStatus::default(),
146            label: None,
147            position: Position::default(),
148            importance: 1.0,
149        }
150    }
151
152    /// Set status
153    #[must_use]
154    pub fn with_status(mut self, status: NodeStatus) -> Self {
155        self.status = status;
156        self
157    }
158
159    /// Set label
160    #[must_use]
161    pub fn with_label(mut self, label: impl Into<String>) -> Self {
162        self.label = Some(label.into());
163        self
164    }
165
166    /// Set importance
167    #[must_use]
168    pub fn with_importance(mut self, importance: f32) -> Self {
169        self.importance = importance;
170        self
171    }
172}
173
174/// Edge in the graph
175#[derive(Debug, Clone)]
176pub struct Edge<E> {
177    /// Source node ID
178    pub from: String,
179    /// Target node ID
180    pub to: String,
181    /// Edge data
182    pub data: E,
183    /// Edge weight (for layout algorithms and visualization)
184    pub weight: f32,
185    /// Optional edge label (Neo4j/Cytoscape pattern)
186    pub label: Option<String>,
187}
188
189impl<E> Edge<E> {
190    /// Create new edge
191    pub fn new(from: impl Into<String>, to: impl Into<String>, data: E) -> Self {
192        Self { from: from.into(), to: to.into(), data, weight: 1.0, label: None }
193    }
194
195    /// Set weight
196    #[must_use]
197    pub fn with_weight(mut self, weight: f32) -> Self {
198        self.weight = weight;
199        self
200    }
201
202    /// Set label (Neo4j pattern)
203    #[must_use]
204    pub fn with_label(mut self, label: impl Into<String>) -> Self {
205        self.label = Some(label.into());
206        self
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    // =========================================================================
215    // NodeShape Tests
216    // =========================================================================
217
218    #[test]
219    fn test_node_shape_default() {
220        assert_eq!(NodeShape::default(), NodeShape::Circle);
221    }
222
223    #[test]
224    fn test_node_shape_unicode() {
225        assert_eq!(NodeShape::Circle.unicode(), '●');
226        assert_eq!(NodeShape::Diamond.unicode(), '◆');
227        assert_eq!(NodeShape::Square.unicode(), '■');
228        assert_eq!(NodeShape::Triangle.unicode(), '▲');
229        assert_eq!(NodeShape::Star.unicode(), '★');
230    }
231
232    #[test]
233    fn test_node_shape_ascii() {
234        assert_eq!(NodeShape::Circle.ascii(), 'o');
235        assert_eq!(NodeShape::Diamond.ascii(), '<');
236        assert_eq!(NodeShape::Square.ascii(), '#');
237        assert_eq!(NodeShape::Triangle.ascii(), '^');
238        assert_eq!(NodeShape::Star.ascii(), '*');
239    }
240
241    // =========================================================================
242    // NodeStatus Tests
243    // =========================================================================
244
245    #[test]
246    fn test_node_status_default() {
247        assert_eq!(NodeStatus::default(), NodeStatus::Healthy);
248    }
249
250    #[test]
251    fn test_node_status_shape() {
252        assert_eq!(NodeStatus::Healthy.shape(), NodeShape::Circle);
253        assert_eq!(NodeStatus::Warning.shape(), NodeShape::Triangle);
254        assert_eq!(NodeStatus::Error.shape(), NodeShape::Diamond);
255        assert_eq!(NodeStatus::Info.shape(), NodeShape::Star);
256        assert_eq!(NodeStatus::Neutral.shape(), NodeShape::Square);
257    }
258
259    #[test]
260    fn test_node_status_color_code() {
261        assert!(NodeStatus::Healthy.color_code().contains("32m"));
262        assert!(NodeStatus::Warning.color_code().contains("33m"));
263        assert!(NodeStatus::Error.color_code().contains("31m"));
264        assert!(NodeStatus::Info.color_code().contains("36m"));
265        assert!(NodeStatus::Neutral.color_code().contains("90m"));
266    }
267
268    // =========================================================================
269    // Position Tests
270    // =========================================================================
271
272    #[test]
273    fn test_position_default() {
274        let pos = Position::default();
275        assert_eq!(pos.x, 0.0);
276        assert_eq!(pos.y, 0.0);
277    }
278
279    #[test]
280    fn test_position_new() {
281        let pos = Position::new(10.0, 20.0);
282        assert_eq!(pos.x, 10.0);
283        assert_eq!(pos.y, 20.0);
284    }
285
286    #[test]
287    fn test_position_distance() {
288        let p1 = Position::new(0.0, 0.0);
289        let p2 = Position::new(3.0, 4.0);
290        assert!((p1.distance(&p2) - 5.0).abs() < 0.001);
291    }
292
293    #[test]
294    fn test_position_distance_to_self() {
295        let p = Position::new(5.0, 5.0);
296        assert!((p.distance(&p)).abs() < 0.001);
297    }
298
299    // =========================================================================
300    // Node Tests
301    // =========================================================================
302
303    #[test]
304    fn test_node_new() {
305        let node = Node::new("test", 42);
306        assert_eq!(node.id, "test");
307        assert_eq!(node.data, 42);
308        assert_eq!(node.status, NodeStatus::Healthy);
309        assert!(node.label.is_none());
310        assert_eq!(node.importance, 1.0);
311    }
312
313    #[test]
314    fn test_node_with_status() {
315        let node = Node::new("test", ()).with_status(NodeStatus::Error);
316        assert_eq!(node.status, NodeStatus::Error);
317    }
318
319    #[test]
320    fn test_node_with_label() {
321        let node = Node::new("test", ()).with_label("My Label");
322        assert_eq!(node.label, Some("My Label".to_string()));
323    }
324
325    #[test]
326    fn test_node_with_importance() {
327        let node = Node::new("test", ()).with_importance(0.5);
328        assert_eq!(node.importance, 0.5);
329    }
330
331    #[test]
332    fn test_node_builder_chain() {
333        let node = Node::new("test", "data")
334            .with_status(NodeStatus::Warning)
335            .with_label("Label")
336            .with_importance(0.75);
337        assert_eq!(node.status, NodeStatus::Warning);
338        assert_eq!(node.label, Some("Label".to_string()));
339        assert_eq!(node.importance, 0.75);
340    }
341
342    // =========================================================================
343    // Edge Tests
344    // =========================================================================
345
346    #[test]
347    fn test_edge_new() {
348        let edge = Edge::new("A", "B", 100);
349        assert_eq!(edge.from, "A");
350        assert_eq!(edge.to, "B");
351        assert_eq!(edge.data, 100);
352        assert_eq!(edge.weight, 1.0);
353        assert!(edge.label.is_none());
354    }
355
356    #[test]
357    fn test_edge_with_weight() {
358        let edge = Edge::new("A", "B", ()).with_weight(2.5);
359        assert_eq!(edge.weight, 2.5);
360    }
361
362    #[test]
363    fn test_edge_with_label() {
364        let edge = Edge::new("A", "B", ()).with_label("depends_on");
365        assert_eq!(edge.label, Some("depends_on".to_string()));
366    }
367
368    #[test]
369    fn test_edge_builder_chain() {
370        let edge =
371            Edge::new("source", "target", "edge_data").with_weight(3.0).with_label("relation");
372        assert_eq!(edge.from, "source");
373        assert_eq!(edge.to, "target");
374        assert_eq!(edge.weight, 3.0);
375        assert_eq!(edge.label, Some("relation".to_string()));
376    }
377
378    // =========================================================================
379    // Constants Tests
380    // =========================================================================
381
382    #[test]
383    fn test_constants() {
384        assert_eq!(MAX_TUI_NODES, 500);
385        assert_eq!(DEFAULT_VISIBLE_NODES, 20);
386    }
387}