1use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex, VulnerabilityRef};
7use crate::quality::{ComplianceResult, QualityReport, QualityScorer, ScoringProfile};
8use crate::tui::app_states::SourcePanelState;
9use crate::tui::state::ListNavigation;
10use crate::tui::widgets::TreeState;
11use std::collections::{HashMap, HashSet};
12
13use super::views::{compute_compliance_results, StandardComplianceState};
14
15pub struct ViewApp {
17 pub sbom: NormalizedSbom,
19
20 pub active_tab: ViewTab,
22
23 pub tree_state: TreeState,
25
26 pub tree_group_by: TreeGroupBy,
28
29 pub tree_filter: TreeFilter,
31
32 pub tree_search_query: String,
34
35 pub tree_search_active: bool,
37
38 pub selected_component: Option<String>,
40
41 pub component_tab: ComponentDetailTab,
43
44 pub vuln_state: VulnExplorerState,
46
47 pub license_state: LicenseViewState,
49
50 pub dependency_state: DependencyViewState,
52
53 pub search_state: SearchState,
55
56 pub focus_panel: FocusPanel,
58
59 pub show_help: bool,
61
62 pub show_export: bool,
64
65 pub show_legend: bool,
67
68 pub status_message: Option<String>,
70
71 pub navigation_ctx: ViewNavigationContext,
73
74 pub should_quit: bool,
76
77 pub tick: u64,
79
80 pub stats: SbomStats,
82
83 pub quality_report: QualityReport,
85
86 pub quality_state: QualityViewState,
88
89 pub compliance_results: Option<Vec<ComplianceResult>>,
91
92 pub compliance_state: StandardComplianceState,
94
95 pub sbom_index: NormalizedSbomIndex,
97
98 pub source_state: SourcePanelState,
100}
101
102impl ViewApp {
103 pub fn new(sbom: NormalizedSbom, raw_content: String) -> Self {
105 let stats = SbomStats::from_sbom(&sbom);
106
107 let scorer = QualityScorer::new(ScoringProfile::Standard);
109 let quality_report = scorer.score(&sbom);
110 let quality_state = QualityViewState::new(quality_report.recommendations.len());
111
112 let compliance_state = StandardComplianceState::new();
113
114 let sbom_index = sbom.build_index();
116
117 let source_state = SourcePanelState::new(&raw_content);
119
120 let mut tree_state = TreeState::new();
122 for eco in stats.ecosystem_counts.keys().take(3) {
123 tree_state.expand(&format!("eco:{}", eco));
124 }
125
126 Self {
127 sbom,
128 active_tab: ViewTab::Overview,
129 tree_state,
130 tree_group_by: TreeGroupBy::Ecosystem,
131 tree_filter: TreeFilter::All,
132 tree_search_query: String::new(),
133 tree_search_active: false,
134 selected_component: None,
135 component_tab: ComponentDetailTab::Overview,
136 vuln_state: VulnExplorerState::new(),
137 license_state: LicenseViewState::new(),
138 dependency_state: DependencyViewState::new(),
139 search_state: SearchState::new(),
140 focus_panel: FocusPanel::Left,
141 show_help: false,
142 show_export: false,
143 show_legend: false,
144 status_message: None,
145 navigation_ctx: ViewNavigationContext::new(),
146 should_quit: false,
147 tick: 0,
148 stats,
149 quality_report,
150 quality_state,
151 compliance_results: None,
152 compliance_state,
153 sbom_index,
154 source_state,
155 }
156 }
157
158 pub fn ensure_compliance_results(&mut self) {
160 if self.compliance_results.is_none() {
161 self.compliance_results = Some(compute_compliance_results(&self.sbom));
162 }
163 }
164
165 pub fn next_tab(&mut self) {
167 self.active_tab = match self.active_tab {
168 ViewTab::Overview => ViewTab::Tree,
169 ViewTab::Tree => ViewTab::Vulnerabilities,
170 ViewTab::Vulnerabilities => ViewTab::Licenses,
171 ViewTab::Licenses => ViewTab::Dependencies,
172 ViewTab::Dependencies => ViewTab::Quality,
173 ViewTab::Quality => ViewTab::Compliance,
174 ViewTab::Compliance => ViewTab::Source,
175 ViewTab::Source => ViewTab::Overview,
176 };
177 }
178
179 pub fn prev_tab(&mut self) {
181 self.active_tab = match self.active_tab {
182 ViewTab::Overview => ViewTab::Source,
183 ViewTab::Tree => ViewTab::Overview,
184 ViewTab::Vulnerabilities => ViewTab::Tree,
185 ViewTab::Licenses => ViewTab::Vulnerabilities,
186 ViewTab::Dependencies => ViewTab::Licenses,
187 ViewTab::Quality => ViewTab::Dependencies,
188 ViewTab::Compliance => ViewTab::Quality,
189 ViewTab::Source => ViewTab::Compliance,
190 };
191 }
192
193 pub fn select_tab(&mut self, tab: ViewTab) {
195 self.active_tab = tab;
196 }
197
198 pub fn get_sort_key(
206 &self,
207 id: &crate::model::CanonicalId,
208 ) -> Option<&crate::model::ComponentSortKey> {
209 self.sbom_index.sort_key(id)
210 }
211
212 pub fn get_dependencies(
214 &self,
215 id: &crate::model::CanonicalId,
216 ) -> Vec<&crate::model::DependencyEdge> {
217 self.sbom_index.dependencies_of(id, &self.sbom.edges)
218 }
219
220 pub fn get_dependents(
222 &self,
223 id: &crate::model::CanonicalId,
224 ) -> Vec<&crate::model::DependencyEdge> {
225 self.sbom_index.dependents_of(id, &self.sbom.edges)
226 }
227
228 pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
230 self.sbom.search_by_name_indexed(query, &self.sbom_index)
231 }
232
233 pub fn toggle_focus(&mut self) {
235 self.focus_panel = match self.focus_panel {
236 FocusPanel::Left => FocusPanel::Right,
237 FocusPanel::Right => FocusPanel::Left,
238 };
239 }
240
241 pub fn start_search(&mut self) {
243 self.search_state.active = true;
244 self.search_state.query.clear();
245 self.search_state.results.clear();
246 }
247
248 pub fn stop_search(&mut self) {
250 self.search_state.active = false;
251 }
252
253 pub fn execute_search(&mut self) {
255 self.search_state.results = self.search(&self.search_state.query.clone());
256 self.search_state.selected = 0;
257 }
258
259 fn search(&self, query: &str) -> Vec<SearchResult> {
261 if query.len() < 2 {
262 return Vec::new();
263 }
264
265 let query_lower = query.to_lowercase();
266 let mut results = Vec::new();
267
268 for (id, comp) in &self.sbom.components {
270 if comp.name.to_lowercase().contains(&query_lower) {
271 results.push(SearchResult::Component {
272 id: id.value().to_string(),
273 name: comp.name.clone(),
274 version: comp.version.clone(),
275 match_field: "name".to_string(),
276 });
277 } else if let Some(purl) = &comp.identifiers.purl {
278 if purl.to_lowercase().contains(&query_lower) {
279 results.push(SearchResult::Component {
280 id: id.value().to_string(),
281 name: comp.name.clone(),
282 version: comp.version.clone(),
283 match_field: "purl".to_string(),
284 });
285 }
286 }
287 }
288
289 for (_, comp) in &self.sbom.components {
291 for vuln in &comp.vulnerabilities {
292 if vuln.id.to_lowercase().contains(&query_lower) {
293 results.push(SearchResult::Vulnerability {
294 id: vuln.id.clone(),
295 component_id: comp.canonical_id.to_string(), component_name: comp.name.clone(),
297 severity: vuln.severity.as_ref().map(|s| s.to_string()),
298 });
299 }
300 }
301 }
302
303 results.truncate(50);
305 results
306 }
307
308 pub fn get_selected_component(&self) -> Option<&Component> {
310 self.selected_component.as_ref().and_then(|selected_id| {
311 self.sbom
312 .components
313 .iter()
314 .find(|(id, _)| id.value() == selected_id)
315 .map(|(_, comp)| comp)
316 })
317 }
318
319 pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
321 let group_id = {
322 let comp = match self
323 .sbom
324 .components
325 .iter()
326 .find(|(id, _)| id.value() == component_id)
327 .map(|(_, comp)| comp)
328 {
329 Some(comp) => comp,
330 None => return false,
331 };
332 self.tree_group_id_for_component(comp)
333 };
334 if let Some(ref group_id) = group_id {
335 self.tree_state.expand(group_id);
336 }
337
338 let nodes = self.build_tree_nodes();
339 let mut flat_items = Vec::new();
340 flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
341
342 if let Some(index) = flat_items
343 .iter()
344 .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
345 {
346 self.tree_state.selected = index;
347 return true;
348 }
349
350 if let Some(group_id) = group_id {
351 if let Some(index) = flat_items
352 .iter()
353 .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
354 {
355 self.tree_state.selected = index;
356 }
357 }
358
359 false
360 }
361
362 pub fn toggle_tree_grouping(&mut self) {
364 self.tree_group_by = match self.tree_group_by {
365 TreeGroupBy::Ecosystem => TreeGroupBy::License,
366 TreeGroupBy::License => TreeGroupBy::VulnStatus,
367 TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
368 TreeGroupBy::ComponentType => TreeGroupBy::Flat,
369 TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
370 };
371 self.tree_state = TreeState::new(); }
373
374 pub fn toggle_tree_filter(&mut self) {
376 self.tree_filter = match self.tree_filter {
377 TreeFilter::All => TreeFilter::HasVulnerabilities,
378 TreeFilter::HasVulnerabilities => TreeFilter::Critical,
379 TreeFilter::Critical => TreeFilter::All,
380 };
381 self.tree_state = TreeState::new();
382 }
383
384 pub fn start_tree_search(&mut self) {
386 self.tree_search_active = true;
387 self.tree_search_query.clear();
388 }
389
390 pub fn stop_tree_search(&mut self) {
392 self.tree_search_active = false;
393 }
394
395 pub fn clear_tree_search(&mut self) {
397 self.tree_search_query.clear();
398 self.tree_search_active = false;
399 self.tree_state = TreeState::new();
400 }
401
402 pub fn tree_search_push_char(&mut self, c: char) {
404 self.tree_search_query.push(c);
405 self.tree_state = TreeState::new();
406 }
407
408 pub fn tree_search_pop_char(&mut self) {
410 self.tree_search_query.pop();
411 self.tree_state = TreeState::new();
412 }
413
414 pub fn next_component_tab(&mut self) {
416 self.component_tab = match self.component_tab {
417 ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
418 ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
419 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
420 ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
421 };
422 }
423
424 pub fn prev_component_tab(&mut self) {
426 self.component_tab = match self.component_tab {
427 ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
428 ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
429 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
430 ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
431 };
432 }
433
434 pub fn select_component_tab(&mut self, tab: ComponentDetailTab) {
436 self.component_tab = tab;
437 }
438
439 pub fn toggle_help(&mut self) {
441 self.show_help = !self.show_help;
442 if self.show_help {
443 self.show_export = false;
444 self.show_legend = false;
445 }
446 }
447
448 pub fn toggle_export(&mut self) {
450 self.show_export = !self.show_export;
451 if self.show_export {
452 self.show_help = false;
453 self.show_legend = false;
454 }
455 }
456
457 pub fn toggle_legend(&mut self) {
459 self.show_legend = !self.show_legend;
460 if self.show_legend {
461 self.show_help = false;
462 self.show_export = false;
463 }
464 }
465
466 pub fn close_overlays(&mut self) {
468 self.show_help = false;
469 self.show_export = false;
470 self.show_legend = false;
471 self.search_state.active = false;
472 self.compliance_state.show_detail = false;
473 }
474
475 pub fn has_overlay(&self) -> bool {
477 self.show_help
478 || self.show_export
479 || self.show_legend
480 || self.search_state.active
481 || self.compliance_state.show_detail
482 }
483
484 pub fn set_status_message(&mut self, msg: impl Into<String>) {
486 self.status_message = Some(msg.into());
487 }
488
489 pub fn clear_status_message(&mut self) {
491 self.status_message = None;
492 }
493
494 pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
496 use crate::tui::export::export_view;
497
498 let result = export_view(format, &self.sbom, None);
499
500 if result.success {
501 self.set_status_message(result.message);
502 } else {
503 self.set_status_message(format!("Export failed: {}", result.message));
504 }
505 }
506
507 pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
509 use crate::tui::export::export_compliance;
510
511 self.ensure_compliance_results();
512 let results = match self.compliance_results.as_ref() {
513 Some(r) if !r.is_empty() => r,
514 _ => {
515 self.set_status_message("No compliance results to export");
516 return;
517 }
518 };
519
520 let result = export_compliance(
521 format,
522 results,
523 self.compliance_state.selected_standard,
524 None,
525 );
526 if result.success {
527 self.set_status_message(result.message);
528 } else {
529 self.set_status_message(format!("Export failed: {}", result.message));
530 }
531 }
532
533 pub fn go_back(&mut self) -> bool {
535 if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
536 self.active_tab = breadcrumb.tab;
537 match breadcrumb.tab {
539 ViewTab::Vulnerabilities => {
540 self.vuln_state.selected = breadcrumb.selection_index;
541 }
542 ViewTab::Licenses => {
543 self.license_state.selected = breadcrumb.selection_index;
544 }
545 ViewTab::Dependencies => {
546 self.dependency_state.selected = breadcrumb.selection_index;
547 }
548 ViewTab::Tree => {
549 self.tree_state.selected = breadcrumb.selection_index;
550 }
551 ViewTab::Source => {
552 self.source_state.selected = breadcrumb.selection_index;
553 }
554 _ => {}
555 }
556 self.focus_panel = FocusPanel::Left;
557 true
558 } else {
559 false
560 }
561 }
562
563 pub fn navigate_up(&mut self) {
565 match self.active_tab {
566 ViewTab::Tree => self.tree_state.select_prev(),
567 ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
568 ViewTab::Licenses => self.license_state.select_prev(),
569 ViewTab::Dependencies => self.dependency_state.select_prev(),
570 ViewTab::Quality => self.quality_state.select_prev(),
571 ViewTab::Compliance => self.compliance_state.select_prev(),
572 ViewTab::Source => self.source_state.select_prev(),
573 ViewTab::Overview => {} }
575 }
576
577 pub fn navigate_down(&mut self) {
579 match self.active_tab {
580 ViewTab::Tree => self.tree_state.select_next(),
581 ViewTab::Vulnerabilities => self.vuln_state.select_next(),
582 ViewTab::Licenses => self.license_state.select_next(),
583 ViewTab::Dependencies => self.dependency_state.select_next(),
584 ViewTab::Quality => self.quality_state.select_next(),
585 ViewTab::Compliance => {
586 self.ensure_compliance_results();
587 let max = self.compliance_results.as_ref()
588 .and_then(|r| r.get(self.compliance_state.selected_standard))
589 .map(|r| r.violations.len())
590 .unwrap_or(0);
591 self.compliance_state.select_next(max);
592 }
593 ViewTab::Source => self.source_state.select_next(),
594 ViewTab::Overview => {} }
596 }
597
598 pub fn page_up(&mut self) {
600 if self.active_tab == ViewTab::Source {
601 self.source_state.page_up();
602 } else {
603 for _ in 0..10 {
604 self.navigate_up();
605 }
606 }
607 }
608
609 pub fn page_down(&mut self) {
611 if self.active_tab == ViewTab::Source {
612 self.source_state.page_down();
613 } else {
614 for _ in 0..10 {
615 self.navigate_down();
616 }
617 }
618 }
619
620 pub fn go_first(&mut self) {
622 match self.active_tab {
623 ViewTab::Tree => self.tree_state.select_first(),
624 ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
625 ViewTab::Licenses => self.license_state.selected = 0,
626 ViewTab::Dependencies => self.dependency_state.selected = 0,
627 ViewTab::Quality => self.quality_state.scroll_offset = 0,
628 ViewTab::Compliance => self.compliance_state.selected_violation = 0,
629 ViewTab::Source => self.source_state.select_first(),
630 ViewTab::Overview => {}
631 }
632 }
633
634 pub fn go_last(&mut self) {
636 match self.active_tab {
637 ViewTab::Tree => self.tree_state.select_last(),
638 ViewTab::Vulnerabilities => {
639 self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
640 }
641 ViewTab::Licenses => {
642 self.license_state.selected = self.license_state.total.saturating_sub(1);
643 }
644 ViewTab::Dependencies => {
645 self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
646 }
647 ViewTab::Quality => {
648 self.quality_state.scroll_offset =
649 self.quality_state.total_recommendations.saturating_sub(1);
650 }
651 ViewTab::Compliance => {
652 self.ensure_compliance_results();
653 let max = self.compliance_results.as_ref()
654 .and_then(|r| r.get(self.compliance_state.selected_standard))
655 .map(|r| r.violations.len())
656 .unwrap_or(0);
657 self.compliance_state.selected_violation = max.saturating_sub(1);
658 }
659 ViewTab::Source => self.source_state.select_last(),
660 ViewTab::Overview => {}
661 }
662 }
663
664 pub fn handle_enter(&mut self) {
666 match self.active_tab {
667 ViewTab::Tree => {
668 if let Some(node) = self.get_selected_tree_node() {
670 match node {
671 SelectedTreeNode::Group(id) => {
672 self.tree_state.toggle_expand(&id);
673 }
674 SelectedTreeNode::Component(id) => {
675 self.selected_component = Some(id);
676 self.focus_panel = FocusPanel::Right;
677 self.component_tab = ComponentDetailTab::Overview;
678 }
679 }
680 }
681 }
682 ViewTab::Vulnerabilities => {
683 if let Some((comp_id, vuln)) = self.vuln_state.get_selected(&self.sbom) {
685 self.navigation_ctx.push_breadcrumb(
687 ViewTab::Vulnerabilities,
688 vuln.id.clone(),
689 self.vuln_state.selected,
690 );
691 self.selected_component = Some(comp_id);
692 self.component_tab = ComponentDetailTab::Overview;
693 self.active_tab = ViewTab::Tree;
694 }
695 }
696 ViewTab::Dependencies => {
697 if let Some(node_id) = self.get_selected_dependency_node_id() {
700 self.dependency_state.toggle_expand(&node_id);
701 }
702 }
703 ViewTab::Compliance => {
704 self.ensure_compliance_results();
706 let idx = self.compliance_state.selected_standard;
707 let has_violations = self
708 .compliance_results.as_ref()
709 .and_then(|r| r.get(idx))
710 .map(|r| !r.violations.is_empty())
711 .unwrap_or(false);
712 if has_violations {
713 self.compliance_state.show_detail = !self.compliance_state.show_detail;
714 }
715 }
716 ViewTab::Source => {
717 if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree {
719 if let Some(ref tree) = self.source_state.json_tree {
720 let mut items = Vec::new();
721 crate::tui::shared::source::flatten_json_tree(
722 tree,
723 "",
724 0,
725 &self.source_state.expanded,
726 &mut items,
727 true,
728 &[],
729 );
730 if let Some(item) = items.get(self.source_state.selected) {
731 if item.is_expandable {
732 let node_id = item.node_id.clone();
733 self.source_state.toggle_expand(&node_id);
734 }
735 }
736 }
737 }
738 }
739 ViewTab::Licenses | ViewTab::Overview | ViewTab::Quality => {}
740 }
741 }
742
743 pub fn handle_source_map_enter(&mut self) {
745 let tree = match &self.source_state.json_tree {
747 Some(t) => t,
748 None => return,
749 };
750 let children = match tree.children() {
751 Some(c) => c,
752 None => return,
753 };
754
755 let expandable: Vec<_> = children
757 .iter()
758 .filter(|c| c.is_expandable())
759 .collect();
760
761 let target = match expandable.get(self.source_state.map_selected) {
762 Some(t) => *t,
763 None => return,
764 };
765
766 let target_id = target.node_id("root");
767
768 match self.source_state.view_mode {
769 crate::tui::app_states::SourceViewMode::Tree => {
770 if !self.source_state.expanded.contains(&target_id) {
772 self.source_state.expanded.insert(target_id.clone());
773 }
774 let mut items = Vec::new();
776 crate::tui::shared::source::flatten_json_tree(
777 tree, "", 0, &self.source_state.expanded, &mut items, true, &[],
778 );
779 if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
780 self.source_state.selected = idx;
781 self.source_state.scroll_offset = idx.saturating_sub(2);
782 }
783 }
784 crate::tui::app_states::SourceViewMode::Raw => {
785 let key = match target {
787 crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
788 | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
789 | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
790 };
791 for (i, line) in self.source_state.raw_lines.iter().enumerate() {
793 let search = format!("\"{}\":", key);
794 if line.contains(&search) && line.starts_with(" ") && !line.starts_with(" ") {
795 self.source_state.selected = i;
796 self.source_state.scroll_offset = i.saturating_sub(2);
797 break;
798 }
799 }
800 }
801 }
802
803 self.focus_panel = FocusPanel::Left;
805 }
806
807 pub fn get_map_context_component_id(&self) -> Option<String> {
810 let tree = self.source_state.json_tree.as_ref()?;
811 let mut items = Vec::new();
812 crate::tui::shared::source::flatten_json_tree(
813 tree,
814 "",
815 0,
816 &self.source_state.expanded,
817 &mut items,
818 true,
819 &[],
820 );
821 let item = items.get(self.source_state.selected)?;
822 let parts: Vec<&str> = item.node_id.split('.').collect();
823 if parts.len() < 3 || parts[1] != "components" {
824 return None;
825 }
826 let idx_part = parts[2];
827 if idx_part.starts_with('[') && idx_part.ends_with(']') {
828 let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
829 let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
830 Some(canon_id.value().to_string())
831 } else {
832 None
833 }
834 }
835
836 pub fn get_selected_dependency_node_id(&self) -> Option<String> {
838 let mut visible_nodes = Vec::new();
840 self.collect_visible_dependency_nodes(&mut visible_nodes);
841 visible_nodes.get(self.dependency_state.selected).cloned()
842 }
843
844 fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
846 let mut edges: std::collections::HashMap<String, Vec<String>> =
848 std::collections::HashMap::new();
849 let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
850 let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
851
852 for (id, _) in &self.sbom.components {
853 all_nodes.insert(id.value().to_string());
854 }
855
856 for edge in &self.sbom.edges {
857 let from = edge.from.value().to_string();
858 let to = edge.to.value().to_string();
859 if all_nodes.contains(&from) && all_nodes.contains(&to) {
860 edges.entry(from).or_default().push(to.clone());
861 has_parent.insert(to);
862 }
863 }
864
865 let mut roots: Vec<_> = all_nodes
867 .iter()
868 .filter(|id| !has_parent.contains(*id))
869 .cloned()
870 .collect();
871 roots.sort();
872
873 for root in roots {
875 self.collect_dep_nodes_recursive(
876 &root,
877 &edges,
878 nodes,
879 &mut std::collections::HashSet::new(),
880 );
881 }
882 }
883
884 fn collect_dep_nodes_recursive(
885 &self,
886 node_id: &str,
887 edges: &std::collections::HashMap<String, Vec<String>>,
888 nodes: &mut Vec<String>,
889 visited: &mut std::collections::HashSet<String>,
890 ) {
891 if visited.contains(node_id) {
892 return;
893 }
894 visited.insert(node_id.to_string());
895 nodes.push(node_id.to_string());
896
897 if self.dependency_state.is_expanded(node_id) {
898 if let Some(children) = edges.get(node_id) {
899 for child in children {
900 self.collect_dep_nodes_recursive(child, edges, nodes, visited);
901 }
902 }
903 }
904 }
905
906 fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
908 let nodes = self.build_tree_nodes();
909 let mut flat_items = Vec::new();
910 flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
911
912 flat_items.get(self.tree_state.selected).cloned()
913 }
914
915 pub fn build_tree_nodes(&self) -> Vec<crate::tui::widgets::TreeNode> {
917 match self.tree_group_by {
918 TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
919 TreeGroupBy::License => self.build_license_tree(),
920 TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
921 TreeGroupBy::ComponentType => self.build_type_tree(),
922 TreeGroupBy::Flat => self.build_flat_tree(),
923 }
924 }
925
926 fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
927 use crate::tui::widgets::TreeNode;
928
929 let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
930
931 for comp in self.sbom.components.values() {
932 if !self.matches_filter(comp) {
933 continue;
934 }
935 let eco = comp
936 .ecosystem
937 .as_ref()
938 .map(|e| e.to_string())
939 .unwrap_or_else(|| "Unknown".to_string());
940 ecosystem_map.entry(eco).or_default().push(comp);
941 }
942
943 let mut groups: Vec<TreeNode> = ecosystem_map
944 .into_iter()
945 .map(|(eco, mut components)| {
946 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
947 components.sort_by(|a, b| a.name.cmp(&b.name));
948 let children: Vec<TreeNode> = components
949 .into_iter()
950 .map(|c| TreeNode::Component {
951 id: c.canonical_id.value().to_string(),
952 name: c.name.clone(),
953 version: c.version.clone(),
954 vuln_count: c.vulnerabilities.len(),
955 max_severity: get_max_severity(c),
956 component_type: Some(
957 crate::tui::widgets::detect_component_type(&c.name).to_string(),
958 ),
959 })
960 .collect();
961 let count = children.len();
962 TreeNode::Group {
963 id: format!("eco:{}", eco),
964 label: eco,
965 children,
966 item_count: count,
967 vuln_count,
968 }
969 })
970 .collect();
971
972 groups.sort_by(|a, b| match (a, b) {
973 (
974 TreeNode::Group { item_count: ac, label: al, .. },
975 TreeNode::Group { item_count: bc, label: bl, .. },
976 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
977 _ => std::cmp::Ordering::Equal,
978 });
979
980 groups
981 }
982
983 fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
984 use crate::tui::widgets::TreeNode;
985
986 let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
987
988 for comp in self.sbom.components.values() {
989 if !self.matches_filter(comp) {
990 continue;
991 }
992 let license = if comp.licenses.declared.is_empty() {
993 "Unknown".to_string()
994 } else {
995 comp.licenses.declared[0].expression.clone()
996 };
997 license_map.entry(license).or_default().push(comp);
998 }
999
1000 let mut groups: Vec<TreeNode> = license_map
1001 .into_iter()
1002 .map(|(license, mut components)| {
1003 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1004 components.sort_by(|a, b| a.name.cmp(&b.name));
1005 let children: Vec<TreeNode> = components
1006 .into_iter()
1007 .map(|c| TreeNode::Component {
1008 id: c.canonical_id.value().to_string(),
1009 name: c.name.clone(),
1010 version: c.version.clone(),
1011 vuln_count: c.vulnerabilities.len(),
1012 max_severity: get_max_severity(c),
1013 component_type: Some(
1014 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1015 ),
1016 })
1017 .collect();
1018 let count = children.len();
1019 TreeNode::Group {
1020 id: format!("lic:{}", license),
1021 label: license,
1022 children,
1023 item_count: count,
1024 vuln_count,
1025 }
1026 })
1027 .collect();
1028
1029 groups.sort_by(|a, b| match (a, b) {
1030 (
1031 TreeNode::Group { item_count: ac, label: al, .. },
1032 TreeNode::Group { item_count: bc, label: bl, .. },
1033 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1034 _ => std::cmp::Ordering::Equal,
1035 });
1036
1037 groups
1038 }
1039
1040 fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1041 use crate::tui::widgets::TreeNode;
1042 use super::severity::severity_category;
1043
1044 let mut critical_comps = Vec::new();
1045 let mut high_comps = Vec::new();
1046 let mut other_vuln_comps = Vec::new();
1047 let mut clean_comps = Vec::new();
1048
1049 for comp in self.sbom.components.values() {
1050 if !self.matches_filter(comp) {
1051 continue;
1052 }
1053
1054 match severity_category(&comp.vulnerabilities) {
1055 "critical" => critical_comps.push(comp),
1056 "high" => high_comps.push(comp),
1057 "clean" => clean_comps.push(comp),
1058 _ => other_vuln_comps.push(comp),
1059 }
1060 }
1061
1062 let build_group = |label: &str, id: &str, comps: Vec<&Component>| -> TreeNode {
1063 let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1064 let children: Vec<TreeNode> = comps
1065 .into_iter()
1066 .map(|c| TreeNode::Component {
1067 id: c.canonical_id.value().to_string(),
1068 name: c.name.clone(),
1069 version: c.version.clone(),
1070 vuln_count: c.vulnerabilities.len(),
1071 max_severity: get_max_severity(c),
1072 component_type: Some(
1073 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1074 ),
1075 })
1076 .collect();
1077 let count = children.len();
1078 TreeNode::Group {
1079 id: id.to_string(),
1080 label: label.to_string(),
1081 children,
1082 item_count: count,
1083 vuln_count,
1084 }
1085 };
1086
1087 let mut groups = Vec::new();
1088 if !critical_comps.is_empty() {
1089 groups.push(build_group("Critical", "vuln:critical", critical_comps));
1090 }
1091 if !high_comps.is_empty() {
1092 groups.push(build_group("High", "vuln:high", high_comps));
1093 }
1094 if !other_vuln_comps.is_empty() {
1095 groups.push(build_group(
1096 "Other Vulnerabilities",
1097 "vuln:other",
1098 other_vuln_comps,
1099 ));
1100 }
1101 if !clean_comps.is_empty() {
1102 groups.push(build_group("No Vulnerabilities", "vuln:clean", clean_comps));
1103 }
1104
1105 groups
1106 }
1107
1108 fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1109 use crate::tui::widgets::TreeNode;
1110
1111 let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1112
1113 for comp in self.sbom.components.values() {
1114 if !self.matches_filter(comp) {
1115 continue;
1116 }
1117 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1118 type_map.entry(comp_type).or_default().push(comp);
1119 }
1120
1121 let type_order = vec![
1123 ("lib", "Libraries"),
1124 ("bin", "Binaries"),
1125 ("cert", "Certificates"),
1126 ("fs", "Filesystems"),
1127 ("file", "Other Files"),
1128 ];
1129
1130 let mut groups = Vec::new();
1131 for (type_key, type_label) in type_order {
1132 if let Some(mut components) = type_map.remove(type_key) {
1133 if components.is_empty() {
1134 continue;
1135 }
1136 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1137 components.sort_by(|a, b| a.name.cmp(&b.name));
1138 let children: Vec<TreeNode> = components
1139 .into_iter()
1140 .map(|c| TreeNode::Component {
1141 id: c.canonical_id.value().to_string(),
1142 name: c.name.clone(),
1143 version: c.version.clone(),
1144 vuln_count: c.vulnerabilities.len(),
1145 max_severity: get_max_severity(c),
1146 component_type: Some(type_key.to_string()),
1147 })
1148 .collect();
1149 let count = children.len();
1150 groups.push(TreeNode::Group {
1151 id: format!("type:{}", type_key),
1152 label: type_label.to_string(),
1153 children,
1154 item_count: count,
1155 vuln_count,
1156 });
1157 }
1158 }
1159
1160 groups
1161 }
1162
1163 fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1164 use crate::tui::widgets::TreeNode;
1165
1166 self.sbom
1167 .components
1168 .values()
1169 .filter(|c| self.matches_filter(c))
1170 .map(|c| TreeNode::Component {
1171 id: c.canonical_id.value().to_string(),
1172 name: c.name.clone(),
1173 version: c.version.clone(),
1174 vuln_count: c.vulnerabilities.len(),
1175 max_severity: get_max_severity(c),
1176 component_type: Some(
1177 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1178 ),
1179 })
1180 .collect()
1181 }
1182
1183 fn matches_filter(&self, comp: &Component) -> bool {
1184 use super::severity::severity_matches;
1185
1186 let passes_filter = match self.tree_filter {
1188 TreeFilter::All => true,
1189 TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1190 TreeFilter::Critical => comp
1191 .vulnerabilities
1192 .iter()
1193 .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1194 };
1195
1196 if !passes_filter {
1197 return false;
1198 }
1199
1200 if self.tree_search_query.is_empty() {
1202 return true;
1203 }
1204
1205 let query_lower = self.tree_search_query.to_lowercase();
1206 let name_lower = comp.name.to_lowercase();
1207
1208 if name_lower.contains(&query_lower) {
1210 return true;
1211 }
1212
1213 if let Some(ref version) = comp.version {
1215 if version.to_lowercase().contains(&query_lower) {
1216 return true;
1217 }
1218 }
1219
1220 if let Some(ref eco) = comp.ecosystem {
1222 if eco.to_string().to_lowercase().contains(&query_lower) {
1223 return true;
1224 }
1225 }
1226
1227 false
1228 }
1229
1230 fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1231 match self.tree_group_by {
1232 TreeGroupBy::Ecosystem => {
1233 let eco = comp
1234 .ecosystem
1235 .as_ref()
1236 .map(|e| e.to_string())
1237 .unwrap_or_else(|| "Unknown".to_string());
1238 Some(format!("eco:{}", eco))
1239 }
1240 TreeGroupBy::License => {
1241 let license = if comp.licenses.declared.is_empty() {
1242 "Unknown".to_string()
1243 } else {
1244 comp.licenses.declared[0].expression.clone()
1245 };
1246 Some(format!("lic:{}", license))
1247 }
1248 TreeGroupBy::VulnStatus => {
1249 use super::severity::severity_category;
1250 let group = match severity_category(&comp.vulnerabilities) {
1251 "critical" => "vuln:critical",
1252 "high" => "vuln:high",
1253 "clean" => "vuln:clean",
1254 _ => "vuln:other",
1255 };
1256 Some(group.to_string())
1257 }
1258 TreeGroupBy::ComponentType => {
1259 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1260 Some(format!("type:{}", comp_type))
1261 }
1262 TreeGroupBy::Flat => None,
1263 }
1264 }
1265}
1266
1267fn get_max_severity(comp: &Component) -> Option<String> {
1269 super::severity::max_severity_from_vulns(&comp.vulnerabilities)
1270}
1271
1272#[derive(Debug, Clone)]
1274enum SelectedTreeNode {
1275 Group(String),
1276 Component(String),
1277}
1278
1279fn flatten_tree_for_selection(
1280 nodes: &[crate::tui::widgets::TreeNode],
1281 state: &TreeState,
1282 items: &mut Vec<SelectedTreeNode>,
1283) {
1284 use crate::tui::widgets::TreeNode;
1285
1286 for node in nodes {
1287 match node {
1288 TreeNode::Group { id, children, .. } => {
1289 items.push(SelectedTreeNode::Group(id.clone()));
1290 if state.is_expanded(id) {
1291 flatten_tree_for_selection(children, state, items);
1292 }
1293 }
1294 TreeNode::Component { id, .. } => {
1295 items.push(SelectedTreeNode::Component(id.clone()));
1296 }
1297 }
1298 }
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1303pub enum ViewTab {
1304 Overview,
1306 Tree,
1308 Vulnerabilities,
1310 Licenses,
1312 Dependencies,
1314 Quality,
1316 Compliance,
1318 Source,
1320}
1321
1322impl ViewTab {
1323 pub fn title(&self) -> &'static str {
1324 match self {
1325 ViewTab::Overview => "Overview",
1326 ViewTab::Tree => "Components",
1327 ViewTab::Vulnerabilities => "Vulnerabilities",
1328 ViewTab::Licenses => "Licenses",
1329 ViewTab::Dependencies => "Dependencies",
1330 ViewTab::Quality => "Quality",
1331 ViewTab::Compliance => "Compliance",
1332 ViewTab::Source => "Source",
1333 }
1334 }
1335
1336 pub fn shortcut(&self) -> &'static str {
1337 match self {
1338 ViewTab::Overview => "1",
1339 ViewTab::Tree => "2",
1340 ViewTab::Vulnerabilities => "3",
1341 ViewTab::Licenses => "4",
1342 ViewTab::Dependencies => "5",
1343 ViewTab::Quality => "6",
1344 ViewTab::Compliance => "7",
1345 ViewTab::Source => "8",
1346 }
1347 }
1348}
1349
1350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1352pub enum TreeGroupBy {
1353 Ecosystem,
1354 License,
1355 VulnStatus,
1356 ComponentType,
1357 Flat,
1358}
1359
1360impl TreeGroupBy {
1361 pub fn label(&self) -> &'static str {
1362 match self {
1363 TreeGroupBy::Ecosystem => "Ecosystem",
1364 TreeGroupBy::License => "License",
1365 TreeGroupBy::VulnStatus => "Vuln Status",
1366 TreeGroupBy::ComponentType => "Type",
1367 TreeGroupBy::Flat => "Flat List",
1368 }
1369 }
1370}
1371
1372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1374pub enum TreeFilter {
1375 All,
1376 HasVulnerabilities,
1377 Critical,
1378}
1379
1380impl TreeFilter {
1381 pub fn label(&self) -> &'static str {
1382 match self {
1383 TreeFilter::All => "All",
1384 TreeFilter::HasVulnerabilities => "Has Vulns",
1385 TreeFilter::Critical => "Critical",
1386 }
1387 }
1388}
1389
1390#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1392pub enum ComponentDetailTab {
1393 #[default]
1394 Overview,
1395 Identifiers,
1396 Vulnerabilities,
1397 Dependencies,
1398}
1399
1400impl ComponentDetailTab {
1401 pub fn title(&self) -> &'static str {
1402 match self {
1403 ComponentDetailTab::Overview => "Overview",
1404 ComponentDetailTab::Identifiers => "Identifiers",
1405 ComponentDetailTab::Vulnerabilities => "Vulnerabilities",
1406 ComponentDetailTab::Dependencies => "Dependencies",
1407 }
1408 }
1409
1410 pub fn shortcut(&self) -> &'static str {
1411 match self {
1412 ComponentDetailTab::Overview => "1",
1413 ComponentDetailTab::Identifiers => "2",
1414 ComponentDetailTab::Vulnerabilities => "3",
1415 ComponentDetailTab::Dependencies => "4",
1416 }
1417 }
1418
1419 pub fn all() -> [ComponentDetailTab; 4] {
1420 [
1421 ComponentDetailTab::Overview,
1422 ComponentDetailTab::Identifiers,
1423 ComponentDetailTab::Vulnerabilities,
1424 ComponentDetailTab::Dependencies,
1425 ]
1426 }
1427}
1428
1429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1431pub enum FocusPanel {
1432 Left,
1433 Right,
1434}
1435
1436#[derive(Debug, Clone)]
1438pub struct VulnExplorerState {
1439 pub selected: usize,
1440 pub total: usize,
1441 pub scroll_offset: usize,
1442 pub group_by: VulnGroupBy,
1443 pub sort_by: VulnSortBy,
1444 pub filter_severity: Option<String>,
1445 pub deduplicate: bool,
1447 cache_key: Option<VulnCacheKey>,
1449 pub cached_data: Option<super::views::VulnCacheRef>,
1451}
1452
1453#[derive(Debug, Clone, PartialEq, Eq)]
1455struct VulnCacheKey {
1456 filter_severity: Option<String>,
1457 deduplicate: bool,
1458}
1459
1460impl VulnExplorerState {
1461 pub fn new() -> Self {
1462 Self {
1463 selected: 0,
1464 total: 0,
1465 scroll_offset: 0,
1466 group_by: VulnGroupBy::Severity,
1467 sort_by: VulnSortBy::Severity,
1468 filter_severity: None,
1469 deduplicate: false,
1470 cache_key: None,
1471 cached_data: None,
1472 }
1473 }
1474
1475 fn current_cache_key(&self) -> VulnCacheKey {
1477 VulnCacheKey {
1478 filter_severity: self.filter_severity.clone(),
1479 deduplicate: self.deduplicate,
1480 }
1481 }
1482
1483 pub fn is_cache_valid(&self) -> bool {
1485 self.cache_key.as_ref() == Some(&self.current_cache_key()) && self.cached_data.is_some()
1486 }
1487
1488 pub fn set_cache(&mut self, cache: super::views::VulnCache) {
1490 self.cache_key = Some(self.current_cache_key());
1491 self.cached_data = Some(std::sync::Arc::new(cache));
1492 }
1493
1494 pub fn invalidate_cache(&mut self) {
1496 self.cache_key = None;
1497 self.cached_data = None;
1498 }
1499
1500 pub fn select_next(&mut self) {
1501 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
1502 self.selected += 1;
1503 }
1504 }
1505
1506 pub fn select_prev(&mut self) {
1507 if self.selected > 0 {
1508 self.selected -= 1;
1509 }
1510 }
1511
1512 pub fn clamp_selection(&mut self) {
1514 if self.total == 0 {
1515 self.selected = 0;
1516 } else if self.selected >= self.total {
1517 self.selected = self.total.saturating_sub(1);
1518 }
1519 }
1520
1521 pub fn toggle_group(&mut self) {
1522 self.group_by = match self.group_by {
1523 VulnGroupBy::Severity => VulnGroupBy::Component,
1524 VulnGroupBy::Component => VulnGroupBy::Flat,
1525 VulnGroupBy::Flat => VulnGroupBy::Severity,
1526 };
1527 self.selected = 0;
1528 }
1529
1530 pub fn toggle_filter(&mut self) {
1531 self.filter_severity = match &self.filter_severity {
1532 None => Some("critical".to_string()),
1533 Some(s) if s == "critical" => Some("high".to_string()),
1534 Some(s) if s == "high" => None,
1535 _ => None,
1536 };
1537 self.selected = 0;
1538 }
1539
1540 pub fn toggle_deduplicate(&mut self) {
1541 self.deduplicate = !self.deduplicate;
1542 self.selected = 0;
1543 }
1544
1545 pub fn get_selected<'a>(
1547 &self,
1548 sbom: &'a NormalizedSbom,
1549 ) -> Option<(String, &'a VulnerabilityRef)> {
1550 let mut idx = 0;
1551 for (comp_id, comp) in &sbom.components {
1552 for vuln in &comp.vulnerabilities {
1553 if let Some(ref filter) = self.filter_severity {
1554 let sev = vuln.severity.as_ref().map(|s| s.to_string().to_lowercase());
1555 if sev.as_deref() != Some(filter) {
1556 continue;
1557 }
1558 }
1559 if idx == self.selected {
1560 return Some((comp_id.value().to_string(), vuln));
1561 }
1562 idx += 1;
1563 }
1564 }
1565 None
1566 }
1567}
1568
1569impl Default for VulnExplorerState {
1570 fn default() -> Self {
1571 Self::new()
1572 }
1573}
1574
1575impl ListNavigation for VulnExplorerState {
1576 fn selected(&self) -> usize {
1577 self.selected
1578 }
1579
1580 fn set_selected(&mut self, idx: usize) {
1581 self.selected = idx;
1582 }
1583
1584 fn total(&self) -> usize {
1585 self.total
1586 }
1587
1588 fn set_total(&mut self, total: usize) {
1589 self.total = total;
1590 }
1591}
1592
1593#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1595pub enum QualityViewMode {
1596 #[default]
1597 Summary,
1598 Breakdown,
1599 Metrics,
1600 Recommendations,
1601}
1602
1603pub struct QualityViewState {
1605 pub view_mode: QualityViewMode,
1606 pub selected_recommendation: usize,
1607 pub total_recommendations: usize,
1608 pub scroll_offset: usize,
1609}
1610
1611impl QualityViewState {
1612 pub fn new(total_recommendations: usize) -> Self {
1613 Self {
1614 view_mode: QualityViewMode::Summary,
1615 selected_recommendation: 0,
1616 total_recommendations,
1617 scroll_offset: 0,
1618 }
1619 }
1620
1621 pub fn toggle_view(&mut self) {
1622 self.view_mode = match self.view_mode {
1623 QualityViewMode::Summary => QualityViewMode::Breakdown,
1624 QualityViewMode::Breakdown => QualityViewMode::Metrics,
1625 QualityViewMode::Metrics => QualityViewMode::Recommendations,
1626 QualityViewMode::Recommendations => QualityViewMode::Summary,
1627 };
1628 self.selected_recommendation = 0;
1629 self.scroll_offset = 0;
1630 }
1631
1632 pub fn select_next(&mut self) {
1633 if self.total_recommendations > 0
1634 && self.selected_recommendation < self.total_recommendations - 1
1635 {
1636 self.selected_recommendation += 1;
1637 }
1638 }
1639
1640 pub fn select_prev(&mut self) {
1641 if self.selected_recommendation > 0 {
1642 self.selected_recommendation -= 1;
1643 }
1644 }
1645
1646 pub fn scroll_down(&mut self) {
1647 self.scroll_offset = self.scroll_offset.saturating_add(1);
1648 }
1649
1650 pub fn scroll_up(&mut self) {
1651 self.scroll_offset = self.scroll_offset.saturating_sub(1);
1652 }
1653}
1654
1655impl Default for QualityViewState {
1656 fn default() -> Self {
1657 Self::new(0)
1658 }
1659}
1660
1661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1663pub enum VulnGroupBy {
1664 Severity,
1665 Component,
1666 Flat,
1667}
1668
1669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1671pub enum VulnSortBy {
1672 Severity,
1673 Cvss,
1674 Component,
1675}
1676
1677#[derive(Debug, Clone)]
1679pub struct LicenseViewState {
1680 pub selected: usize,
1681 pub total: usize,
1682 pub scroll_offset: usize,
1683 pub group_by: LicenseGroupBy,
1684 pub component_scroll: usize,
1686 pub component_total: usize,
1688}
1689
1690impl LicenseViewState {
1691 pub fn new() -> Self {
1692 Self {
1693 selected: 0,
1694 total: 0,
1695 scroll_offset: 0,
1696 group_by: LicenseGroupBy::License,
1697 component_scroll: 0,
1698 component_total: 0,
1699 }
1700 }
1701
1702 pub fn scroll_components_up(&mut self) {
1704 if self.component_scroll > 0 {
1705 self.component_scroll -= 1;
1706 }
1707 }
1708
1709 pub fn scroll_components_down(&mut self, visible_count: usize) {
1711 if self.component_total > visible_count
1712 && self.component_scroll < self.component_total - visible_count
1713 {
1714 self.component_scroll += 1;
1715 }
1716 }
1717
1718 pub fn reset_component_scroll(&mut self) {
1720 self.component_scroll = 0;
1721 }
1722
1723 pub fn select_next(&mut self) {
1724 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
1725 self.selected += 1;
1726 self.reset_component_scroll();
1727 }
1728 }
1729
1730 pub fn select_prev(&mut self) {
1731 if self.selected > 0 {
1732 self.selected -= 1;
1733 self.reset_component_scroll();
1734 }
1735 }
1736
1737 pub fn clamp_selection(&mut self) {
1739 if self.total == 0 {
1740 self.selected = 0;
1741 } else if self.selected >= self.total {
1742 self.selected = self.total.saturating_sub(1);
1743 }
1744 }
1745
1746 pub fn toggle_group(&mut self) {
1747 self.group_by = match self.group_by {
1748 LicenseGroupBy::License => LicenseGroupBy::Category,
1749 LicenseGroupBy::Category => LicenseGroupBy::License,
1750 };
1751 self.selected = 0;
1752 self.reset_component_scroll();
1753 }
1754}
1755
1756impl Default for LicenseViewState {
1757 fn default() -> Self {
1758 Self::new()
1759 }
1760}
1761
1762impl ListNavigation for LicenseViewState {
1763 fn selected(&self) -> usize {
1764 self.selected
1765 }
1766
1767 fn set_selected(&mut self, idx: usize) {
1768 self.selected = idx;
1769 }
1770
1771 fn total(&self) -> usize {
1772 self.total
1773 }
1774
1775 fn set_total(&mut self, total: usize) {
1776 self.total = total;
1777 }
1778}
1779
1780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1782pub enum LicenseGroupBy {
1783 License,
1784 Category,
1785}
1786
1787#[derive(Debug, Clone)]
1789pub struct DependencyViewState {
1790 pub selected: usize,
1792 pub total: usize,
1794 pub expanded: HashSet<String>,
1796 pub scroll_offset: usize,
1798}
1799
1800impl DependencyViewState {
1801 pub fn new() -> Self {
1802 Self {
1803 selected: 0,
1804 total: 0,
1805 expanded: HashSet::new(),
1806 scroll_offset: 0,
1807 }
1808 }
1809
1810 pub fn select_next(&mut self) {
1811 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
1812 self.selected += 1;
1813 }
1814 }
1815
1816 pub fn select_prev(&mut self) {
1817 if self.selected > 0 {
1818 self.selected -= 1;
1819 }
1820 }
1821
1822 pub fn toggle_expand(&mut self, node_id: &str) {
1823 if self.expanded.contains(node_id) {
1824 self.expanded.remove(node_id);
1825 } else {
1826 self.expanded.insert(node_id.to_string());
1827 }
1828 }
1829
1830 pub fn is_expanded(&self, node_id: &str) -> bool {
1831 self.expanded.contains(node_id)
1832 }
1833
1834 pub fn clamp_selection(&mut self) {
1836 if self.total == 0 {
1837 self.selected = 0;
1838 } else if self.selected >= self.total {
1839 self.selected = self.total.saturating_sub(1);
1840 }
1841 }
1842}
1843
1844impl Default for DependencyViewState {
1845 fn default() -> Self {
1846 Self::new()
1847 }
1848}
1849
1850impl ListNavigation for DependencyViewState {
1851 fn selected(&self) -> usize {
1852 self.selected
1853 }
1854
1855 fn set_selected(&mut self, idx: usize) {
1856 self.selected = idx;
1857 }
1858
1859 fn total(&self) -> usize {
1860 self.total
1861 }
1862
1863 fn set_total(&mut self, total: usize) {
1864 self.total = total;
1865 }
1866}
1867
1868#[derive(Debug, Clone)]
1870pub struct SearchState {
1871 pub active: bool,
1872 pub query: String,
1873 pub results: Vec<SearchResult>,
1874 pub selected: usize,
1875}
1876
1877impl SearchState {
1878 pub fn new() -> Self {
1879 Self {
1880 active: false,
1881 query: String::new(),
1882 results: Vec::new(),
1883 selected: 0,
1884 }
1885 }
1886
1887 pub fn push_char(&mut self, c: char) {
1888 self.query.push(c);
1889 }
1890
1891 pub fn pop_char(&mut self) {
1892 self.query.pop();
1893 }
1894
1895 pub fn select_next(&mut self) {
1896 if !self.results.is_empty() && self.selected < self.results.len() - 1 {
1897 self.selected += 1;
1898 }
1899 }
1900
1901 pub fn select_prev(&mut self) {
1902 if self.selected > 0 {
1903 self.selected -= 1;
1904 }
1905 }
1906}
1907
1908impl Default for SearchState {
1909 fn default() -> Self {
1910 Self::new()
1911 }
1912}
1913
1914#[derive(Debug, Clone)]
1916pub enum SearchResult {
1917 Component {
1918 id: String,
1919 name: String,
1920 version: Option<String>,
1921 match_field: String,
1922 },
1923 Vulnerability {
1924 id: String,
1925 component_id: String,
1927 component_name: String,
1929 severity: Option<String>,
1930 },
1931}
1932
1933#[derive(Debug, Clone)]
1935pub struct SbomStats {
1936 pub component_count: usize,
1937 pub vuln_count: usize,
1938 pub license_count: usize,
1939 pub ecosystem_counts: HashMap<String, usize>,
1940 pub vuln_by_severity: HashMap<String, usize>,
1941 pub license_counts: HashMap<String, usize>,
1942 pub critical_count: usize,
1943 pub high_count: usize,
1944 pub medium_count: usize,
1945 pub low_count: usize,
1946 pub unknown_count: usize,
1947}
1948
1949impl SbomStats {
1950 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1951 let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
1952 let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
1953 let mut license_counts: HashMap<String, usize> = HashMap::new();
1954 let mut vuln_count = 0;
1955 let mut critical_count = 0;
1956 let mut high_count = 0;
1957 let mut medium_count = 0;
1958 let mut low_count = 0;
1959 let mut unknown_count = 0;
1960
1961 for comp in sbom.components.values() {
1962 let eco = comp
1964 .ecosystem
1965 .as_ref()
1966 .map(|e| e.to_string())
1967 .unwrap_or_else(|| "Unknown".to_string());
1968 *ecosystem_counts.entry(eco).or_insert(0) += 1;
1969
1970 for lic in &comp.licenses.declared {
1972 *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
1973 }
1974 if comp.licenses.declared.is_empty() {
1975 *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
1976 }
1977
1978 for vuln in &comp.vulnerabilities {
1980 vuln_count += 1;
1981 let sev = vuln
1982 .severity
1983 .as_ref()
1984 .map(|s| s.to_string())
1985 .unwrap_or_else(|| "Unknown".to_string());
1986 *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
1987
1988 match sev.to_lowercase().as_str() {
1989 "critical" => critical_count += 1,
1990 "high" => high_count += 1,
1991 "medium" => medium_count += 1,
1992 "low" => low_count += 1,
1993 _ => unknown_count += 1,
1994 }
1995 }
1996 }
1997
1998 Self {
1999 component_count: sbom.components.len(),
2000 vuln_count,
2001 license_count: license_counts.len(),
2002 ecosystem_counts,
2003 vuln_by_severity,
2004 license_counts,
2005 critical_count,
2006 high_count,
2007 medium_count,
2008 low_count,
2009 unknown_count,
2010 }
2011 }
2012}
2013
2014#[derive(Debug, Clone)]
2016pub struct ViewBreadcrumb {
2017 pub tab: ViewTab,
2019 pub label: String,
2021 pub selection_index: usize,
2023}
2024
2025#[derive(Debug, Clone, Default)]
2027pub struct ViewNavigationContext {
2028 pub breadcrumbs: Vec<ViewBreadcrumb>,
2030 pub target_component: Option<String>,
2032 pub target_vulnerability: Option<String>,
2034}
2035
2036impl ViewNavigationContext {
2037 pub fn new() -> Self {
2038 Self {
2039 breadcrumbs: Vec::new(),
2040 target_component: None,
2041 target_vulnerability: None,
2042 }
2043 }
2044
2045 pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
2047 self.breadcrumbs.push(ViewBreadcrumb {
2048 tab,
2049 label,
2050 selection_index,
2051 });
2052 }
2053
2054 pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
2056 self.breadcrumbs.pop()
2057 }
2058
2059 pub fn clear_breadcrumbs(&mut self) {
2061 self.breadcrumbs.clear();
2062 }
2063
2064 pub fn has_history(&self) -> bool {
2066 !self.breadcrumbs.is_empty()
2067 }
2068
2069 pub fn breadcrumb_trail(&self) -> String {
2071 self.breadcrumbs
2072 .iter()
2073 .map(|b| format!("{}: {}", b.tab.title(), b.label))
2074 .collect::<Vec<_>>()
2075 .join(" > ")
2076 }
2077
2078 pub fn clear_targets(&mut self) {
2080 self.target_component = None;
2081 self.target_vulnerability = None;
2082 }
2083}
2084
2085#[cfg(test)]
2086mod tests {
2087 use super::*;
2088 use crate::model::NormalizedSbom;
2089
2090 #[test]
2091 fn test_view_app_creation() {
2092 let sbom = NormalizedSbom::default();
2093 let app = ViewApp::new(sbom, String::new());
2094 assert_eq!(app.active_tab, ViewTab::Overview);
2095 assert!(!app.should_quit);
2096 }
2097
2098 #[test]
2099 fn test_tab_navigation() {
2100 let sbom = NormalizedSbom::default();
2101 let mut app = ViewApp::new(sbom, String::new());
2102
2103 app.next_tab();
2104 assert_eq!(app.active_tab, ViewTab::Tree);
2105
2106 app.next_tab();
2107 assert_eq!(app.active_tab, ViewTab::Vulnerabilities);
2108
2109 app.prev_tab();
2110 assert_eq!(app.active_tab, ViewTab::Tree);
2111 }
2112
2113 #[test]
2114 fn test_vuln_state_navigation_with_zero_total() {
2115 let mut state = VulnExplorerState::new();
2117 assert_eq!(state.total, 0);
2118 assert_eq!(state.selected, 0);
2119
2120 state.select_next();
2122 assert_eq!(state.selected, 0);
2123
2124 state.select_prev();
2125 assert_eq!(state.selected, 0);
2126 }
2127
2128 #[test]
2129 fn test_vuln_state_clamp_selection() {
2130 let mut state = VulnExplorerState::new();
2131 state.total = 5;
2132 state.selected = 10; state.clamp_selection();
2135 assert_eq!(state.selected, 4); state.total = 0;
2138 state.clamp_selection();
2139 assert_eq!(state.selected, 0); }
2141
2142 #[test]
2143 fn test_license_state_navigation_with_zero_total() {
2144 let mut state = LicenseViewState::new();
2145 assert_eq!(state.total, 0);
2146 assert_eq!(state.selected, 0);
2147
2148 state.select_next();
2150 assert_eq!(state.selected, 0);
2151
2152 state.select_prev();
2153 assert_eq!(state.selected, 0);
2154 }
2155
2156 #[test]
2157 fn test_license_state_clamp_selection() {
2158 let mut state = LicenseViewState::new();
2159 state.total = 3;
2160 state.selected = 5; state.clamp_selection();
2163 assert_eq!(state.selected, 2); }
2165
2166 #[test]
2167 fn test_dependency_state_navigation() {
2168 let mut state = DependencyViewState::new();
2169 assert_eq!(state.total, 0);
2170 assert_eq!(state.selected, 0);
2171
2172 state.select_next();
2174 assert_eq!(state.selected, 0);
2175
2176 state.total = 5;
2178 state.select_next();
2179 assert_eq!(state.selected, 1);
2180
2181 state.select_next();
2182 state.select_next();
2183 state.select_next();
2184 assert_eq!(state.selected, 4); state.select_next();
2187 assert_eq!(state.selected, 4); state.select_prev();
2190 assert_eq!(state.selected, 3);
2191 }
2192
2193 #[test]
2194 fn test_dependency_state_expand_collapse() {
2195 let mut state = DependencyViewState::new();
2196
2197 assert!(!state.is_expanded("node1"));
2198
2199 state.toggle_expand("node1");
2200 assert!(state.is_expanded("node1"));
2201
2202 state.toggle_expand("node1");
2203 assert!(!state.is_expanded("node1"));
2204 }
2205}