Skip to main content

sbom_tools/tui/widgets/
tree.rs

1//! Hierarchical tree widget for component navigation.
2
3use crate::tui::theme::colors;
4use ratatui::{
5    prelude::*,
6    widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
7};
8use std::collections::HashSet;
9
10/// A node in the component tree.
11#[derive(Debug, Clone)]
12pub enum TreeNode {
13    /// A group node (ecosystem, namespace, etc.)
14    Group {
15        id: String,
16        label: String,
17        children: Vec<TreeNode>,
18        item_count: usize,
19        vuln_count: usize,
20    },
21    /// A leaf component node
22    Component {
23        id: String,
24        name: String,
25        version: Option<String>,
26        vuln_count: usize,
27        /// Maximum severity level of vulnerabilities (critical/high/medium/low)
28        max_severity: Option<String>,
29        /// Component type indicator (library, binary, file, etc.)
30        component_type: Option<String>,
31    },
32}
33
34impl TreeNode {
35    pub fn id(&self) -> &str {
36        match self {
37            TreeNode::Group { id, .. } => id,
38            TreeNode::Component { id, .. } => id,
39        }
40    }
41
42    pub fn label(&self) -> String {
43        match self {
44            TreeNode::Group {
45                label, item_count, ..
46            } => format!("{} ({})", label, item_count),
47            TreeNode::Component { name, version, .. } => {
48                let display_name = extract_display_name(name);
49                if let Some(v) = version {
50                    format!("{}@{}", display_name, v)
51                } else {
52                    display_name
53                }
54            }
55        }
56    }
57
58    /// Get the raw name (full path) for display in details
59    pub fn raw_name(&self) -> Option<&str> {
60        match self {
61            TreeNode::Component { name, .. } => Some(name),
62            _ => None,
63        }
64    }
65
66    pub fn vuln_count(&self) -> usize {
67        match self {
68            TreeNode::Group { vuln_count, .. } => *vuln_count,
69            TreeNode::Component { vuln_count, .. } => *vuln_count,
70        }
71    }
72
73    pub fn max_severity(&self) -> Option<&str> {
74        match self {
75            TreeNode::Component { max_severity, .. } => max_severity.as_deref(),
76            _ => None,
77        }
78    }
79
80    pub fn component_type(&self) -> Option<&str> {
81        match self {
82            TreeNode::Component { component_type, .. } => component_type.as_deref(),
83            _ => None,
84        }
85    }
86
87    pub fn is_group(&self) -> bool {
88        matches!(self, TreeNode::Group { .. })
89    }
90
91    pub fn children(&self) -> Option<&[TreeNode]> {
92        match self {
93            TreeNode::Group { children, .. } => Some(children),
94            TreeNode::Component { .. } => None,
95        }
96    }
97}
98
99/// Extract a meaningful display name from a component path
100pub fn extract_display_name(name: &str) -> String {
101    // If it's a clean package name (no path separators, reasonable length), use it as-is
102    if !name.contains('/') && !name.starts_with('.') && name.len() <= 40 {
103        return name.to_string();
104    }
105
106    // Extract the meaningful part from a path
107    if let Some(filename) = name.rsplit('/').next() {
108        // Clean up common suffixes
109        let clean = filename
110            .trim_end_matches(".squashfs")
111            .trim_end_matches(".squ")
112            .trim_end_matches(".img")
113            .trim_end_matches(".bin")
114            .trim_end_matches(".unknown")
115            .trim_end_matches(".crt")
116            .trim_end_matches(".so")
117            .trim_end_matches(".a")
118            .trim_end_matches(".elf32");
119
120        // If the remaining name is a hash-like string, try to get parent directory context
121        if is_hash_like(clean) {
122            // Try to find a meaningful parent directory
123            let parts: Vec<&str> = name.split('/').collect();
124            if parts.len() >= 2 {
125                // Look for meaningful directory names
126                for part in parts.iter().rev().skip(1) {
127                    if !part.is_empty()
128                        && !part.starts_with('.')
129                        && !is_hash_like(part)
130                        && part.len() > 2
131                    {
132                        return format!("{}/{}", part, truncate_name(filename, 20));
133                    }
134                }
135            }
136            return truncate_name(filename, 25);
137        }
138
139        return clean.to_string();
140    }
141
142    truncate_name(name, 30)
143}
144
145/// Check if a name looks like a hash (hex digits and dashes)
146fn is_hash_like(name: &str) -> bool {
147    if name.len() < 8 {
148        return false;
149    }
150    let clean = name.replace('-', "").replace('_', "");
151    clean.chars().all(|c| c.is_ascii_hexdigit())
152        || (clean.chars().filter(|c| c.is_ascii_digit()).count() > clean.len() / 2)
153}
154
155/// Truncate a name with ellipsis
156fn truncate_name(name: &str, max_len: usize) -> String {
157    if name.len() <= max_len {
158        name.to_string()
159    } else {
160        format!("{}...", &name[..max_len.saturating_sub(3)])
161    }
162}
163
164/// Get component type from path/name
165pub fn detect_component_type(name: &str) -> &'static str {
166    let lower = name.to_lowercase();
167
168    if lower.ends_with(".so") || lower.contains(".so.") {
169        return "lib";
170    }
171    if lower.ends_with(".a") {
172        return "lib";
173    }
174    if lower.ends_with(".crt") || lower.ends_with(".pem") || lower.ends_with(".key") {
175        return "cert";
176    }
177    if lower.ends_with(".img") || lower.ends_with(".bin") || lower.ends_with(".elf")
178        || lower.ends_with(".elf32")
179    {
180        return "bin";
181    }
182    if lower.ends_with(".squashfs") || lower.ends_with(".squ") {
183        return "fs";
184    }
185    if lower.ends_with(".unknown") {
186        return "unk";
187    }
188    if lower.contains("lib") {
189        return "lib";
190    }
191
192    "file"
193}
194
195/// State for the tree widget.
196#[derive(Debug, Clone, Default)]
197pub struct TreeState {
198    /// Currently selected node index in flattened view
199    pub selected: usize,
200    /// Set of expanded node IDs
201    pub expanded: HashSet<String>,
202    /// Scroll offset
203    pub offset: usize,
204    /// Total visible items
205    pub visible_count: usize,
206}
207
208impl TreeState {
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    pub fn toggle_expand(&mut self, node_id: &str) {
214        if self.expanded.contains(node_id) {
215            self.expanded.remove(node_id);
216        } else {
217            self.expanded.insert(node_id.to_string());
218        }
219    }
220
221    pub fn expand(&mut self, node_id: &str) {
222        self.expanded.insert(node_id.to_string());
223    }
224
225    pub fn collapse(&mut self, node_id: &str) {
226        self.expanded.remove(node_id);
227    }
228
229    pub fn is_expanded(&self, node_id: &str) -> bool {
230        self.expanded.contains(node_id)
231    }
232
233    pub fn select_next(&mut self) {
234        if self.visible_count > 0 && self.selected < self.visible_count - 1 {
235            self.selected += 1;
236        }
237    }
238
239    pub fn select_prev(&mut self) {
240        if self.selected > 0 {
241            self.selected -= 1;
242        }
243    }
244
245    pub fn select_first(&mut self) {
246        self.selected = 0;
247    }
248
249    pub fn select_last(&mut self) {
250        if self.visible_count > 0 {
251            self.selected = self.visible_count - 1;
252        }
253    }
254
255    pub fn page_down(&mut self, page_size: usize) {
256        self.selected = (self.selected + page_size).min(self.visible_count.saturating_sub(1));
257    }
258
259    pub fn page_up(&mut self, page_size: usize) {
260        self.selected = self.selected.saturating_sub(page_size);
261    }
262}
263
264/// A flattened tree item for rendering.
265#[derive(Debug, Clone)]
266pub struct FlattenedItem {
267    pub node_id: String,
268    pub label: String,
269    pub depth: usize,
270    pub is_group: bool,
271    pub is_expanded: bool,
272    pub is_last_sibling: bool,
273    pub vuln_count: usize,
274    pub ancestors_last: Vec<bool>,
275    /// Maximum severity for components with vulnerabilities
276    pub max_severity: Option<String>,
277    /// Component type (lib, bin, cert, file, etc.)
278    pub component_type: Option<String>,
279}
280
281/// The tree widget.
282pub struct Tree<'a> {
283    roots: &'a [TreeNode],
284    block: Option<Block<'a>>,
285    highlight_style: Style,
286    highlight_symbol: &'a str,
287    group_style: Style,
288    component_style: Style,
289}
290
291impl<'a> Tree<'a> {
292    pub fn new(roots: &'a [TreeNode]) -> Self {
293        let scheme = colors();
294        Self {
295            roots,
296            block: None,
297            highlight_style: Style::default()
298                .bg(scheme.selection)
299                .add_modifier(Modifier::BOLD),
300            highlight_symbol: "▶ ",
301            group_style: Style::default().fg(scheme.primary).bold(),
302            component_style: Style::default().fg(scheme.text),
303        }
304    }
305
306    pub fn block(mut self, block: Block<'a>) -> Self {
307        self.block = Some(block);
308        self
309    }
310
311    pub fn highlight_style(mut self, style: Style) -> Self {
312        self.highlight_style = style;
313        self
314    }
315
316    pub fn highlight_symbol(mut self, symbol: &'a str) -> Self {
317        self.highlight_symbol = symbol;
318        self
319    }
320
321    /// Flatten the tree into a list of items for rendering.
322    fn flatten(&self, state: &TreeState) -> Vec<FlattenedItem> {
323        let mut items = Vec::new();
324        self.flatten_nodes(self.roots, 0, state, &mut items, &[]);
325        items
326    }
327
328    fn flatten_nodes(
329        &self,
330        nodes: &[TreeNode],
331        depth: usize,
332        state: &TreeState,
333        items: &mut Vec<FlattenedItem>,
334        ancestors_last: &[bool],
335    ) {
336        for (i, node) in nodes.iter().enumerate() {
337            let is_last = i == nodes.len() - 1;
338            let is_expanded = state.is_expanded(node.id());
339
340            let mut current_ancestors = ancestors_last.to_vec();
341            current_ancestors.push(is_last);
342
343            items.push(FlattenedItem {
344                node_id: node.id().to_string(),
345                label: node.label(),
346                depth,
347                is_group: node.is_group(),
348                is_expanded,
349                is_last_sibling: is_last,
350                vuln_count: node.vuln_count(),
351                ancestors_last: current_ancestors.clone(),
352                max_severity: node.max_severity().map(|s| s.to_string()),
353                component_type: node.component_type().map(|s| s.to_string()),
354            });
355
356            // Recursively add children if expanded
357            if is_expanded {
358                if let Some(children) = node.children() {
359                    self.flatten_nodes(children, depth + 1, state, items, &current_ancestors);
360                }
361            }
362        }
363    }
364}
365
366impl<'a> StatefulWidget for Tree<'a> {
367    type State = TreeState;
368
369    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
370        // Handle block separately to avoid borrow issues
371        let inner_area = if let Some(ref b) = self.block {
372            let inner = b.inner(area);
373            b.clone().render(area, buf);
374            inner
375        } else {
376            area
377        };
378
379        if inner_area.width < 4 || inner_area.height < 1 {
380            return;
381        }
382
383        let items = self.flatten(state);
384        let area = inner_area;
385        state.visible_count = items.len();
386
387        // Calculate scroll offset to keep selected item visible
388        let visible_height = area.height as usize;
389        if state.selected >= state.offset + visible_height {
390            state.offset = state.selected - visible_height + 1;
391        } else if state.selected < state.offset {
392            state.offset = state.selected;
393        }
394
395        // Render visible items
396        for (i, item) in items
397            .iter()
398            .skip(state.offset)
399            .take(visible_height)
400            .enumerate()
401        {
402            let y = area.y + i as u16;
403            let is_selected = state.offset + i == state.selected;
404
405            // Build the tree prefix with box-drawing characters
406            let mut prefix = String::new();
407            for (depth, is_last) in item.ancestors_last.iter().take(item.depth).enumerate() {
408                if depth < item.depth {
409                    if *is_last {
410                        prefix.push_str("   ");
411                    } else {
412                        prefix.push_str("│  ");
413                    }
414                }
415            }
416
417            // Add the branch character for this node
418            if item.depth > 0 {
419                if item.is_last_sibling {
420                    prefix.push_str("└─ ");
421                } else {
422                    prefix.push_str("├─ ");
423                }
424            }
425
426            // Add expand/collapse indicator for groups
427            let expand_indicator = if item.is_group {
428                if item.is_expanded {
429                    "▼ "
430                } else {
431                    "▶ "
432                }
433            } else {
434                "  "
435            };
436
437            // Build the line
438            let mut x = area.x;
439
440            // Selection indicator
441            let scheme = colors();
442            if is_selected {
443                let symbol = self.highlight_symbol;
444                for ch in symbol.chars() {
445                    if x < area.x + area.width {
446                        if let Some(cell) = buf.cell_mut((x, y)) {
447                            cell.set_char(ch)
448                                .set_style(Style::default().fg(scheme.accent));
449                        }
450                        x += 1;
451                    }
452                }
453            } else {
454                x += self.highlight_symbol.len() as u16;
455            }
456
457            // Tree prefix
458            for ch in prefix.chars() {
459                if x < area.x + area.width {
460                    if let Some(cell) = buf.cell_mut((x, y)) {
461                        cell.set_char(ch)
462                            .set_style(Style::default().fg(scheme.muted));
463                    }
464                    x += 1;
465                }
466            }
467
468            // Expand indicator
469            let indicator_style = if item.is_group {
470                Style::default().fg(scheme.accent)
471            } else {
472                Style::default()
473            };
474            for ch in expand_indicator.chars() {
475                if x < area.x + area.width {
476                    if let Some(cell) = buf.cell_mut((x, y)) {
477                        cell.set_char(ch).set_style(indicator_style);
478                    }
479                    x += 1;
480                }
481            }
482
483            // Label
484            let label_style = if is_selected {
485                self.highlight_style
486            } else if item.is_group {
487                self.group_style
488            } else {
489                self.component_style
490            };
491
492            for ch in item.label.chars() {
493                if x < area.x + area.width {
494                    if let Some(cell) = buf.cell_mut((x, y)) {
495                        cell.set_char(ch).set_style(label_style);
496                    }
497                    x += 1;
498                }
499            }
500
501            // Vulnerability indicator with severity badge
502            if item.vuln_count > 0 {
503                // Get severity color
504                let (sev_char, sev_color) = if let Some(ref sev) = item.max_severity {
505                    match sev.to_lowercase().as_str() {
506                        "critical" => ('C', scheme.critical),
507                        "high" => ('H', scheme.high),
508                        "medium" => ('M', scheme.medium),
509                        "low" => ('L', scheme.low),
510                        _ => ('?', scheme.muted),
511                    }
512                } else {
513                    ('?', scheme.muted)
514                };
515
516                // Space before indicator
517                if x < area.x + area.width {
518                    if let Some(cell) = buf.cell_mut((x, y)) {
519                        cell.set_char(' ');
520                    }
521                    x += 1;
522                }
523
524                // Severity badge [C], [H], [M], [L]
525                let badge_style = Style::default()
526                    .fg(scheme.badge_fg_dark)
527                    .bg(sev_color)
528                    .bold();
529
530                if x < area.x + area.width {
531                    if let Some(cell) = buf.cell_mut((x, y)) {
532                        cell.set_char(sev_char).set_style(badge_style);
533                    }
534                    x += 1;
535                }
536
537                // Vuln count
538                let count_text = format!("{}", item.vuln_count);
539                let count_style = Style::default().fg(sev_color).bold();
540                for ch in count_text.chars() {
541                    if x < area.x + area.width {
542                        if let Some(cell) = buf.cell_mut((x, y)) {
543                            cell.set_char(ch).set_style(count_style);
544                        }
545                        x += 1;
546                    }
547                }
548            }
549
550            // Fill rest with background if selected
551            if is_selected {
552                while x < area.x + area.width {
553                    if let Some(cell) = buf.cell_mut((x, y)) {
554                        cell.set_style(self.highlight_style);
555                    }
556                    x += 1;
557                }
558            }
559        }
560
561        // Render scrollbar if needed
562        if items.len() > visible_height {
563            let scheme = colors();
564            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
565                .thumb_style(Style::default().fg(scheme.accent))
566                .track_style(Style::default().fg(scheme.muted));
567            let mut scrollbar_state = ScrollbarState::new(items.len()).position(state.selected);
568            scrollbar.render(area, buf, &mut scrollbar_state);
569        }
570    }
571}
572
573/// Get the currently selected node ID.
574pub fn get_selected_node<'a>(roots: &'a [TreeNode], state: &TreeState) -> Option<&'a TreeNode> {
575    let mut items = Vec::new();
576    flatten_for_selection(roots, state, &mut items);
577    items.get(state.selected).copied()
578}
579
580fn flatten_for_selection<'a>(
581    nodes: &'a [TreeNode],
582    state: &TreeState,
583    items: &mut Vec<&'a TreeNode>,
584) {
585    for node in nodes {
586        items.push(node);
587        if state.is_expanded(node.id()) {
588            if let Some(children) = node.children() {
589                flatten_for_selection(children, state, items);
590            }
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_tree_state() {
601        let mut state = TreeState::new();
602        assert!(!state.is_expanded("test"));
603
604        state.toggle_expand("test");
605        assert!(state.is_expanded("test"));
606
607        state.toggle_expand("test");
608        assert!(!state.is_expanded("test"));
609    }
610
611    #[test]
612    fn test_tree_node() {
613        let node = TreeNode::Component {
614            id: "comp-1".to_string(),
615            name: "lodash".to_string(),
616            version: Some("4.17.21".to_string()),
617            vuln_count: 2,
618            max_severity: Some("high".to_string()),
619            component_type: Some("lib".to_string()),
620        };
621
622        assert_eq!(node.label(), "lodash@4.17.21");
623        assert_eq!(node.vuln_count(), 2);
624        assert_eq!(node.max_severity(), Some("high"));
625        assert!(!node.is_group());
626    }
627
628    #[test]
629    fn test_extract_display_name() {
630        // Path-like names - extracts the filename
631        assert_eq!(
632            extract_display_name("./6488064-48136192.squashfs_v4_le_extract/SMASH/ShowProperty"),
633            "ShowProperty"
634        );
635
636        // Clean package names should pass through
637        assert_eq!(extract_display_name("lodash"), "lodash");
638        assert_eq!(extract_display_name("openssl-1.1.1"), "openssl-1.1.1");
639
640        // Hash-like filenames with meaningful parent get parent/file format
641        let hash_result = extract_display_name("./6488064-48136192.squashfs");
642        assert!(hash_result.len() <= 30);
643    }
644
645    #[test]
646    fn test_detect_component_type() {
647        assert_eq!(detect_component_type("libssl.so"), "lib");
648        assert_eq!(detect_component_type("libcrypto.so.1.1"), "lib");
649        assert_eq!(detect_component_type("server.crt"), "cert");
650        assert_eq!(detect_component_type("firmware.img"), "bin");
651        assert_eq!(detect_component_type("rootfs.squashfs"), "fs");
652        assert_eq!(detect_component_type("random.unknown"), "unk");
653    }
654}