1use crate::tui::theme::colors;
4use ratatui::{
5 prelude::*,
6 widgets::{Block, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
7};
8use std::collections::HashSet;
9
10#[derive(Debug, Clone)]
12pub enum TreeNode {
13 Group {
15 id: String,
16 label: String,
17 children: Vec<TreeNode>,
18 item_count: usize,
19 vuln_count: usize,
20 },
21 Component {
23 id: String,
24 name: String,
25 version: Option<String>,
26 vuln_count: usize,
27 max_severity: Option<String>,
29 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 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
99pub fn extract_display_name(name: &str) -> String {
101 if !name.contains('/') && !name.starts_with('.') && name.len() <= 40 {
103 return name.to_string();
104 }
105
106 if let Some(filename) = name.rsplit('/').next() {
108 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 is_hash_like(clean) {
122 let parts: Vec<&str> = name.split('/').collect();
124 if parts.len() >= 2 {
125 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
145fn 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
155fn 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
164pub 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#[derive(Debug, Clone, Default)]
197pub struct TreeState {
198 pub selected: usize,
200 pub expanded: HashSet<String>,
202 pub offset: usize,
204 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#[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 pub max_severity: Option<String>,
277 pub component_type: Option<String>,
279}
280
281pub 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 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 if is_expanded {
358 if let Some(children) = node.children() {
359 self.flatten_nodes(children, depth + 1, state, items, ¤t_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 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 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 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 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 if item.depth > 0 {
419 if item.is_last_sibling {
420 prefix.push_str("└─ ");
421 } else {
422 prefix.push_str("├─ ");
423 }
424 }
425
426 let expand_indicator = if item.is_group {
428 if item.is_expanded {
429 "▼ "
430 } else {
431 "▶ "
432 }
433 } else {
434 " "
435 };
436
437 let mut x = area.x;
439
440 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 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 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 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 if item.vuln_count > 0 {
503 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 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 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 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 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 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
573pub 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 assert_eq!(
632 extract_display_name("./6488064-48136192.squashfs_v4_le_extract/SMASH/ShowProperty"),
633 "ShowProperty"
634 );
635
636 assert_eq!(extract_display_name("lodash"), "lodash");
638 assert_eq!(extract_display_name("openssl-1.1.1"), "openssl-1.1.1");
639
640 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}