1use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex, VulnerabilityRef};
7use crate::quality::{ComplianceResult, QualityReport, QualityScorer};
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::{StandardComplianceState, compute_compliance_results};
14
15pub struct ViewApp {
17 pub(crate) sbom: NormalizedSbom,
19
20 pub(crate) bom_profile: crate::model::BomProfile,
22
23 pub(crate) active_tab: ViewTab,
25
26 pub(crate) tree_state: TreeState,
28
29 pub(crate) tree_group_by: TreeGroupBy,
31
32 pub(crate) tree_filter: TreeFilter,
34
35 pub(crate) tree_search_query: String,
37
38 pub(crate) tree_search_active: bool,
40
41 pub(crate) cached_tree_nodes: Vec<crate::tui::widgets::TreeNode>,
43 tree_cache_key: Option<TreeCacheKey>,
45
46 pub(crate) selected_component: Option<String>,
48
49 pub(crate) component_tab: ComponentDetailTab,
51
52 pub(crate) component_detail_scroll: u16,
54
55 pub(crate) vuln_state: VulnExplorerState,
57
58 pub(crate) license_state: LicenseViewState,
60
61 pub(crate) dependency_state: DependencyViewState,
63
64 pub(crate) search_state: SearchState,
66
67 pub(crate) focus_panel: FocusPanel,
69
70 pub(crate) show_help: bool,
72
73 pub(crate) show_export: bool,
75
76 pub(crate) show_legend: bool,
78
79 pub(crate) status_message: Option<String>,
81
82 pub(crate) status_sticky: bool,
84
85 pub(crate) navigation_ctx: ViewNavigationContext,
87
88 pub(crate) should_quit: bool,
90
91 pub(crate) tick: u64,
93
94 pub(crate) stats: SbomStats,
96
97 pub(crate) quality_report: QualityReport,
99
100 pub(crate) quality_state: QualityViewState,
102
103 pub(crate) compliance_results: Option<Vec<ComplianceResult>>,
105
106 pub(crate) compliance_state: StandardComplianceState,
108
109 pub(crate) sbom_index: NormalizedSbomIndex,
111
112 pub(crate) source_state: SourcePanelState,
114
115 pub(crate) crypto_list_selected: usize,
117 pub(crate) algorithms_selected: usize,
119 pub(crate) certificates_selected: usize,
121 pub(crate) keys_selected: usize,
123 pub(crate) protocols_selected: usize,
125
126 pub(crate) algorithm_sort_by: AlgorithmSortBy,
128
129 pub(crate) models_selected: usize,
131 pub(crate) datasets_selected: usize,
133
134 pub(crate) bookmarked: HashSet<String>,
136
137 pub(crate) export_template: Option<String>,
139
140 pub(crate) cra_sidecar: Option<crate::model::CraSidecarMetadata>,
145}
146
147impl ViewApp {
148 #[must_use]
150 pub fn new(
151 sbom: NormalizedSbom,
152 raw_content: &str,
153 bom_profile: crate::model::BomProfile,
154 ) -> Self {
155 let stats = SbomStats::from_sbom(&sbom);
156
157 let scoring_profile = crate::tui::scoring_profile_for(bom_profile);
159 let scorer = QualityScorer::new(scoring_profile);
160 let quality_report = scorer.score(&sbom);
161 let quality_state = QualityViewState::new(quality_report.recommendations.len());
162
163 let compliance_state = StandardComplianceState::new();
164
165 let sbom_index = sbom.build_index();
167
168 let source_state = SourcePanelState::new(raw_content);
170
171 let mut tree_state = TreeState::new();
173 for eco in stats.ecosystem_counts.keys().take(3) {
174 tree_state.expand(&format!("eco:{eco}"));
175 }
176
177 let available_tabs = ViewTab::tabs_for_profile(bom_profile);
179 let initial_tab = crate::config::TuiPreferences::load()
180 .last_view_tab
181 .as_deref()
182 .and_then(ViewTab::from_str_opt)
183 .filter(|t| available_tabs.contains(t))
184 .unwrap_or(ViewTab::Overview);
185
186 let mut app = Self {
187 sbom,
188 bom_profile,
189 active_tab: initial_tab,
190 tree_state,
191 tree_group_by: TreeGroupBy::Ecosystem,
192 tree_filter: TreeFilter::All,
193 tree_search_query: String::new(),
194 tree_search_active: false,
195 cached_tree_nodes: Vec::new(),
196 tree_cache_key: None,
197 selected_component: None,
198 component_tab: ComponentDetailTab::Overview,
199 component_detail_scroll: 0,
200 vuln_state: VulnExplorerState::new(),
201 license_state: LicenseViewState::new(),
202 dependency_state: DependencyViewState::new(),
203 search_state: SearchState::new(),
204 focus_panel: FocusPanel::Left,
205 show_help: false,
206 show_export: false,
207 show_legend: false,
208 status_message: None,
209 status_sticky: false,
210 navigation_ctx: ViewNavigationContext::new(),
211 should_quit: false,
212 tick: 0,
213 stats,
214 quality_report,
215 quality_state,
216 compliance_results: None,
217 compliance_state,
218 sbom_index,
219 source_state,
220 crypto_list_selected: 0,
221 algorithms_selected: 0,
222 certificates_selected: 0,
223 keys_selected: 0,
224 protocols_selected: 0,
225 algorithm_sort_by: AlgorithmSortBy::default(),
226 models_selected: 0,
227 datasets_selected: 0,
228 bookmarked: HashSet::new(),
229 export_template: None,
230 cra_sidecar: None,
231 };
232
233 let cache = super::views::build_vuln_cache(&app);
235 app.vuln_state.set_cache(cache);
236
237 app
238 }
239
240 pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
244 self.compliance_results = None;
246 self.cra_sidecar = Some(sidecar);
247 self
248 }
249
250 pub fn ensure_compliance_results(&mut self) {
252 if self.compliance_results.is_none() {
253 self.compliance_results = Some(compute_compliance_results(
254 &self.sbom,
255 self.cra_sidecar.as_ref(),
256 ));
257 }
258 }
259
260 pub fn next_tab(&mut self) {
262 let tabs = ViewTab::tabs_for_profile(self.bom_profile);
263 let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
264 self.active_tab = tabs[(idx + 1) % tabs.len()];
265 self.focus_panel = FocusPanel::Left;
266 }
267
268 pub fn prev_tab(&mut self) {
270 let tabs = ViewTab::tabs_for_profile(self.bom_profile);
271 let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
272 self.active_tab = tabs[(idx + tabs.len() - 1) % tabs.len()];
273 self.focus_panel = FocusPanel::Left;
274 }
275
276 pub fn select_tab(&mut self, tab: ViewTab) {
278 self.active_tab = tab;
279 self.focus_panel = FocusPanel::Left;
280 }
281
282 pub fn active_crypto_selected(&self) -> usize {
288 match self.active_tab {
289 ViewTab::Algorithms => self.algorithms_selected,
290 ViewTab::Certificates => self.certificates_selected,
291 ViewTab::Keys => self.keys_selected,
292 ViewTab::Protocols => self.protocols_selected,
293 _ => self.crypto_list_selected,
294 }
295 }
296
297 pub fn active_crypto_selected_mut(&mut self) -> &mut usize {
299 match self.active_tab {
300 ViewTab::Algorithms => &mut self.algorithms_selected,
301 ViewTab::Certificates => &mut self.certificates_selected,
302 ViewTab::Keys => &mut self.keys_selected,
303 ViewTab::Protocols => &mut self.protocols_selected,
304 _ => &mut self.crypto_list_selected,
305 }
306 }
307
308 pub fn crypto_count_for_tab(&self) -> usize {
310 use crate::model::{ComponentType, CryptoAssetType};
311 let filter = match self.active_tab {
312 ViewTab::Algorithms => Some(CryptoAssetType::Algorithm),
313 ViewTab::Certificates => Some(CryptoAssetType::Certificate),
314 ViewTab::Keys => Some(CryptoAssetType::RelatedCryptoMaterial),
315 ViewTab::Protocols => Some(CryptoAssetType::Protocol),
316 _ => None,
317 };
318 self.sbom
319 .components
320 .values()
321 .filter(|c| {
322 c.component_type == ComponentType::Cryptographic
323 && filter.as_ref().is_none_or(|f| {
324 c.crypto_properties
325 .as_ref()
326 .is_some_and(|cp| &cp.asset_type == f)
327 })
328 })
329 .count()
330 }
331
332 #[must_use]
338 pub fn ml_model_count(&self) -> usize {
339 use crate::model::ComponentType;
340 self.sbom
341 .components
342 .values()
343 .filter(|c| c.component_type == ComponentType::MachineLearningModel)
344 .count()
345 }
346
347 #[must_use]
349 pub fn dataset_count(&self) -> usize {
350 use crate::model::ComponentType;
351 self.sbom
352 .components
353 .values()
354 .filter(|c| c.component_type == ComponentType::Data)
355 .count()
356 }
357
358 #[must_use]
366 pub fn get_sort_key(
367 &self,
368 id: &crate::model::CanonicalId,
369 ) -> Option<&crate::model::ComponentSortKey> {
370 self.sbom_index.sort_key(id)
371 }
372
373 #[must_use]
375 pub fn get_dependencies(
376 &self,
377 id: &crate::model::CanonicalId,
378 ) -> Vec<&crate::model::DependencyEdge> {
379 self.sbom_index.dependencies_of(id, &self.sbom.edges)
380 }
381
382 #[must_use]
384 pub fn get_dependents(
385 &self,
386 id: &crate::model::CanonicalId,
387 ) -> Vec<&crate::model::DependencyEdge> {
388 self.sbom_index.dependents_of(id, &self.sbom.edges)
389 }
390
391 #[must_use]
393 pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
394 self.sbom.search_by_name_indexed(query, &self.sbom_index)
395 }
396
397 pub const fn toggle_focus(&mut self) {
399 self.focus_panel = match self.focus_panel {
400 FocusPanel::Left => FocusPanel::Right,
401 FocusPanel::Right => FocusPanel::Left,
402 };
403 }
404
405 pub fn start_search(&mut self) {
407 self.search_state.active = true;
408 self.search_state.query.clear();
409 self.search_state.results.clear();
410 }
411
412 pub const fn stop_search(&mut self) {
414 self.search_state.active = false;
415 }
416
417 pub fn execute_search(&mut self) {
419 self.search_state.results = self.search(&self.search_state.query.clone());
420 self.search_state.selected = 0;
421 }
422
423 fn search(&self, query: &str) -> Vec<SearchResult> {
425 if query.len() < 2 {
426 return Vec::new();
427 }
428
429 let query_lower = query.to_lowercase();
430 let mut results = Vec::new();
431
432 for (id, comp) in &self.sbom.components {
434 if comp.name.to_lowercase().contains(&query_lower) {
435 results.push(SearchResult::Component {
436 id: id.value().to_string(),
437 name: comp.name.clone(),
438 version: comp.version.clone(),
439 match_field: "name".to_string(),
440 });
441 } else if let Some(purl) = &comp.identifiers.purl
442 && purl.to_lowercase().contains(&query_lower)
443 {
444 results.push(SearchResult::Component {
445 id: id.value().to_string(),
446 name: comp.name.clone(),
447 version: comp.version.clone(),
448 match_field: "purl".to_string(),
449 });
450 }
451 }
452
453 for (_, comp) in &self.sbom.components {
455 for vuln in &comp.vulnerabilities {
456 if vuln.id.to_lowercase().contains(&query_lower) {
457 results.push(SearchResult::Vulnerability {
458 id: vuln.id.clone(),
459 component_id: comp.canonical_id.to_string(), component_name: comp.name.clone(),
461 severity: vuln.severity.as_ref().map(std::string::ToString::to_string),
462 });
463 }
464 }
465 }
466
467 results.truncate(50);
469 results
470 }
471
472 #[must_use]
474 pub fn get_selected_component(&self) -> Option<&Component> {
475 self.selected_component.as_ref().and_then(|selected_id| {
476 self.sbom
477 .components
478 .iter()
479 .find(|(id, _)| id.value() == selected_id)
480 .map(|(_, comp)| comp)
481 })
482 }
483
484 pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
486 let group_id = {
487 let Some(comp) = self
488 .sbom
489 .components
490 .iter()
491 .find(|(id, _)| id.value() == component_id)
492 .map(|(_, comp)| comp)
493 else {
494 return false;
495 };
496 self.tree_group_id_for_component(comp)
497 };
498 if let Some(ref group_id) = group_id {
499 self.tree_state.expand(group_id);
500 }
501
502 self.ensure_tree_cache();
503 let mut flat_items = Vec::new();
504 flatten_tree_for_selection(&self.cached_tree_nodes, &self.tree_state, &mut flat_items);
505
506 if let Some(index) = flat_items
507 .iter()
508 .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
509 {
510 self.tree_state.selected = index;
511 return true;
512 }
513
514 if let Some(group_id) = group_id
515 && let Some(index) = flat_items
516 .iter()
517 .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
518 {
519 self.tree_state.selected = index;
520 }
521
522 false
523 }
524
525 pub fn jump_to_vuln_by_id(&mut self, vuln_id: &str) -> bool {
529 if self.vuln_state.cached_data.is_none() {
531 let cache = super::views::build_vuln_cache(self);
532 self.vuln_state.cached_data = Some(std::sync::Arc::new(cache));
533 }
534 let Some(cache) = &self.vuln_state.cached_data else {
535 return false;
536 };
537 if let Some(vuln_idx) = cache.vulns.iter().position(|v| v.vuln_id == vuln_id) {
539 let display_idx = self
542 .vuln_state
543 .cached_display_items
544 .iter()
545 .position(|item| {
546 matches!(item, super::views::VulnDisplayItem::Vuln { idx, .. } if *idx == vuln_idx)
547 })
548 .unwrap_or(vuln_idx);
549 self.vuln_state.selected = display_idx;
550 return true;
551 }
552 false
553 }
554
555 pub fn find_source_item_for_ref(&mut self, ref_value: &str) -> Option<usize> {
558 self.source_state.ensure_flat_cache();
559 let quoted = format!("\"{ref_value}\"");
560 self.source_state
561 .cached_flat_items
562 .iter()
563 .position(|item| item.value_preview == quoted || item.value_preview == ref_value)
564 }
565
566 #[must_use]
569 pub fn get_selected_group_info(&self) -> Option<(String, Vec<String>)> {
570 let nodes = self.build_tree_nodes();
571 let mut flat_items = Vec::new();
572 flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
573
574 let selected = flat_items.get(self.tree_state.selected)?;
575 match selected {
576 SelectedTreeNode::Group(group_id) => {
577 fn find_group_children(
579 nodes: &[crate::tui::widgets::TreeNode],
580 target_id: &str,
581 ) -> Option<(String, Vec<String>)> {
582 for node in nodes {
583 if let crate::tui::widgets::TreeNode::Group {
584 id,
585 label,
586 children,
587 ..
588 } = node
589 {
590 if id == target_id {
591 let child_ids: Vec<String> = children
592 .iter()
593 .filter_map(|c| match c {
594 crate::tui::widgets::TreeNode::Component { id, .. } => {
595 Some(id.clone())
596 }
597 crate::tui::widgets::TreeNode::Group { .. } => None,
598 })
599 .collect();
600 return Some((label.clone(), child_ids));
601 }
602 if let Some(result) = find_group_children(children, target_id) {
604 return Some(result);
605 }
606 }
607 }
608 None
609 }
610 find_group_children(nodes, group_id)
611 }
612 SelectedTreeNode::Component(_) => None,
613 }
614 }
615
616 pub fn toggle_bookmark(&mut self) {
618 if let Some(ref comp_id) = self.selected_component {
619 if self.bookmarked.contains(comp_id) {
620 self.bookmarked.remove(comp_id);
621 } else {
622 self.bookmarked.insert(comp_id.clone());
623 }
624 } else if let Some(node) = self.get_selected_tree_node() {
625 match node {
626 SelectedTreeNode::Component(id) => {
627 if self.bookmarked.contains(&id) {
628 self.bookmarked.remove(&id);
629 } else {
630 self.bookmarked.insert(id);
631 }
632 }
633 SelectedTreeNode::Group(_) => {}
634 }
635 }
636 }
637
638 pub fn toggle_tree_grouping(&mut self) {
640 self.tree_group_by = match self.tree_group_by {
641 TreeGroupBy::Ecosystem => TreeGroupBy::License,
642 TreeGroupBy::License => TreeGroupBy::VulnStatus,
643 TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
644 TreeGroupBy::ComponentType => TreeGroupBy::Flat,
645 TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
646 };
647 self.tree_state = TreeState::new(); }
649
650 pub fn toggle_tree_filter(&mut self) {
652 self.tree_filter = match self.tree_filter {
653 TreeFilter::All => TreeFilter::HasVulnerabilities,
654 TreeFilter::HasVulnerabilities => TreeFilter::Critical,
655 TreeFilter::Critical => TreeFilter::Bookmarked,
656 TreeFilter::Bookmarked => TreeFilter::All,
657 };
658 self.tree_state = TreeState::new();
659 }
660
661 pub fn start_tree_search(&mut self) {
663 self.tree_search_active = true;
664 self.tree_search_query.clear();
665 }
666
667 pub const fn stop_tree_search(&mut self) {
669 self.tree_search_active = false;
670 }
671
672 pub fn clear_tree_search(&mut self) {
674 self.tree_search_query.clear();
675 self.tree_search_active = false;
676 self.tree_state = TreeState::new();
677 }
678
679 pub fn tree_search_push_char(&mut self, c: char) {
681 self.tree_search_query.push(c);
682 self.tree_state = TreeState::new();
683 }
684
685 pub fn tree_search_pop_char(&mut self) {
687 self.tree_search_query.pop();
688 self.tree_state = TreeState::new();
689 }
690
691 pub const fn next_component_tab(&mut self) {
693 self.component_tab = match self.component_tab {
694 ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
695 ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
696 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
697 ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
698 };
699 self.component_detail_scroll = 0;
700 }
701
702 pub const fn prev_component_tab(&mut self) {
704 self.component_tab = match self.component_tab {
705 ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
706 ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
707 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
708 ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
709 };
710 self.component_detail_scroll = 0;
711 }
712
713 pub(crate) const fn select_component_tab(&mut self, tab: ComponentDetailTab) {
715 self.component_tab = tab;
716 self.component_detail_scroll = 0;
717 }
718
719 pub const fn toggle_help(&mut self) {
721 self.show_help = !self.show_help;
722 if self.show_help {
723 self.show_export = false;
724 self.show_legend = false;
725 }
726 }
727
728 pub const fn toggle_export(&mut self) {
730 self.show_export = !self.show_export;
731 if self.show_export {
732 self.show_help = false;
733 self.show_legend = false;
734 }
735 }
736
737 pub const fn toggle_legend(&mut self) {
739 self.show_legend = !self.show_legend;
740 if self.show_legend {
741 self.show_help = false;
742 self.show_export = false;
743 }
744 }
745
746 pub const fn close_overlays(&mut self) {
748 self.show_help = false;
749 self.show_export = false;
750 self.show_legend = false;
751 self.search_state.active = false;
752 self.compliance_state.show_detail = false;
753 }
754
755 #[must_use]
757 pub const fn has_overlay(&self) -> bool {
758 self.show_help
759 || self.show_export
760 || self.show_legend
761 || self.search_state.active
762 || self.compliance_state.show_detail
763 }
764
765 pub fn set_status_message(&mut self, msg: impl Into<String>) {
767 self.status_message = Some(msg.into());
768 }
769
770 pub fn clear_status_message(&mut self) {
775 if self.status_sticky {
776 self.status_sticky = false;
777 } else {
778 self.status_message = None;
779 }
780 }
781
782 pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
787 use crate::reports::ReportConfig;
788 use crate::tui::export::{export_view, view_tab_to_report_type};
789
790 let report_type = view_tab_to_report_type(self.active_tab);
791 let config = ReportConfig::with_types(vec![report_type]);
792 let result = export_view(
793 format,
794 &self.sbom,
795 None,
796 &config,
797 self.export_template.as_deref(),
798 );
799
800 if result.success {
801 self.set_status_message(result.message);
802 self.status_sticky = true;
803 } else {
804 self.set_status_message(format!("Export failed: {}", result.message));
805 }
806 }
807
808 pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
810 use crate::tui::export::export_compliance;
811
812 self.ensure_compliance_results();
813 let results = match self.compliance_results.as_ref() {
814 Some(r) if !r.is_empty() => r,
815 _ => {
816 self.set_status_message("No compliance results to export");
817 return;
818 }
819 };
820
821 let result = export_compliance(
822 format,
823 results,
824 self.compliance_state.selected_standard,
825 None,
826 self.export_template.as_deref(),
827 );
828 if result.success {
829 self.set_status_message(result.message);
830 self.status_sticky = true;
831 } else {
832 self.set_status_message(format!("Export failed: {}", result.message));
833 }
834 }
835
836 pub fn go_back(&mut self) -> bool {
838 if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
839 self.active_tab = breadcrumb.tab;
840 match breadcrumb.tab {
842 ViewTab::Vulnerabilities => {
843 self.vuln_state.selected = breadcrumb.selection_index;
844 }
845 ViewTab::Licenses => {
846 self.license_state.selected = breadcrumb.selection_index;
847 }
848 ViewTab::Dependencies => {
849 self.dependency_state.selected = breadcrumb.selection_index;
850 }
851 ViewTab::Tree => {
852 self.tree_state.selected = breadcrumb.selection_index;
853 }
854 ViewTab::Source => {
855 self.source_state.selected = breadcrumb.selection_index;
856 }
857 _ => {}
858 }
859 self.focus_panel = FocusPanel::Left;
860 true
861 } else {
862 false
863 }
864 }
865
866 pub fn navigate_up(&mut self) {
868 match self.active_tab {
869 ViewTab::Tree => self.tree_state.select_prev(),
870 ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
871 ViewTab::Licenses => self.license_state.select_prev(),
872 ViewTab::Dependencies => self.dependency_state.select_prev(),
873 ViewTab::Quality => self.quality_state.select_prev(),
874 ViewTab::Compliance => self.compliance_state.select_prev(),
875 ViewTab::Source => self.source_state.select_prev(),
876 ViewTab::Crypto
877 | ViewTab::Algorithms
878 | ViewTab::Certificates
879 | ViewTab::Keys
880 | ViewTab::Protocols => {
881 let sel = self.active_crypto_selected_mut();
882 *sel = sel.saturating_sub(1);
883 }
884 ViewTab::Models => {
885 self.models_selected = self.models_selected.saturating_sub(1);
886 }
887 ViewTab::Datasets => {
888 self.datasets_selected = self.datasets_selected.saturating_sub(1);
889 }
890 ViewTab::Overview | ViewTab::PqcCompliance | ViewTab::AiReadiness => {}
891 }
892 }
893
894 pub fn navigate_down(&mut self) {
896 match self.active_tab {
897 ViewTab::Tree => self.tree_state.select_next(),
898 ViewTab::Vulnerabilities => self.vuln_state.select_next(),
899 ViewTab::Licenses => self.license_state.select_next(),
900 ViewTab::Dependencies => self.dependency_state.select_next(),
901 ViewTab::Quality => self.quality_state.select_next(),
902 ViewTab::Compliance => {
903 self.ensure_compliance_results();
904 let max = self.filtered_compliance_violation_count();
905 self.compliance_state.select_next(max);
906 }
907 ViewTab::Source => self.source_state.select_next(),
908 ViewTab::Crypto
909 | ViewTab::Algorithms
910 | ViewTab::Certificates
911 | ViewTab::Keys
912 | ViewTab::Protocols => {
913 let max = self.crypto_count_for_tab().saturating_sub(1);
914 let sel = self.active_crypto_selected_mut();
915 *sel = sel.saturating_add(1).min(max);
916 }
917 ViewTab::Models => {
918 let max = self.ml_model_count().saturating_sub(1);
919 self.models_selected = self.models_selected.saturating_add(1).min(max);
920 }
921 ViewTab::Datasets => {
922 let max = self.dataset_count().saturating_sub(1);
923 self.datasets_selected = self.datasets_selected.saturating_add(1).min(max);
924 }
925 ViewTab::Overview | ViewTab::PqcCompliance | ViewTab::AiReadiness => {}
926 }
927 }
928
929 pub(crate) fn filtered_compliance_violation_count(&self) -> usize {
931 self.compliance_results
932 .as_ref()
933 .and_then(|r| r.get(self.compliance_state.selected_standard))
934 .map_or(0, |r| {
935 if self.compliance_state.grouped {
936 super::views::build_groups(r, self.compliance_state.severity_filter).len()
938 } else {
939 r.violations
940 .iter()
941 .filter(|v| self.compliance_state.severity_filter.matches(v.severity))
942 .count()
943 }
944 })
945 }
946
947 pub fn page_up(&mut self) {
949 use crate::tui::constants::PAGE_SIZE;
950 if self.active_tab == ViewTab::Source {
951 self.source_state.page_up();
952 } else {
953 for _ in 0..PAGE_SIZE {
954 self.navigate_up();
955 }
956 }
957 }
958
959 pub fn page_down(&mut self) {
961 use crate::tui::constants::PAGE_SIZE;
962 if self.active_tab == ViewTab::Source {
963 self.source_state.page_down();
964 } else {
965 for _ in 0..PAGE_SIZE {
966 self.navigate_down();
967 }
968 }
969 }
970
971 pub fn go_first(&mut self) {
973 match self.active_tab {
974 ViewTab::Tree => self.tree_state.select_first(),
975 ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
976 ViewTab::Licenses => self.license_state.selected = 0,
977 ViewTab::Dependencies => self.dependency_state.selected = 0,
978 ViewTab::Quality => self.quality_state.scroll_offset = 0,
979 ViewTab::Compliance => self.compliance_state.selected_violation = 0,
980 ViewTab::Source => self.source_state.select_first(),
981 ViewTab::Crypto
982 | ViewTab::Algorithms
983 | ViewTab::Certificates
984 | ViewTab::Keys
985 | ViewTab::Protocols => *self.active_crypto_selected_mut() = 0,
986 ViewTab::Models => self.models_selected = 0,
987 ViewTab::Datasets => self.datasets_selected = 0,
988 ViewTab::Overview | ViewTab::PqcCompliance | ViewTab::AiReadiness => {}
989 }
990 }
991
992 pub fn go_last(&mut self) {
994 match self.active_tab {
995 ViewTab::Tree => self.tree_state.select_last(),
996 ViewTab::Vulnerabilities => {
997 self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
998 }
999 ViewTab::Licenses => {
1000 self.license_state.selected = self.license_state.total.saturating_sub(1);
1001 }
1002 ViewTab::Dependencies => {
1003 self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
1004 }
1005 ViewTab::Quality => {
1006 self.quality_state.scroll_offset =
1007 self.quality_state.total_recommendations.saturating_sub(1);
1008 }
1009 ViewTab::Compliance => {
1010 self.ensure_compliance_results();
1011 let max = self.filtered_compliance_violation_count();
1012 self.compliance_state.selected_violation = max.saturating_sub(1);
1013 }
1014 ViewTab::Source => self.source_state.select_last(),
1015 ViewTab::Crypto
1016 | ViewTab::Algorithms
1017 | ViewTab::Certificates
1018 | ViewTab::Keys
1019 | ViewTab::Protocols => {
1020 let max = self.crypto_count_for_tab();
1021 *self.active_crypto_selected_mut() = max.saturating_sub(1);
1022 }
1023 ViewTab::Models => self.models_selected = self.ml_model_count().saturating_sub(1),
1024 ViewTab::Datasets => self.datasets_selected = self.dataset_count().saturating_sub(1),
1025 ViewTab::Overview | ViewTab::PqcCompliance | ViewTab::AiReadiness => {}
1026 }
1027 }
1028
1029 pub fn handle_enter(&mut self) {
1031 match self.active_tab {
1032 ViewTab::Tree => {
1033 if let Some(node) = self.get_selected_tree_node() {
1035 match node {
1036 SelectedTreeNode::Group(id) => {
1037 self.tree_state.toggle_expand(&id);
1038 }
1039 SelectedTreeNode::Component(id) => {
1040 self.selected_component = Some(id);
1041 self.focus_panel = FocusPanel::Right;
1042 self.component_tab = ComponentDetailTab::Overview;
1043 }
1044 }
1045 }
1046 }
1047 ViewTab::Vulnerabilities => {
1048 if self.vuln_state.group_by != VulnGroupBy::Flat
1050 && let Some(item) = self
1051 .vuln_state
1052 .cached_display_items
1053 .get(self.vuln_state.selected)
1054 {
1055 match item {
1056 super::views::VulnDisplayItem::GroupHeader { label, .. } => {
1057 let label = label.clone();
1058 self.vuln_state.toggle_vuln_group(&label);
1059 return;
1060 }
1061 super::views::VulnDisplayItem::SubGroupHeader {
1062 parent_label,
1063 label,
1064 ..
1065 } => {
1066 let key = format!("{parent_label}::{label}");
1067 self.vuln_state.toggle_vuln_group(&key);
1068 return;
1069 }
1070 super::views::VulnDisplayItem::Vuln { .. } => {
1071 }
1073 }
1074 }
1075 if let Some(cache) = &self.vuln_state.cached_data.clone()
1077 && let Some((comp_id, vuln_id)) = self.vuln_state.get_nav_component_id(cache)
1078 {
1079 self.navigation_ctx.push_breadcrumb(
1081 ViewTab::Vulnerabilities,
1082 vuln_id.clone(),
1083 self.vuln_state.selected,
1084 );
1085 self.selected_component = Some(comp_id.clone());
1086 self.component_tab = ComponentDetailTab::Overview;
1087 self.active_tab = ViewTab::Tree;
1088 self.focus_panel = FocusPanel::Right;
1089 self.jump_to_component_in_tree(&comp_id);
1090 self.set_status_message(format!("→ {vuln_id} (Backspace to return)"));
1091 }
1092 }
1093 ViewTab::Licenses => {
1094 let license_data = super::views::build_license_data_from_app(self);
1096 let selected_idx = self
1097 .license_state
1098 .selected
1099 .min(license_data.len().saturating_sub(1));
1100 if let Some((license, _, _)) = license_data.get(selected_idx)
1101 && let Some(comp_id) =
1102 super::views::get_first_component_id_for_license(self, license)
1103 {
1104 self.navigation_ctx.push_breadcrumb(
1105 ViewTab::Licenses,
1106 license.clone(),
1107 self.license_state.selected,
1108 );
1109 self.selected_component = Some(comp_id.clone());
1110 self.component_tab = ComponentDetailTab::Overview;
1111 self.active_tab = ViewTab::Tree;
1112 self.focus_panel = FocusPanel::Right;
1113 self.jump_to_component_in_tree(&comp_id);
1114 self.set_status_message(format!("→ {license} (Backspace to return)"));
1115 }
1116 }
1117 ViewTab::Dependencies => {
1118 if let Some(node_id) = self.get_selected_dependency_node_id() {
1119 let is_leaf = self
1121 .dependency_state
1122 .cached_flat_nodes
1123 .get(self.dependency_state.selected)
1124 .is_some_and(|n| !n.has_children);
1125 if is_leaf {
1126 let display_name = self
1128 .dependency_state
1129 .cached_flat_nodes
1130 .get(self.dependency_state.selected)
1131 .map(|n| n.name.clone())
1132 .unwrap_or_default();
1133 self.navigation_ctx.push_breadcrumb(
1134 ViewTab::Dependencies,
1135 display_name.clone(),
1136 self.dependency_state.selected,
1137 );
1138 self.selected_component = Some(node_id.clone());
1139 self.component_tab = ComponentDetailTab::Overview;
1140 self.active_tab = ViewTab::Tree;
1141 self.focus_panel = FocusPanel::Right;
1142 self.jump_to_component_in_tree(&node_id);
1143 self.set_status_message(format!("→ {display_name} (Backspace to return)"));
1144 } else {
1145 self.dependency_state.toggle_expand(&node_id);
1146 }
1147 }
1148 }
1149 ViewTab::Compliance => {
1150 self.ensure_compliance_results();
1152 let idx = self.compliance_state.selected_standard;
1153 let has_violations = self
1154 .compliance_results
1155 .as_ref()
1156 .and_then(|r| r.get(idx))
1157 .is_some_and(|r| !r.violations.is_empty());
1158 if has_violations {
1159 self.compliance_state.show_detail = !self.compliance_state.show_detail;
1160 }
1161 }
1162 ViewTab::Source => {
1163 if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree
1165 && let Some(ref tree) = self.source_state.json_tree
1166 {
1167 let mut items = Vec::new();
1168 crate::tui::shared::source::flatten_json_tree(
1169 tree,
1170 "",
1171 0,
1172 &self.source_state.expanded,
1173 &mut items,
1174 true,
1175 &[],
1176 self.source_state.sort_mode,
1177 "",
1178 );
1179 if let Some(item) = items.get(self.source_state.selected)
1180 && item.is_expandable
1181 {
1182 let node_id = item.node_id.clone();
1183 self.source_state.toggle_expand(&node_id);
1184 }
1185 }
1186 }
1187 ViewTab::Quality => {
1188 if self.quality_state.view_mode == QualityViewMode::Summary {
1189 self.quality_state.view_mode = QualityViewMode::Recommendations;
1191 }
1192 }
1193 ViewTab::Overview
1194 | ViewTab::Crypto
1195 | ViewTab::Algorithms
1196 | ViewTab::Certificates
1197 | ViewTab::Keys
1198 | ViewTab::Protocols
1199 | ViewTab::PqcCompliance
1200 | ViewTab::Models
1201 | ViewTab::Datasets
1202 | ViewTab::AiReadiness => {}
1203 }
1204 }
1205
1206 pub fn handle_source_map_enter(&mut self) {
1208 let Some(tree) = &self.source_state.json_tree else {
1210 return;
1211 };
1212 let Some(children) = tree.children() else {
1213 return;
1214 };
1215
1216 let expandable: Vec<_> = children.iter().filter(|c| c.is_expandable()).collect();
1218
1219 let target = match expandable.get(self.source_state.map_selected) {
1220 Some(t) => *t,
1221 None => return,
1222 };
1223
1224 let target_id = target.node_id("root");
1225
1226 match self.source_state.view_mode {
1227 crate::tui::app_states::SourceViewMode::Tree => {
1228 if !self.source_state.expanded.contains(&target_id) {
1230 self.source_state.expanded.insert(target_id.clone());
1231 }
1232 let mut items = Vec::new();
1234 crate::tui::shared::source::flatten_json_tree(
1235 tree,
1236 "",
1237 0,
1238 &self.source_state.expanded,
1239 &mut items,
1240 true,
1241 &[],
1242 self.source_state.sort_mode,
1243 "",
1244 );
1245 if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
1246 self.source_state.selected = idx;
1247 self.source_state.scroll_offset = idx.saturating_sub(2);
1248 }
1249 }
1250 crate::tui::app_states::SourceViewMode::Raw => {
1251 let key = match target {
1253 crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
1254 | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
1255 | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
1256 };
1257 for (i, line) in self.source_state.raw_lines.iter().enumerate() {
1259 let search = format!("\"{key}\":");
1260 if line.contains(&search) && line.starts_with(" ") && !line.starts_with(" ")
1261 {
1262 self.source_state.selected = i;
1263 self.source_state.scroll_offset = i.saturating_sub(2);
1264 break;
1265 }
1266 }
1267 }
1268 }
1269
1270 self.focus_panel = FocusPanel::Left;
1272 }
1273
1274 #[must_use]
1277 pub fn get_map_context_component_id(&self) -> Option<String> {
1278 let tree = self.source_state.json_tree.as_ref()?;
1279 let mut items = Vec::new();
1280 crate::tui::shared::source::flatten_json_tree(
1281 tree,
1282 "",
1283 0,
1284 &self.source_state.expanded,
1285 &mut items,
1286 true,
1287 &[],
1288 self.source_state.sort_mode,
1289 "",
1290 );
1291 let item = items.get(self.source_state.selected)?;
1292 let parts: Vec<&str> = item.node_id.split('.').collect();
1293 if parts.len() < 3 || parts[1] != "components" {
1294 return None;
1295 }
1296 let idx_part = parts[2];
1297 if idx_part.starts_with('[') && idx_part.ends_with(']') {
1298 let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
1299 let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
1300 Some(canon_id.value().to_string())
1301 } else {
1302 None
1303 }
1304 }
1305
1306 #[must_use]
1308 pub fn get_selected_dependency_node_id(&self) -> Option<String> {
1309 if !self.dependency_state.cached_flat_nodes.is_empty() {
1311 return self
1312 .dependency_state
1313 .cached_flat_nodes
1314 .get(self.dependency_state.selected)
1315 .map(|n| n.id.clone());
1316 }
1317 let mut visible_nodes = Vec::new();
1319 self.collect_visible_dependency_nodes(&mut visible_nodes);
1320 visible_nodes.get(self.dependency_state.selected).cloned()
1321 }
1322
1323 fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
1325 let mut edges: std::collections::HashMap<String, Vec<String>> =
1327 std::collections::HashMap::new();
1328 let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
1329 let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
1330
1331 for (id, _) in &self.sbom.components {
1332 all_nodes.insert(id.value().to_string());
1333 }
1334
1335 for edge in &self.sbom.edges {
1336 let from = edge.from.value().to_string();
1337 let to = edge.to.value().to_string();
1338 if all_nodes.contains(&from) && all_nodes.contains(&to) {
1339 edges.entry(from).or_default().push(to.clone());
1340 has_parent.insert(to);
1341 }
1342 }
1343
1344 let mut roots: Vec<_> = all_nodes
1346 .iter()
1347 .filter(|id| !has_parent.contains(*id))
1348 .cloned()
1349 .collect();
1350 roots.sort();
1351
1352 for root in roots {
1354 self.collect_dep_nodes_recursive(
1355 &root,
1356 &edges,
1357 nodes,
1358 &mut std::collections::HashSet::new(),
1359 );
1360 }
1361 }
1362
1363 fn collect_dep_nodes_recursive(
1364 &self,
1365 node_id: &str,
1366 edges: &std::collections::HashMap<String, Vec<String>>,
1367 nodes: &mut Vec<String>,
1368 visited: &mut std::collections::HashSet<String>,
1369 ) {
1370 if visited.contains(node_id) {
1371 return;
1372 }
1373 visited.insert(node_id.to_string());
1374 nodes.push(node_id.to_string());
1375
1376 if self.dependency_state.is_expanded(node_id)
1377 && let Some(children) = edges.get(node_id)
1378 {
1379 for child in children {
1380 self.collect_dep_nodes_recursive(child, edges, nodes, visited);
1381 }
1382 }
1383 }
1384
1385 fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
1387 let nodes = self.build_tree_nodes();
1388 let mut flat_items = Vec::new();
1389 flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
1390
1391 flat_items.get(self.tree_state.selected).cloned()
1392 }
1393
1394 pub fn ensure_tree_cache(&mut self) {
1396 let current_key = TreeCacheKey {
1397 group_by: self.tree_group_by,
1398 filter: self.tree_filter,
1399 search_query: self.tree_search_query.clone(),
1400 };
1401 if self.tree_cache_key.as_ref() != Some(¤t_key) {
1402 self.cached_tree_nodes = match self.tree_group_by {
1403 TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
1404 TreeGroupBy::License => self.build_license_tree(),
1405 TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
1406 TreeGroupBy::ComponentType => self.build_type_tree(),
1407 TreeGroupBy::Flat => self.build_flat_tree(),
1408 };
1409 self.tree_cache_key = Some(current_key);
1410 }
1411 }
1412
1413 pub fn build_tree_nodes(&self) -> &[crate::tui::widgets::TreeNode] {
1417 &self.cached_tree_nodes
1418 }
1419
1420 fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1421 use crate::tui::widgets::TreeNode;
1422
1423 let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
1424
1425 for comp in self.sbom.components.values() {
1426 if !self.matches_filter(comp) {
1427 continue;
1428 }
1429 let eco = comp
1430 .ecosystem
1431 .as_ref()
1432 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1433 ecosystem_map.entry(eco).or_default().push(comp);
1434 }
1435
1436 let mut groups: Vec<TreeNode> = ecosystem_map
1437 .into_iter()
1438 .map(|(eco, mut components)| {
1439 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1440 components.sort_by(|a, b| a.name.cmp(&b.name));
1441 let children: Vec<TreeNode> = components
1442 .into_iter()
1443 .map(|c| TreeNode::Component {
1444 id: c.canonical_id.value().to_string(),
1445 name: c.name.clone(),
1446 version: c.version.clone(),
1447 vuln_count: c.vulnerabilities.len(),
1448 max_severity: get_max_severity(c),
1449 component_type: Some(
1450 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1451 ),
1452 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1453 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1454 })
1455 .collect();
1456 let count = children.len();
1457 TreeNode::Group {
1458 id: format!("eco:{eco}"),
1459 label: eco,
1460 children,
1461 item_count: count,
1462 vuln_count,
1463 }
1464 })
1465 .collect();
1466
1467 groups.sort_by(|a, b| match (a, b) {
1468 (
1469 TreeNode::Group {
1470 item_count: ac,
1471 label: al,
1472 ..
1473 },
1474 TreeNode::Group {
1475 item_count: bc,
1476 label: bl,
1477 ..
1478 },
1479 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1480 _ => std::cmp::Ordering::Equal,
1481 });
1482
1483 groups
1484 }
1485
1486 fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1487 use crate::tui::widgets::TreeNode;
1488
1489 let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
1490
1491 for comp in self.sbom.components.values() {
1492 if !self.matches_filter(comp) {
1493 continue;
1494 }
1495 let license = if comp.licenses.declared.is_empty() {
1496 "Unknown".to_string()
1497 } else {
1498 comp.licenses.declared[0].expression.clone()
1499 };
1500 license_map.entry(license).or_default().push(comp);
1501 }
1502
1503 let mut groups: Vec<TreeNode> = license_map
1504 .into_iter()
1505 .map(|(license, mut components)| {
1506 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1507 components.sort_by(|a, b| a.name.cmp(&b.name));
1508 let children: Vec<TreeNode> = components
1509 .into_iter()
1510 .map(|c| TreeNode::Component {
1511 id: c.canonical_id.value().to_string(),
1512 name: c.name.clone(),
1513 version: c.version.clone(),
1514 vuln_count: c.vulnerabilities.len(),
1515 max_severity: get_max_severity(c),
1516 component_type: Some(
1517 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1518 ),
1519 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1520 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1521 })
1522 .collect();
1523 let count = children.len();
1524 TreeNode::Group {
1525 id: format!("lic:{license}"),
1526 label: license,
1527 children,
1528 item_count: count,
1529 vuln_count,
1530 }
1531 })
1532 .collect();
1533
1534 groups.sort_by(|a, b| match (a, b) {
1535 (
1536 TreeNode::Group {
1537 item_count: ac,
1538 label: al,
1539 ..
1540 },
1541 TreeNode::Group {
1542 item_count: bc,
1543 label: bl,
1544 ..
1545 },
1546 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1547 _ => std::cmp::Ordering::Equal,
1548 });
1549
1550 groups
1551 }
1552
1553 fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1554 use super::severity::severity_category;
1555 use crate::tui::widgets::TreeNode;
1556
1557 let mut critical_comps = Vec::new();
1558 let mut high_comps = Vec::new();
1559 let mut other_vuln_comps = Vec::new();
1560 let mut clean_comps = Vec::new();
1561
1562 for comp in self.sbom.components.values() {
1563 if !self.matches_filter(comp) {
1564 continue;
1565 }
1566
1567 match severity_category(&comp.vulnerabilities) {
1568 "critical" => critical_comps.push(comp),
1569 "high" => high_comps.push(comp),
1570 "clean" => clean_comps.push(comp),
1571 _ => other_vuln_comps.push(comp),
1572 }
1573 }
1574
1575 let build_group = |label: &str,
1576 id: &str,
1577 comps: Vec<&Component>,
1578 bookmarked: &HashSet<String>|
1579 -> TreeNode {
1580 let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1581 let children: Vec<TreeNode> = comps
1582 .into_iter()
1583 .map(|c| TreeNode::Component {
1584 id: c.canonical_id.value().to_string(),
1585 name: c.name.clone(),
1586 version: c.version.clone(),
1587 vuln_count: c.vulnerabilities.len(),
1588 max_severity: get_max_severity(c),
1589 component_type: Some(
1590 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1591 ),
1592 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1593 is_bookmarked: bookmarked.contains(c.canonical_id.value()),
1594 })
1595 .collect();
1596 let count = children.len();
1597 TreeNode::Group {
1598 id: id.to_string(),
1599 label: label.to_string(),
1600 children,
1601 item_count: count,
1602 vuln_count,
1603 }
1604 };
1605
1606 let mut groups = Vec::new();
1607 if !critical_comps.is_empty() {
1608 groups.push(build_group(
1609 "Critical",
1610 "vuln:critical",
1611 critical_comps,
1612 &self.bookmarked,
1613 ));
1614 }
1615 if !high_comps.is_empty() {
1616 groups.push(build_group(
1617 "High",
1618 "vuln:high",
1619 high_comps,
1620 &self.bookmarked,
1621 ));
1622 }
1623 if !other_vuln_comps.is_empty() {
1624 groups.push(build_group(
1625 "Other Vulnerabilities",
1626 "vuln:other",
1627 other_vuln_comps,
1628 &self.bookmarked,
1629 ));
1630 }
1631 if !clean_comps.is_empty() {
1632 groups.push(build_group(
1633 "No Vulnerabilities",
1634 "vuln:clean",
1635 clean_comps,
1636 &self.bookmarked,
1637 ));
1638 }
1639
1640 groups
1641 }
1642
1643 fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1644 use crate::tui::widgets::TreeNode;
1645
1646 let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1647
1648 for comp in self.sbom.components.values() {
1649 if !self.matches_filter(comp) {
1650 continue;
1651 }
1652 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1653 type_map.entry(comp_type).or_default().push(comp);
1654 }
1655
1656 let type_order = vec![
1658 ("lib", "Libraries"),
1659 ("bin", "Binaries"),
1660 ("cert", "Certificates"),
1661 ("fs", "Filesystems"),
1662 ("file", "Other Files"),
1663 ];
1664
1665 let mut groups = Vec::new();
1666 for (type_key, type_label) in type_order {
1667 if let Some(mut components) = type_map.remove(type_key) {
1668 if components.is_empty() {
1669 continue;
1670 }
1671 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1672 components.sort_by(|a, b| a.name.cmp(&b.name));
1673 let children: Vec<TreeNode> = components
1674 .into_iter()
1675 .map(|c| TreeNode::Component {
1676 id: c.canonical_id.value().to_string(),
1677 name: c.name.clone(),
1678 version: c.version.clone(),
1679 vuln_count: c.vulnerabilities.len(),
1680 max_severity: get_max_severity(c),
1681 component_type: Some(type_key.to_string()),
1682 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1683 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1684 })
1685 .collect();
1686 let count = children.len();
1687 groups.push(TreeNode::Group {
1688 id: format!("type:{type_key}"),
1689 label: type_label.to_string(),
1690 children,
1691 item_count: count,
1692 vuln_count,
1693 });
1694 }
1695 }
1696
1697 groups
1698 }
1699
1700 fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1701 use crate::tui::widgets::TreeNode;
1702
1703 self.sbom
1704 .components
1705 .values()
1706 .filter(|c| self.matches_filter(c))
1707 .map(|c| TreeNode::Component {
1708 id: c.canonical_id.value().to_string(),
1709 name: c.name.clone(),
1710 version: c.version.clone(),
1711 vuln_count: c.vulnerabilities.len(),
1712 max_severity: get_max_severity(c),
1713 component_type: Some(
1714 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1715 ),
1716 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1717 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1718 })
1719 .collect()
1720 }
1721
1722 fn matches_filter(&self, comp: &Component) -> bool {
1723 use super::severity::severity_matches;
1724
1725 let passes_filter = match self.tree_filter {
1727 TreeFilter::All => true,
1728 TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1729 TreeFilter::Critical => comp
1730 .vulnerabilities
1731 .iter()
1732 .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1733 TreeFilter::Bookmarked => self.bookmarked.contains(comp.canonical_id.value()),
1734 };
1735
1736 if !passes_filter {
1737 return false;
1738 }
1739
1740 if self.tree_search_query.is_empty() {
1742 return true;
1743 }
1744
1745 let query_lower = self.tree_search_query.to_lowercase();
1746 let name_lower = comp.name.to_lowercase();
1747
1748 if name_lower.contains(&query_lower) {
1750 return true;
1751 }
1752
1753 if let Some(ref version) = comp.version
1755 && version.to_lowercase().contains(&query_lower)
1756 {
1757 return true;
1758 }
1759
1760 if let Some(ref eco) = comp.ecosystem
1762 && eco.to_string().to_lowercase().contains(&query_lower)
1763 {
1764 return true;
1765 }
1766
1767 false
1768 }
1769
1770 fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1771 match self.tree_group_by {
1772 TreeGroupBy::Ecosystem => {
1773 let eco = comp
1774 .ecosystem
1775 .as_ref()
1776 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1777 Some(format!("eco:{eco}"))
1778 }
1779 TreeGroupBy::License => {
1780 let license = if comp.licenses.declared.is_empty() {
1781 "Unknown".to_string()
1782 } else {
1783 comp.licenses.declared[0].expression.clone()
1784 };
1785 Some(format!("lic:{license}"))
1786 }
1787 TreeGroupBy::VulnStatus => {
1788 use super::severity::severity_category;
1789 let group = match severity_category(&comp.vulnerabilities) {
1790 "critical" => "vuln:critical",
1791 "high" => "vuln:high",
1792 "clean" => "vuln:clean",
1793 _ => "vuln:other",
1794 };
1795 Some(group.to_string())
1796 }
1797 TreeGroupBy::ComponentType => {
1798 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1799 Some(format!("type:{comp_type}"))
1800 }
1801 TreeGroupBy::Flat => None,
1802 }
1803 }
1804}
1805
1806fn get_max_severity(comp: &Component) -> Option<String> {
1808 super::severity::max_severity_from_vulns(&comp.vulnerabilities)
1809}
1810
1811#[derive(Debug, Clone)]
1813enum SelectedTreeNode {
1814 Group(String),
1815 Component(String),
1816}
1817
1818fn flatten_tree_for_selection(
1819 nodes: &[crate::tui::widgets::TreeNode],
1820 state: &TreeState,
1821 items: &mut Vec<SelectedTreeNode>,
1822) {
1823 use crate::tui::widgets::TreeNode;
1824
1825 for node in nodes {
1826 match node {
1827 TreeNode::Group { id, children, .. } => {
1828 items.push(SelectedTreeNode::Group(id.clone()));
1829 if state.is_expanded(id) {
1830 flatten_tree_for_selection(children, state, items);
1831 }
1832 }
1833 TreeNode::Component { id, .. } => {
1834 items.push(SelectedTreeNode::Component(id.clone()));
1835 }
1836 }
1837 }
1838}
1839
1840#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1842pub enum ViewTab {
1843 Overview,
1846 Quality,
1848 Source,
1850
1851 Tree,
1854 Vulnerabilities,
1856 Licenses,
1858 Dependencies,
1860 Compliance,
1862
1863 Algorithms,
1866 Certificates,
1868 Keys,
1870 Protocols,
1872 PqcCompliance,
1874
1875 Models,
1878 Datasets,
1880 AiReadiness,
1882
1883 Crypto,
1886}
1887
1888impl ViewTab {
1889 #[must_use]
1891 pub const fn title(&self) -> &'static str {
1892 match self {
1893 Self::Overview => "Overview",
1894 Self::Quality => "Quality",
1895 Self::Source => "Source",
1896 Self::Tree => "Components",
1897 Self::Vulnerabilities => "Vulns",
1898 Self::Licenses => "Licenses",
1899 Self::Dependencies => "Deps",
1900 Self::Compliance => "Compliance",
1901 Self::Algorithms => "Algorithms",
1902 Self::Certificates => "Certs",
1903 Self::Keys => "Keys",
1904 Self::Protocols => "Protocols",
1905 Self::PqcCompliance => "PQC Compliance",
1906 Self::Models => "Models",
1907 Self::Datasets => "Datasets",
1908 Self::AiReadiness => "AI-Readiness",
1909 Self::Crypto => "Crypto",
1910 }
1911 }
1912
1913 #[must_use]
1915 pub fn shortcut_for_profile(&self, profile: crate::model::BomProfile) -> Option<usize> {
1916 Self::tabs_for_profile(profile)
1917 .iter()
1918 .position(|t| t == self)
1919 .map(|i| i + 1)
1920 }
1921
1922 #[must_use]
1924 pub const fn as_str(&self) -> &'static str {
1925 match self {
1926 Self::Overview => "overview",
1927 Self::Quality => "quality",
1928 Self::Source => "source",
1929 Self::Tree => "tree",
1930 Self::Vulnerabilities => "vulnerabilities",
1931 Self::Licenses => "licenses",
1932 Self::Dependencies => "dependencies",
1933 Self::Compliance => "compliance",
1934 Self::Algorithms => "algorithms",
1935 Self::Certificates => "certificates",
1936 Self::Keys => "keys",
1937 Self::Protocols => "protocols",
1938 Self::PqcCompliance => "pqc-compliance",
1939 Self::Models => "models",
1940 Self::Datasets => "datasets",
1941 Self::AiReadiness => "ai-readiness",
1942 Self::Crypto => "crypto",
1943 }
1944 }
1945
1946 #[must_use]
1951 pub const fn tabs_for_profile(profile: crate::model::BomProfile) -> &'static [ViewTab] {
1952 match profile {
1953 crate::model::BomProfile::Sbom => &[
1954 Self::Overview,
1955 Self::Tree,
1956 Self::Vulnerabilities,
1957 Self::Licenses,
1958 Self::Dependencies,
1959 Self::Quality,
1960 Self::Compliance,
1961 Self::Source,
1962 ],
1963 crate::model::BomProfile::Cbom => &[
1964 Self::Overview,
1965 Self::Algorithms,
1966 Self::Certificates,
1967 Self::Keys,
1968 Self::Protocols,
1969 Self::Quality,
1970 Self::PqcCompliance,
1971 Self::Source,
1972 ],
1973 crate::model::BomProfile::AiBom => &[
1974 Self::Overview,
1975 Self::Models,
1976 Self::Datasets,
1977 Self::AiReadiness,
1978 Self::Compliance,
1979 Self::Source,
1980 ],
1981 }
1982 }
1983
1984 #[must_use]
1986 pub fn from_str_opt(s: &str) -> Option<Self> {
1987 match s {
1988 "overview" => Some(Self::Overview),
1989 "quality" => Some(Self::Quality),
1990 "source" => Some(Self::Source),
1991 "tree" => Some(Self::Tree),
1992 "vulnerabilities" => Some(Self::Vulnerabilities),
1993 "licenses" => Some(Self::Licenses),
1994 "dependencies" => Some(Self::Dependencies),
1995 "compliance" => Some(Self::Compliance),
1996 "algorithms" => Some(Self::Algorithms),
1997 "certificates" => Some(Self::Certificates),
1998 "keys" => Some(Self::Keys),
1999 "protocols" => Some(Self::Protocols),
2000 "pqc-compliance" => Some(Self::PqcCompliance),
2001 "models" => Some(Self::Models),
2002 "datasets" => Some(Self::Datasets),
2003 "ai-readiness" => Some(Self::AiReadiness),
2004 "crypto" => Some(Self::Crypto),
2005 _ => None,
2006 }
2007 }
2008}
2009
2010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2012pub enum TreeGroupBy {
2013 Ecosystem,
2014 License,
2015 VulnStatus,
2016 ComponentType,
2017 Flat,
2018}
2019
2020impl TreeGroupBy {
2021 #[must_use]
2022 pub const fn label(&self) -> &'static str {
2023 match self {
2024 Self::Ecosystem => "Ecosystem",
2025 Self::License => "License",
2026 Self::VulnStatus => "Vuln Status",
2027 Self::ComponentType => "Type",
2028 Self::Flat => "Flat List",
2029 }
2030 }
2031}
2032
2033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2035pub enum TreeFilter {
2036 All,
2037 HasVulnerabilities,
2038 Critical,
2039 Bookmarked,
2040}
2041
2042impl TreeFilter {
2043 #[must_use]
2044 pub const fn label(&self) -> &'static str {
2045 match self {
2046 Self::All => "All",
2047 Self::HasVulnerabilities => "Has Vulns",
2048 Self::Critical => "Critical",
2049 Self::Bookmarked => "Bookmarked",
2050 }
2051 }
2052}
2053
2054#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2056pub(crate) enum ComponentDetailTab {
2057 #[default]
2058 Overview,
2059 Identifiers,
2060 Vulnerabilities,
2061 Dependencies,
2062}
2063
2064impl ComponentDetailTab {
2065 pub const fn title(self) -> &'static str {
2066 match self {
2067 Self::Overview => "Overview",
2068 Self::Identifiers => "Identifiers",
2069 Self::Vulnerabilities => "Vulnerabilities",
2070 Self::Dependencies => "Dependencies",
2071 }
2072 }
2073
2074 pub const fn shortcut(self) -> &'static str {
2075 match self {
2076 Self::Overview => "1",
2077 Self::Identifiers => "2",
2078 Self::Vulnerabilities => "3",
2079 Self::Dependencies => "4",
2080 }
2081 }
2082
2083 pub const fn all() -> [Self; 4] {
2084 [
2085 Self::Overview,
2086 Self::Identifiers,
2087 Self::Vulnerabilities,
2088 Self::Dependencies,
2089 ]
2090 }
2091}
2092
2093#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2095pub(crate) enum FocusPanel {
2096 Left,
2097 Right,
2098}
2099
2100#[derive(Debug, Clone)]
2102pub(crate) struct VulnExplorerState {
2103 pub selected: usize,
2104 pub total: usize,
2105 pub scroll_offset: usize,
2106 pub group_by: VulnGroupBy,
2107 pub sort_by: VulnSortBy,
2108 pub filter_severity: Option<String>,
2109 pub filter_kev: bool,
2111 pub deduplicate: bool,
2113 pub search_query: String,
2115 pub search_active: bool,
2117 pub detail_scroll: u16,
2119 pub expanded_groups: HashSet<String>,
2121 cache_key: Option<VulnCacheKey>,
2123 pub cached_data: Option<super::views::VulnCacheRef>,
2125 pub cached_display_items: Vec<super::views::VulnDisplayItem>,
2128 display_items_expanded_snapshot: HashSet<String>,
2130 display_items_group_by: VulnGroupBy,
2132 pub inspect_component_idx: usize,
2134}
2135
2136#[derive(Debug, Clone, PartialEq, Eq)]
2139struct TreeCacheKey {
2140 group_by: TreeGroupBy,
2141 filter: TreeFilter,
2142 search_query: String,
2143}
2144
2145#[derive(Debug, Clone, PartialEq, Eq)]
2146struct VulnCacheKey {
2147 filter_severity: Option<String>,
2148 filter_kev: bool,
2149 deduplicate: bool,
2150 sort_by: VulnSortBy,
2151 search_query: String,
2152}
2153
2154impl VulnExplorerState {
2155 pub fn new() -> Self {
2156 Self {
2157 selected: 0,
2158 total: 0,
2159 scroll_offset: 0,
2160 group_by: VulnGroupBy::Component,
2161 sort_by: VulnSortBy::Severity,
2162 filter_severity: None,
2163 filter_kev: false,
2164 deduplicate: true,
2165 search_query: String::new(),
2166 search_active: false,
2167 detail_scroll: 0,
2168 expanded_groups: HashSet::new(),
2169 cache_key: None,
2170 cached_data: None,
2171 cached_display_items: Vec::new(),
2172 display_items_expanded_snapshot: HashSet::new(),
2173 display_items_group_by: VulnGroupBy::Component,
2174 inspect_component_idx: 0,
2175 }
2176 }
2177
2178 fn current_cache_key(&self) -> VulnCacheKey {
2180 VulnCacheKey {
2181 filter_severity: self.filter_severity.clone(),
2182 filter_kev: self.filter_kev,
2183 deduplicate: self.deduplicate,
2184 sort_by: self.sort_by,
2185 search_query: self.search_query.clone(),
2186 }
2187 }
2188
2189 pub fn is_cache_valid(&self) -> bool {
2191 if let Some(key) = &self.cache_key {
2192 self.cached_data.is_some()
2193 && key.filter_severity == self.filter_severity
2194 && key.filter_kev == self.filter_kev
2195 && key.deduplicate == self.deduplicate
2196 && key.sort_by == self.sort_by
2197 && key.search_query == self.search_query
2198 } else {
2199 false
2200 }
2201 }
2202
2203 pub fn set_cache(&mut self, cache: super::views::VulnCache) {
2205 self.cache_key = Some(self.current_cache_key());
2206 self.cached_data = Some(std::sync::Arc::new(cache));
2207 }
2208
2209 pub fn invalidate_cache(&mut self) {
2211 self.cache_key = None;
2212 self.cached_data = None;
2213 self.cached_display_items.clear();
2214 }
2215
2216 pub fn are_display_items_valid(&self) -> bool {
2218 !self.cached_display_items.is_empty()
2219 && self.display_items_expanded_snapshot == self.expanded_groups
2220 && self.display_items_group_by == self.group_by
2221 }
2222
2223 pub fn rebuild_display_items(&mut self) {
2225 if let Some(cache) = &self.cached_data {
2226 self.cached_display_items = super::views::build_display_items(
2227 &cache.vulns,
2228 &self.group_by,
2229 &self.expanded_groups,
2230 );
2231 self.display_items_expanded_snapshot = self.expanded_groups.clone();
2232 self.display_items_group_by = self.group_by;
2233 }
2234 }
2235
2236 pub const fn select_next(&mut self) {
2237 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2238 self.selected += 1;
2239 self.detail_scroll = 0;
2240 self.inspect_component_idx = 0;
2241 }
2242 }
2243
2244 pub const fn select_prev(&mut self) {
2245 if self.selected > 0 {
2246 self.selected -= 1;
2247 self.detail_scroll = 0;
2248 self.inspect_component_idx = 0;
2249 }
2250 }
2251
2252 pub const fn detail_scroll_down(&mut self) {
2254 self.detail_scroll = self.detail_scroll.saturating_add(1);
2255 }
2256
2257 pub const fn detail_scroll_up(&mut self) {
2259 self.detail_scroll = self.detail_scroll.saturating_sub(1);
2260 }
2261
2262 pub const fn clamp_selection(&mut self) {
2264 if self.total == 0 {
2265 self.selected = 0;
2266 } else if self.selected >= self.total {
2267 self.selected = self.total.saturating_sub(1);
2268 }
2269 }
2270
2271 pub fn get_selected_vuln_row<'a>(
2274 &self,
2275 cache: &'a super::views::VulnCache,
2276 ) -> Option<&'a super::views::VulnRow> {
2277 let item = self.cached_display_items.get(self.selected)?;
2278 match item {
2279 super::views::VulnDisplayItem::Vuln { idx, .. } => cache.vulns.get(*idx),
2280 _ => None,
2281 }
2282 }
2283
2284 pub fn get_nav_component_id(
2287 &self,
2288 cache: &super::views::VulnCache,
2289 ) -> Option<(String, String)> {
2290 let vuln = self.get_selected_vuln_row(cache)?;
2291 let idx = self
2292 .inspect_component_idx
2293 .min(vuln.affected_component_ids.len().saturating_sub(1));
2294 let comp_id = vuln.affected_component_ids.get(idx)?;
2295 Some((comp_id.clone(), vuln.vuln_id.clone()))
2296 }
2297
2298 pub fn toggle_group(&mut self) {
2299 self.group_by = match self.group_by {
2300 VulnGroupBy::Severity => VulnGroupBy::Component,
2301 VulnGroupBy::Component => VulnGroupBy::Flat,
2302 VulnGroupBy::Flat => VulnGroupBy::Severity,
2303 };
2304 self.selected = 0;
2305 self.expanded_groups.clear();
2306 self.invalidate_cache();
2307 }
2308
2309 pub fn toggle_vuln_group(&mut self, group_id: &str) {
2311 if self.expanded_groups.contains(group_id) {
2312 self.expanded_groups.remove(group_id);
2313 } else {
2314 self.expanded_groups.insert(group_id.to_string());
2315 }
2316 }
2317
2318 pub fn expand_all_groups(&mut self, labels: &[String]) {
2320 for label in labels {
2321 self.expanded_groups.insert(label.clone());
2322 }
2323 }
2324
2325 pub fn collapse_all_groups(&mut self) {
2327 self.expanded_groups.clear();
2328 }
2329
2330 pub fn jump_next_group_cached(&mut self) {
2332 for (i, item) in self
2333 .cached_display_items
2334 .iter()
2335 .enumerate()
2336 .skip(self.selected + 1)
2337 {
2338 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2339 self.selected = i;
2340 self.detail_scroll = 0;
2341 return;
2342 }
2343 }
2344 for (i, item) in self.cached_display_items.iter().enumerate() {
2346 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2347 self.selected = i;
2348 self.detail_scroll = 0;
2349 return;
2350 }
2351 }
2352 }
2353
2354 pub fn jump_prev_group_cached(&mut self) {
2356 for (i, item) in self
2357 .cached_display_items
2358 .iter()
2359 .enumerate()
2360 .take(self.selected)
2361 .rev()
2362 {
2363 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2364 self.selected = i;
2365 self.detail_scroll = 0;
2366 return;
2367 }
2368 }
2369 for (i, item) in self.cached_display_items.iter().enumerate().rev() {
2371 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2372 self.selected = i;
2373 self.detail_scroll = 0;
2374 return;
2375 }
2376 }
2377 }
2378
2379 pub fn toggle_filter(&mut self) {
2380 self.filter_severity = match &self.filter_severity {
2381 None => Some("critical".to_string()),
2382 Some(s) if s == "critical" => Some("high".to_string()),
2383 Some(s) if s == "high" => Some("medium".to_string()),
2384 Some(s) if s == "medium" => Some("low".to_string()),
2385 Some(s) if s == "low" => Some("unknown".to_string()),
2386 Some(s) if s == "unknown" => None,
2387 _ => None,
2388 };
2389 self.selected = 0;
2390 self.invalidate_cache();
2391 }
2392
2393 pub fn toggle_kev_filter(&mut self) {
2396 self.filter_kev = !self.filter_kev;
2397 self.selected = 0;
2398 self.invalidate_cache();
2399 }
2400
2401 pub fn toggle_sort(&mut self) {
2402 self.sort_by = self.sort_by.next();
2403 self.selected = 0;
2404 self.invalidate_cache();
2405 }
2406
2407 pub fn toggle_deduplicate(&mut self) {
2408 self.deduplicate = !self.deduplicate;
2409 self.selected = 0;
2410 self.invalidate_cache();
2411 }
2412
2413 pub fn start_vuln_search(&mut self) {
2415 self.search_active = true;
2416 self.search_query.clear();
2417 }
2418
2419 pub const fn stop_vuln_search(&mut self) {
2421 self.search_active = false;
2422 }
2423
2424 pub fn clear_vuln_search(&mut self) {
2426 self.search_active = false;
2427 self.search_query.clear();
2428 self.selected = 0;
2429 self.invalidate_cache();
2430 }
2431
2432 pub fn search_push(&mut self, c: char) {
2434 self.search_query.push(c);
2435 self.selected = 0;
2436 self.invalidate_cache();
2437 }
2438
2439 pub fn search_pop(&mut self) {
2441 self.search_query.pop();
2442 self.selected = 0;
2443 self.invalidate_cache();
2444 }
2445
2446 pub fn get_selected<'a>(
2448 &self,
2449 sbom: &'a NormalizedSbom,
2450 ) -> Option<(String, &'a VulnerabilityRef)> {
2451 let mut idx = 0;
2452 for (comp_id, comp) in &sbom.components {
2453 for vuln in &comp.vulnerabilities {
2454 if let Some(ref filter) = self.filter_severity {
2455 let sev = vuln.severity.as_ref().map(|s| s.to_string().to_lowercase());
2456 if sev.as_deref() != Some(filter) {
2457 continue;
2458 }
2459 }
2460 if idx == self.selected {
2461 return Some((comp_id.value().to_string(), vuln));
2462 }
2463 idx += 1;
2464 }
2465 }
2466 None
2467 }
2468}
2469
2470impl Default for VulnExplorerState {
2471 fn default() -> Self {
2472 Self::new()
2473 }
2474}
2475
2476impl ListNavigation for VulnExplorerState {
2477 fn selected(&self) -> usize {
2478 self.selected
2479 }
2480
2481 fn set_selected(&mut self, idx: usize) {
2482 self.selected = idx;
2483 }
2484
2485 fn total(&self) -> usize {
2486 self.total
2487 }
2488
2489 fn set_total(&mut self, total: usize) {
2490 self.total = total;
2491 }
2492}
2493
2494#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2496pub(crate) enum QualityViewMode {
2497 #[default]
2498 Summary,
2499 Breakdown,
2500 Metrics,
2501 Recommendations,
2502}
2503
2504pub(crate) struct QualityViewState {
2506 pub view_mode: QualityViewMode,
2507 pub selected_recommendation: usize,
2508 pub total_recommendations: usize,
2509 pub scroll_offset: usize,
2510}
2511
2512impl QualityViewState {
2513 pub const fn new(total_recommendations: usize) -> Self {
2514 Self {
2515 view_mode: QualityViewMode::Summary,
2516 selected_recommendation: 0,
2517 total_recommendations,
2518 scroll_offset: 0,
2519 }
2520 }
2521
2522 pub const fn toggle_view(&mut self) {
2523 self.view_mode = match self.view_mode {
2524 QualityViewMode::Summary => QualityViewMode::Breakdown,
2525 QualityViewMode::Breakdown => QualityViewMode::Metrics,
2526 QualityViewMode::Metrics => QualityViewMode::Recommendations,
2527 QualityViewMode::Recommendations => QualityViewMode::Summary,
2528 };
2529 self.selected_recommendation = 0;
2530 self.scroll_offset = 0;
2531 }
2532}
2533
2534impl ListNavigation for QualityViewState {
2535 fn selected(&self) -> usize {
2536 self.selected_recommendation
2537 }
2538
2539 fn set_selected(&mut self, idx: usize) {
2540 self.selected_recommendation = idx;
2541 }
2542
2543 fn total(&self) -> usize {
2544 self.total_recommendations
2545 }
2546
2547 fn set_total(&mut self, total: usize) {
2548 self.total_recommendations = total;
2549 }
2550}
2551
2552impl Default for QualityViewState {
2553 fn default() -> Self {
2554 Self::new(0)
2555 }
2556}
2557
2558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2560pub(crate) enum VulnGroupBy {
2561 Severity,
2562 Component,
2563 Flat,
2564}
2565
2566#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2568pub(crate) enum VulnSortBy {
2569 Severity,
2570 Cvss,
2571 CveId,
2572 Component,
2573}
2574
2575impl VulnSortBy {
2576 pub const fn next(self) -> Self {
2577 match self {
2578 Self::Severity => Self::Cvss,
2579 Self::Cvss => Self::CveId,
2580 Self::CveId => Self::Component,
2581 Self::Component => Self::Severity,
2582 }
2583 }
2584
2585 pub const fn label(self) -> &'static str {
2586 match self {
2587 Self::Severity => "Severity",
2588 Self::Cvss => "CVSS",
2589 Self::CveId => "CVE ID",
2590 Self::Component => "Component",
2591 }
2592 }
2593}
2594
2595#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2597pub(crate) enum AlgorithmSortBy {
2598 #[default]
2599 Name,
2600 Family,
2601 QuantumLevel,
2602 Strength,
2603}
2604
2605impl AlgorithmSortBy {
2606 pub const fn next(self) -> Self {
2607 match self {
2608 Self::Name => Self::Family,
2609 Self::Family => Self::QuantumLevel,
2610 Self::QuantumLevel => Self::Strength,
2611 Self::Strength => Self::Name,
2612 }
2613 }
2614
2615 pub const fn label(self) -> &'static str {
2616 match self {
2617 Self::Name => "Name",
2618 Self::Family => "Family",
2619 Self::QuantumLevel => "Quantum",
2620 Self::Strength => "Strength",
2621 }
2622 }
2623}
2624
2625#[derive(Debug, Clone)]
2627pub(crate) struct LicenseViewState {
2628 pub selected: usize,
2629 pub total: usize,
2630 pub scroll_offset: usize,
2631 pub group_by: LicenseGroupBy,
2632 pub component_scroll: usize,
2634 pub component_total: usize,
2636}
2637
2638impl LicenseViewState {
2639 pub const fn new() -> Self {
2640 Self {
2641 selected: 0,
2642 total: 0,
2643 scroll_offset: 0,
2644 group_by: LicenseGroupBy::License,
2645 component_scroll: 0,
2646 component_total: 0,
2647 }
2648 }
2649
2650 pub const fn scroll_components_up(&mut self) {
2652 if self.component_scroll > 0 {
2653 self.component_scroll -= 1;
2654 }
2655 }
2656
2657 pub const fn scroll_components_down(&mut self, visible_count: usize) {
2659 if self.component_total > visible_count
2660 && self.component_scroll < self.component_total - visible_count
2661 {
2662 self.component_scroll += 1;
2663 }
2664 }
2665
2666 pub const fn reset_component_scroll(&mut self) {
2668 self.component_scroll = 0;
2669 }
2670
2671 pub const fn select_next(&mut self) {
2672 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2673 self.selected += 1;
2674 self.reset_component_scroll();
2675 }
2676 }
2677
2678 pub const fn select_prev(&mut self) {
2679 if self.selected > 0 {
2680 self.selected -= 1;
2681 self.reset_component_scroll();
2682 }
2683 }
2684
2685 pub const fn clamp_selection(&mut self) {
2687 if self.total == 0 {
2688 self.selected = 0;
2689 } else if self.selected >= self.total {
2690 self.selected = self.total.saturating_sub(1);
2691 }
2692 }
2693
2694 pub const fn toggle_group(&mut self) {
2695 self.group_by = match self.group_by {
2696 LicenseGroupBy::License => LicenseGroupBy::Category,
2697 LicenseGroupBy::Category => LicenseGroupBy::License,
2698 };
2699 self.selected = 0;
2700 self.reset_component_scroll();
2701 }
2702}
2703
2704impl Default for LicenseViewState {
2705 fn default() -> Self {
2706 Self::new()
2707 }
2708}
2709
2710impl ListNavigation for LicenseViewState {
2711 fn selected(&self) -> usize {
2712 self.selected
2713 }
2714
2715 fn set_selected(&mut self, idx: usize) {
2716 self.selected = idx;
2717 }
2718
2719 fn total(&self) -> usize {
2720 self.total
2721 }
2722
2723 fn set_total(&mut self, total: usize) {
2724 self.total = total;
2725 }
2726}
2727
2728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2730pub(crate) enum LicenseGroupBy {
2731 License,
2732 Category,
2733}
2734
2735#[derive(Debug, Clone)]
2737pub(crate) struct DependencyViewState {
2738 pub selected: usize,
2740 pub total: usize,
2742 pub expanded: HashSet<String>,
2744 pub scroll_offset: usize,
2746 pub search_query: String,
2748 pub search_active: bool,
2750 pub detail_scroll: u16,
2752 pub roots_initialized: bool,
2754 expanded_snapshot: HashSet<String>,
2756 pub cached_flat_nodes: Vec<super::views::FlatDepNode>,
2758 cached_search_match: (String, Option<usize>),
2760 pub(crate) cached_graph: Option<super::views::DependencyGraph>,
2762}
2763
2764impl DependencyViewState {
2765 pub fn new() -> Self {
2766 Self {
2767 selected: 0,
2768 total: 0,
2769 expanded: HashSet::new(),
2770 scroll_offset: 0,
2771 search_query: String::new(),
2772 search_active: false,
2773 detail_scroll: 0,
2774 roots_initialized: false,
2775 expanded_snapshot: HashSet::new(),
2776 cached_flat_nodes: Vec::new(),
2777 cached_search_match: (String::new(), None),
2778 cached_graph: None,
2779 }
2780 }
2781
2782 pub fn toggle_expand(&mut self, node_id: &str) {
2783 if self.expanded.contains(node_id) {
2784 self.expanded.remove(node_id);
2785 } else {
2786 self.expanded.insert(node_id.to_string());
2787 }
2788 }
2789
2790 pub fn is_expanded(&self, node_id: &str) -> bool {
2791 self.expanded.contains(node_id)
2792 }
2793
2794 pub fn expand_all(&mut self, all_node_ids: &[String]) {
2795 self.expanded.extend(all_node_ids.iter().cloned());
2796 }
2797
2798 pub fn collapse_all(&mut self) {
2799 self.expanded.clear();
2800 }
2801
2802 pub fn start_search(&mut self) {
2803 self.search_active = true;
2804 }
2805
2806 pub fn stop_search(&mut self) {
2807 self.search_active = false;
2808 }
2809
2810 pub fn clear_search(&mut self) {
2811 self.search_query.clear();
2812 self.search_active = false;
2813 }
2814
2815 pub fn search_push(&mut self, c: char) {
2816 self.search_query.push(c);
2817 }
2818
2819 pub fn search_pop(&mut self) {
2820 self.search_query.pop();
2821 }
2822
2823 pub fn are_flat_nodes_valid(&self) -> bool {
2825 !self.cached_flat_nodes.is_empty() && self.expanded_snapshot == self.expanded
2826 }
2827
2828 pub fn set_cached_flat_nodes(&mut self, nodes: Vec<super::views::FlatDepNode>) {
2830 self.cached_flat_nodes = nodes;
2831 self.expanded_snapshot = self.expanded.clone();
2832 }
2833
2834 pub fn get_search_match_count(&mut self) -> Option<usize> {
2836 if self.search_query.is_empty() {
2837 return None;
2838 }
2839 if self.cached_search_match.0 == self.search_query {
2840 return self.cached_search_match.1;
2841 }
2842 let q = self.search_query.to_lowercase();
2843 let count = self
2844 .cached_flat_nodes
2845 .iter()
2846 .filter(|n| n.name.to_lowercase().contains(&q))
2847 .count();
2848 self.cached_search_match = (self.search_query.clone(), Some(count));
2849 Some(count)
2850 }
2851}
2852
2853impl Default for DependencyViewState {
2854 fn default() -> Self {
2855 Self::new()
2856 }
2857}
2858
2859impl ListNavigation for DependencyViewState {
2860 fn selected(&self) -> usize {
2861 self.selected
2862 }
2863
2864 fn set_selected(&mut self, idx: usize) {
2865 self.selected = idx;
2866 }
2867
2868 fn total(&self) -> usize {
2869 self.total
2870 }
2871
2872 fn set_total(&mut self, total: usize) {
2873 self.total = total;
2874 }
2875}
2876
2877#[derive(Debug, Clone)]
2879pub(crate) struct SearchState {
2880 pub active: bool,
2881 pub query: String,
2882 pub results: Vec<SearchResult>,
2883 pub selected: usize,
2884}
2885
2886impl SearchState {
2887 pub const fn new() -> Self {
2888 Self {
2889 active: false,
2890 query: String::new(),
2891 results: Vec::new(),
2892 selected: 0,
2893 }
2894 }
2895
2896 pub fn push_char(&mut self, c: char) {
2897 self.query.push(c);
2898 }
2899
2900 pub fn pop_char(&mut self) {
2901 self.query.pop();
2902 }
2903
2904 pub fn select_next(&mut self) {
2905 if !self.results.is_empty() && self.selected < self.results.len() - 1 {
2906 self.selected += 1;
2907 }
2908 }
2909
2910 pub const fn select_prev(&mut self) {
2911 if self.selected > 0 {
2912 self.selected -= 1;
2913 }
2914 }
2915}
2916
2917impl Default for SearchState {
2918 fn default() -> Self {
2919 Self::new()
2920 }
2921}
2922
2923#[derive(Debug, Clone)]
2925pub(crate) enum SearchResult {
2926 Component {
2927 id: String,
2928 name: String,
2929 version: Option<String>,
2930 match_field: String,
2931 },
2932 Vulnerability {
2933 id: String,
2934 component_id: String,
2936 component_name: String,
2938 severity: Option<String>,
2939 },
2940}
2941
2942#[derive(Debug, Clone)]
2944pub struct SbomStats {
2945 pub component_count: usize,
2946 pub vuln_count: usize,
2947 pub license_count: usize,
2948 pub ecosystem_counts: HashMap<String, usize>,
2949 pub vuln_by_severity: HashMap<String, usize>,
2950 pub license_counts: HashMap<String, usize>,
2951 pub critical_count: usize,
2952 pub high_count: usize,
2953 pub medium_count: usize,
2954 pub low_count: usize,
2955 pub unknown_count: usize,
2956 pub eol_count: usize,
2957 pub eol_approaching_count: usize,
2958 pub eol_supported_count: usize,
2959 pub eol_security_only_count: usize,
2960 pub eol_enriched: bool,
2961}
2962
2963impl SbomStats {
2964 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
2965 let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
2966 let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
2967 let mut license_counts: HashMap<String, usize> = HashMap::new();
2968 let mut vuln_count = 0;
2969 let mut critical_count = 0;
2970 let mut high_count = 0;
2971 let mut medium_count = 0;
2972 let mut low_count = 0;
2973 let mut unknown_count = 0;
2974 let mut eol_count = 0;
2975 let mut eol_approaching_count = 0;
2976 let mut eol_supported_count = 0;
2977 let mut eol_security_only_count = 0;
2978 let mut eol_enriched = false;
2979
2980 for comp in sbom.components.values() {
2981 let eco = comp
2983 .ecosystem
2984 .as_ref()
2985 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
2986 *ecosystem_counts.entry(eco).or_insert(0) += 1;
2987
2988 for lic in &comp.licenses.declared {
2990 *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
2991 }
2992 if comp.licenses.declared.is_empty() {
2993 *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
2994 }
2995
2996 for vuln in &comp.vulnerabilities {
2998 vuln_count += 1;
2999 let sev = vuln
3000 .severity
3001 .as_ref()
3002 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
3003 *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
3004
3005 match sev.to_lowercase().as_str() {
3006 "critical" => critical_count += 1,
3007 "high" => high_count += 1,
3008 "medium" => medium_count += 1,
3009 "low" => low_count += 1,
3010 _ => unknown_count += 1,
3011 }
3012 }
3013
3014 if let Some(eol) = &comp.eol {
3016 use crate::model::EolStatus;
3017 eol_enriched = true;
3018 match eol.status {
3019 EolStatus::EndOfLife => eol_count += 1,
3020 EolStatus::ApproachingEol => eol_approaching_count += 1,
3021 EolStatus::Supported => eol_supported_count += 1,
3022 EolStatus::SecurityOnly => eol_security_only_count += 1,
3023 _ => {}
3024 }
3025 }
3026 }
3027
3028 Self {
3029 component_count: sbom.components.len(),
3030 vuln_count,
3031 license_count: license_counts.len(),
3032 ecosystem_counts,
3033 vuln_by_severity,
3034 license_counts,
3035 critical_count,
3036 high_count,
3037 medium_count,
3038 low_count,
3039 unknown_count,
3040 eol_count,
3041 eol_approaching_count,
3042 eol_supported_count,
3043 eol_security_only_count,
3044 eol_enriched,
3045 }
3046 }
3047}
3048
3049#[derive(Debug, Clone)]
3051pub struct ViewBreadcrumb {
3052 pub tab: ViewTab,
3054 pub label: String,
3056 pub selection_index: usize,
3058}
3059
3060#[derive(Debug, Clone, Default)]
3062pub struct ViewNavigationContext {
3063 pub breadcrumbs: Vec<ViewBreadcrumb>,
3065 pub target_component: Option<String>,
3067 pub target_vulnerability: Option<String>,
3069}
3070
3071impl ViewNavigationContext {
3072 #[must_use]
3073 pub const fn new() -> Self {
3074 Self {
3075 breadcrumbs: Vec::new(),
3076 target_component: None,
3077 target_vulnerability: None,
3078 }
3079 }
3080
3081 pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
3083 self.breadcrumbs.push(ViewBreadcrumb {
3084 tab,
3085 label,
3086 selection_index,
3087 });
3088 }
3089
3090 pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
3092 self.breadcrumbs.pop()
3093 }
3094
3095 pub fn clear_breadcrumbs(&mut self) {
3097 self.breadcrumbs.clear();
3098 }
3099
3100 #[must_use]
3102 pub fn has_history(&self) -> bool {
3103 !self.breadcrumbs.is_empty()
3104 }
3105
3106 #[must_use]
3108 pub fn breadcrumb_trail(&self) -> String {
3109 self.breadcrumbs
3110 .iter()
3111 .map(|b| format!("{}: {}", b.tab.title(), b.label))
3112 .collect::<Vec<_>>()
3113 .join(" > ")
3114 }
3115
3116 pub fn clear_targets(&mut self) {
3118 self.target_component = None;
3119 self.target_vulnerability = None;
3120 }
3121}
3122
3123#[cfg(test)]
3124mod tests {
3125 use super::*;
3126 use crate::model::NormalizedSbom;
3127
3128 #[test]
3129 fn test_view_app_creation() {
3130 let sbom = NormalizedSbom::default();
3131 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3132 app.active_tab = ViewTab::Overview;
3134 assert_eq!(app.active_tab, ViewTab::Overview);
3135 assert!(!app.should_quit);
3136 }
3137
3138 #[test]
3139 fn test_tab_navigation() {
3140 let sbom = NormalizedSbom::default();
3141 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3142 app.active_tab = ViewTab::Overview;
3144
3145 app.next_tab();
3146 assert_eq!(app.active_tab, ViewTab::Tree);
3147
3148 app.next_tab();
3149 assert_eq!(app.active_tab, ViewTab::Vulnerabilities);
3150
3151 app.prev_tab();
3152 assert_eq!(app.active_tab, ViewTab::Tree);
3153 }
3154
3155 #[test]
3156 fn test_vuln_state_navigation_with_zero_total() {
3157 let mut state = VulnExplorerState::new();
3159 assert_eq!(state.total, 0);
3160 assert_eq!(state.selected, 0);
3161
3162 state.select_next();
3164 assert_eq!(state.selected, 0);
3165
3166 state.select_prev();
3167 assert_eq!(state.selected, 0);
3168 }
3169
3170 #[test]
3171 fn test_vuln_state_clamp_selection() {
3172 let mut state = VulnExplorerState::new();
3173 state.total = 5;
3174 state.selected = 10; state.clamp_selection();
3177 assert_eq!(state.selected, 4); state.total = 0;
3180 state.clamp_selection();
3181 assert_eq!(state.selected, 0); }
3183
3184 #[test]
3185 fn test_license_state_navigation_with_zero_total() {
3186 let mut state = LicenseViewState::new();
3187 assert_eq!(state.total, 0);
3188 assert_eq!(state.selected, 0);
3189
3190 state.select_next();
3192 assert_eq!(state.selected, 0);
3193
3194 state.select_prev();
3195 assert_eq!(state.selected, 0);
3196 }
3197
3198 #[test]
3199 fn test_license_state_clamp_selection() {
3200 let mut state = LicenseViewState::new();
3201 state.total = 3;
3202 state.selected = 5; state.clamp_selection();
3205 assert_eq!(state.selected, 2); }
3207
3208 #[test]
3209 fn test_dependency_state_navigation() {
3210 let mut state = DependencyViewState::new();
3211 assert_eq!(state.total, 0);
3212 assert_eq!(state.selected, 0);
3213
3214 state.select_next();
3216 assert_eq!(state.selected, 0);
3217
3218 state.total = 5;
3220 state.select_next();
3221 assert_eq!(state.selected, 1);
3222
3223 state.select_next();
3224 state.select_next();
3225 state.select_next();
3226 assert_eq!(state.selected, 4); state.select_next();
3229 assert_eq!(state.selected, 4); state.select_prev();
3232 assert_eq!(state.selected, 3);
3233 }
3234
3235 #[test]
3236 fn test_dependency_state_expand_collapse() {
3237 let mut state = DependencyViewState::new();
3238
3239 assert!(!state.is_expanded("node1"));
3240
3241 state.toggle_expand("node1");
3242 assert!(state.is_expanded("node1"));
3243
3244 state.toggle_expand("node1");
3245 assert!(!state.is_expanded("node1"));
3246 }
3247
3248 #[test]
3249 fn test_tabs_for_profile_sbom() {
3250 let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Sbom);
3251 assert_eq!(tabs.len(), 8);
3252 assert_eq!(tabs[0], ViewTab::Overview);
3253 assert_eq!(tabs[1], ViewTab::Tree);
3254 assert_eq!(tabs[7], ViewTab::Source);
3255 assert!(!tabs.contains(&ViewTab::Algorithms));
3256 }
3257
3258 #[test]
3259 fn test_tabs_for_profile_cbom() {
3260 let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Cbom);
3261 assert_eq!(tabs.len(), 8);
3262 assert_eq!(tabs[0], ViewTab::Overview);
3263 assert_eq!(tabs[1], ViewTab::Algorithms);
3264 assert_eq!(tabs[2], ViewTab::Certificates);
3265 assert_eq!(tabs[3], ViewTab::Keys);
3266 assert_eq!(tabs[4], ViewTab::Protocols);
3267 assert_eq!(tabs[5], ViewTab::Quality);
3268 assert_eq!(tabs[6], ViewTab::PqcCompliance);
3269 assert_eq!(tabs[7], ViewTab::Source);
3270 assert!(!tabs.contains(&ViewTab::Tree));
3271 }
3272
3273 #[test]
3274 fn test_cbom_tab_navigation_cycles() {
3275 let sbom = NormalizedSbom::default();
3276 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3277 app.active_tab = ViewTab::Overview;
3278
3279 app.next_tab();
3280 assert_eq!(app.active_tab, ViewTab::Algorithms);
3281
3282 app.next_tab();
3283 assert_eq!(app.active_tab, ViewTab::Certificates);
3284
3285 app.active_tab = ViewTab::Source;
3287 app.next_tab();
3288 assert_eq!(app.active_tab, ViewTab::Overview);
3289
3290 app.prev_tab();
3292 assert_eq!(app.active_tab, ViewTab::Source);
3293 }
3294
3295 #[test]
3296 fn test_per_tab_selection_independent() {
3297 let mut sbom = NormalizedSbom::default();
3298 for i in 0..5 {
3300 let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3301 c.component_type = crate::model::ComponentType::Cryptographic;
3302 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3303 crate::model::CryptoAssetType::Algorithm,
3304 ));
3305 sbom.add_component(c);
3306 }
3307 for i in 0..2 {
3308 let mut c = crate::model::Component::new(format!("cert-{i}"), format!("cert-{i}@1.0"));
3309 c.component_type = crate::model::ComponentType::Cryptographic;
3310 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3311 crate::model::CryptoAssetType::Certificate,
3312 ));
3313 sbom.add_component(c);
3314 }
3315
3316 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3317
3318 app.active_tab = ViewTab::Algorithms;
3320 app.navigate_down();
3321 app.navigate_down();
3322 assert_eq!(app.algorithms_selected, 2);
3323
3324 app.active_tab = ViewTab::Certificates;
3326 assert_eq!(app.certificates_selected, 0);
3327
3328 app.navigate_down();
3330 assert_eq!(app.certificates_selected, 1);
3331
3332 app.active_tab = ViewTab::Algorithms;
3334 assert_eq!(app.algorithms_selected, 2);
3335 }
3336
3337 #[test]
3338 fn test_crypto_count_for_tab() {
3339 let mut sbom = NormalizedSbom::default();
3340 for i in 0..3 {
3341 let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3342 c.component_type = crate::model::ComponentType::Cryptographic;
3343 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3344 crate::model::CryptoAssetType::Algorithm,
3345 ));
3346 sbom.add_component(c);
3347 }
3348 let mut c = crate::model::Component::new("cert-0".to_string(), "cert-0@1.0".to_string());
3349 c.component_type = crate::model::ComponentType::Cryptographic;
3350 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3351 crate::model::CryptoAssetType::Certificate,
3352 ));
3353 sbom.add_component(c);
3354
3355 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3356
3357 app.active_tab = ViewTab::Algorithms;
3358 assert_eq!(app.crypto_count_for_tab(), 3);
3359
3360 app.active_tab = ViewTab::Certificates;
3361 assert_eq!(app.crypto_count_for_tab(), 1);
3362
3363 app.active_tab = ViewTab::Keys;
3364 assert_eq!(app.crypto_count_for_tab(), 0);
3365
3366 app.active_tab = ViewTab::Crypto;
3368 assert_eq!(app.crypto_count_for_tab(), 4);
3369 }
3370}