1use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex};
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 pub(crate) tab_window: crate::tui::shared::TabWindow,
37
38 pub(crate) tree_search_query: String,
40
41 pub(crate) tree_search_active: bool,
43
44 pub(crate) cached_tree_nodes: Vec<crate::tui::widgets::TreeNode>,
46 tree_cache_key: Option<TreeCacheKey>,
48
49 pub(crate) selected_component: Option<String>,
51
52 pub(crate) component_tab: ComponentDetailTab,
54
55 pub(crate) component_detail_scroll: u16,
57
58 pub(crate) vuln_state: VulnExplorerState,
60
61 pub(crate) license_state: LicenseViewState,
63
64 pub(crate) dependency_state: DependencyViewState,
66
67 pub(crate) search_state: SearchState,
69
70 pub(crate) focus_panel: FocusPanel,
72
73 pub(crate) show_help: bool,
75
76 pub(crate) help_scroll: u16,
78
79 pub(crate) help_max_scroll: u16,
81
82 pub(crate) show_export: bool,
84
85 pub(crate) show_legend: bool,
87
88 pub(crate) status_message: Option<String>,
90
91 pub(crate) status_sticky: bool,
93
94 pub(crate) navigation_ctx: ViewNavigationContext,
96
97 pub(crate) should_quit: bool,
99
100 pub(crate) tick: u64,
102
103 pub(crate) stats: SbomStats,
105
106 pub(crate) quality_report: QualityReport,
108
109 pub(crate) quality_state: QualityViewState,
111
112 pub(crate) compliance_results: Option<Vec<ComplianceResult>>,
114
115 pub(crate) compliance_state: StandardComplianceState,
117
118 pub(crate) sbom_index: NormalizedSbomIndex,
120
121 pub(crate) source_state: SourcePanelState,
123
124 pub(crate) crypto_list_selected: usize,
126 pub(crate) algorithms_selected: usize,
128 pub(crate) certificates_selected: usize,
130 pub(crate) keys_selected: usize,
132 pub(crate) protocols_selected: usize,
134 pub(crate) pqc_selected: usize,
136
137 pub(crate) algorithm_sort_by: AlgorithmSortBy,
139
140 pub(crate) models_selected: usize,
142 pub(crate) datasets_selected: usize,
144 pub(crate) models_detail_scroll: u16,
146 pub(crate) datasets_detail_scroll: u16,
148 pub(crate) ai_readiness_scroll: usize,
150
151 pub(crate) bookmarked: HashSet<String>,
153
154 pub(crate) export_template: Option<String>,
156
157 pub(crate) cra_sidecar: Option<crate::model::CraSidecarMetadata>,
162
163 pub(crate) cra_product_class: Option<crate::model::CraProductClass>,
169}
170
171impl ViewApp {
172 #[must_use]
174 pub fn new(
175 sbom: NormalizedSbom,
176 raw_content: &str,
177 bom_profile: crate::model::BomProfile,
178 ) -> Self {
179 let stats = SbomStats::from_sbom(&sbom);
180
181 let scoring_profile = crate::tui::scoring_profile_for(bom_profile);
183 let scorer = QualityScorer::new(scoring_profile);
184 let quality_report = scorer.score(&sbom);
185 let quality_state = QualityViewState::new(quality_report.recommendations.len());
186
187 let compliance_state = StandardComplianceState::new();
188
189 let sbom_index = sbom.build_index();
191
192 let source_state = SourcePanelState::new(raw_content);
194
195 let mut tree_state = TreeState::new();
197 for eco in stats.ecosystem_counts.keys().take(3) {
198 tree_state.expand(&format!("eco:{eco}"));
199 }
200
201 let available_tabs = ViewTab::tabs_for_profile(bom_profile);
203 let initial_tab = crate::config::TuiPreferences::load()
204 .last_view_tab
205 .as_deref()
206 .and_then(ViewTab::from_str_opt)
207 .filter(|t| available_tabs.contains(t))
208 .unwrap_or(ViewTab::Overview);
209
210 let mut app = Self {
211 sbom,
212 bom_profile,
213 active_tab: initial_tab,
214 tree_state,
215 tree_group_by: TreeGroupBy::Ecosystem,
216 tree_filter: TreeFilter::All,
217 tab_window: crate::tui::shared::TabWindow::default(),
218 tree_search_query: String::new(),
219 tree_search_active: false,
220 cached_tree_nodes: Vec::new(),
221 tree_cache_key: None,
222 selected_component: None,
223 component_tab: ComponentDetailTab::Overview,
224 component_detail_scroll: 0,
225 vuln_state: VulnExplorerState::new(),
226 license_state: LicenseViewState::new(),
227 dependency_state: DependencyViewState::new(),
228 search_state: SearchState::new(),
229 focus_panel: FocusPanel::Left,
230 show_help: false,
231 help_scroll: 0,
232 help_max_scroll: 0,
233 show_export: false,
234 show_legend: false,
235 status_message: None,
236 status_sticky: false,
237 navigation_ctx: ViewNavigationContext::new(),
238 should_quit: false,
239 tick: 0,
240 stats,
241 quality_report,
242 quality_state,
243 compliance_results: None,
244 compliance_state,
245 sbom_index,
246 source_state,
247 crypto_list_selected: 0,
248 algorithms_selected: 0,
249 certificates_selected: 0,
250 keys_selected: 0,
251 protocols_selected: 0,
252 pqc_selected: 0,
253 algorithm_sort_by: AlgorithmSortBy::default(),
254 models_selected: 0,
255 datasets_selected: 0,
256 models_detail_scroll: 0,
257 datasets_detail_scroll: 0,
258 ai_readiness_scroll: 0,
259 bookmarked: HashSet::new(),
260 export_template: None,
261 cra_sidecar: None,
262 cra_product_class: None,
263 };
264
265 let cache = super::views::build_vuln_cache(&app);
267 app.vuln_state.set_cache(cache);
268
269 app
270 }
271
272 pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
276 self.compliance_results = None;
278 self.cra_sidecar = Some(sidecar);
279 self
280 }
281
282 pub fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
287 self.compliance_results = None;
289 self.cra_product_class = Some(class);
290 self
291 }
292
293 pub fn ensure_compliance_results(&mut self) {
295 if self.compliance_results.is_none() {
296 self.compliance_results = Some(compute_compliance_results(
297 &self.sbom,
298 self.cra_sidecar.as_ref(),
299 self.cra_product_class,
300 ));
301 }
302 }
303
304 pub fn next_tab(&mut self) {
306 let tabs = ViewTab::tabs_for_profile(self.bom_profile);
307 let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
308 self.active_tab = tabs[(idx + 1) % tabs.len()];
309 self.focus_panel = FocusPanel::Left;
310 }
311
312 pub fn prev_tab(&mut self) {
314 let tabs = ViewTab::tabs_for_profile(self.bom_profile);
315 let idx = tabs.iter().position(|t| *t == self.active_tab).unwrap_or(0);
316 self.active_tab = tabs[(idx + tabs.len() - 1) % tabs.len()];
317 self.focus_panel = FocusPanel::Left;
318 }
319
320 pub fn select_tab(&mut self, tab: ViewTab) {
322 self.active_tab = tab;
323 self.focus_panel = FocusPanel::Left;
324 }
325
326 pub fn active_crypto_selected(&self) -> usize {
332 match self.active_tab {
333 ViewTab::Algorithms => self.algorithms_selected,
334 ViewTab::Certificates => self.certificates_selected,
335 ViewTab::Keys => self.keys_selected,
336 ViewTab::Protocols => self.protocols_selected,
337 ViewTab::PqcCompliance => self.pqc_selected,
338 _ => self.crypto_list_selected,
339 }
340 }
341
342 pub fn active_crypto_selected_mut(&mut self) -> &mut usize {
344 match self.active_tab {
345 ViewTab::Algorithms => &mut self.algorithms_selected,
346 ViewTab::Certificates => &mut self.certificates_selected,
347 ViewTab::Keys => &mut self.keys_selected,
348 ViewTab::Protocols => &mut self.protocols_selected,
349 ViewTab::PqcCompliance => &mut self.pqc_selected,
350 _ => &mut self.crypto_list_selected,
351 }
352 }
353
354 pub fn visible_crypto_components(&self, tab: ViewTab) -> Vec<&Component> {
361 use crate::model::{ComponentType, CryptoAssetType};
362 let filter = match tab {
363 ViewTab::Algorithms => Some(CryptoAssetType::Algorithm),
364 ViewTab::Certificates => Some(CryptoAssetType::Certificate),
365 ViewTab::Keys => Some(CryptoAssetType::RelatedCryptoMaterial),
366 ViewTab::Protocols => Some(CryptoAssetType::Protocol),
367 _ => None,
369 };
370 let mut list: Vec<&Component> = self
371 .sbom
372 .components
373 .values()
374 .filter(|c| {
375 c.component_type == ComponentType::Cryptographic
376 && filter.as_ref().is_none_or(|f| {
377 c.crypto_properties
378 .as_ref()
379 .is_some_and(|cp| &cp.asset_type == f)
380 })
381 })
382 .collect();
383 match tab {
384 ViewTab::Algorithms => {
386 list.sort_by(|a, b| match self.algorithm_sort_by {
387 AlgorithmSortBy::Name => a.name.cmp(&b.name),
388 AlgorithmSortBy::Family => {
389 let fam = |c: &&Component| {
390 c.crypto_properties
391 .as_ref()
392 .and_then(|cp| cp.algorithm_properties.as_ref())
393 .and_then(|algo| algo.algorithm_family.clone())
394 .unwrap_or_default()
395 };
396 fam(a).cmp(&fam(b))
397 }
398 AlgorithmSortBy::QuantumLevel => {
399 let lvl = |c: &&Component| {
400 c.crypto_properties
401 .as_ref()
402 .and_then(|cp| cp.algorithm_properties.as_ref())
403 .and_then(|algo| algo.nist_quantum_security_level)
404 .unwrap_or(0)
405 };
406 lvl(b).cmp(&lvl(a)) }
408 AlgorithmSortBy::Strength => {
409 let strength = |c: &&Component| -> u8 {
410 let Some(cp) = &c.crypto_properties else {
411 return 1;
412 };
413 let Some(algo) = &cp.algorithm_properties else {
414 return 1;
415 };
416 if algo.is_weak_by_name(&c.name) {
417 return 0;
418 }
419 if algo.nist_quantum_security_level == Some(0) {
420 return 1;
421 }
422 2
423 };
424 strength(a).cmp(&strength(b))
425 }
426 });
427 }
428 ViewTab::Certificates => {
430 let days_remaining = |c: &&Component| -> i64 {
431 c.crypto_properties
432 .as_ref()
433 .and_then(|cp| cp.certificate_properties.as_ref())
434 .and_then(|cert| cert.validity_days())
435 .unwrap_or(i64::MAX)
436 };
437 list.sort_by_key(days_remaining);
438 }
439 _ => {}
441 }
442 list
443 }
444
445 pub fn crypto_count_for_tab(&self) -> usize {
447 use crate::model::{ComponentType, CryptoAssetType};
448 let filter = match self.active_tab {
449 ViewTab::Algorithms | ViewTab::PqcCompliance => Some(CryptoAssetType::Algorithm),
450 ViewTab::Certificates => Some(CryptoAssetType::Certificate),
451 ViewTab::Keys => Some(CryptoAssetType::RelatedCryptoMaterial),
452 ViewTab::Protocols => Some(CryptoAssetType::Protocol),
453 _ => None,
454 };
455 self.sbom
456 .components
457 .values()
458 .filter(|c| {
459 c.component_type == ComponentType::Cryptographic
460 && filter.as_ref().is_none_or(|f| {
461 c.crypto_properties
462 .as_ref()
463 .is_some_and(|cp| &cp.asset_type == f)
464 })
465 })
466 .count()
467 }
468
469 #[must_use]
478 pub fn ml_model_count(&self) -> usize {
479 use crate::model::ComponentType;
480 self.sbom
481 .components
482 .values()
483 .filter(|c| {
484 c.component_type == ComponentType::MachineLearningModel || c.ml_model.is_some()
485 })
486 .count()
487 }
488
489 #[must_use]
493 pub fn ai_readiness_max_scroll(&self) -> usize {
494 let checks = self
495 .quality_report
496 .ai_readiness_metrics
497 .as_ref()
498 .map_or(0, |m| m.checks.len());
499 checks
500 .max(self.quality_report.recommendations.len())
501 .saturating_sub(1)
502 }
503
504 #[must_use]
506 pub fn dataset_count(&self) -> usize {
507 use crate::model::ComponentType;
508 self.sbom
509 .components
510 .values()
511 .filter(|c| c.component_type == ComponentType::Data)
512 .count()
513 }
514
515 #[must_use]
523 pub fn get_sort_key(
524 &self,
525 id: &crate::model::CanonicalId,
526 ) -> Option<&crate::model::ComponentSortKey> {
527 self.sbom_index.sort_key(id)
528 }
529
530 #[must_use]
532 pub fn get_dependencies(
533 &self,
534 id: &crate::model::CanonicalId,
535 ) -> Vec<&crate::model::DependencyEdge> {
536 self.sbom_index.dependencies_of(id, &self.sbom.edges)
537 }
538
539 #[must_use]
541 pub fn get_dependents(
542 &self,
543 id: &crate::model::CanonicalId,
544 ) -> Vec<&crate::model::DependencyEdge> {
545 self.sbom_index.dependents_of(id, &self.sbom.edges)
546 }
547
548 #[must_use]
550 pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
551 self.sbom.search_by_name_indexed(query, &self.sbom_index)
552 }
553
554 pub const fn toggle_focus(&mut self) {
556 self.focus_panel = match self.focus_panel {
557 FocusPanel::Left => FocusPanel::Right,
558 FocusPanel::Right => FocusPanel::Left,
559 };
560 }
561
562 pub fn start_search(&mut self) {
564 self.search_state.active = true;
565 self.search_state.query.clear();
566 self.search_state.results.clear();
567 self.search_state.search_error = None;
568 }
569
570 pub const fn stop_search(&mut self) {
572 self.search_state.active = false;
573 }
574
575 pub fn execute_search(&mut self) {
577 let query = self.search_state.query.clone();
578 if query.len() < 2 {
579 self.search_state.results = Vec::new();
580 self.search_state.selected = 0;
581 return;
582 }
583 match crate::tui::app_states::SearchMatcher::build(&query, self.search_state.mode) {
585 Ok(matcher) => {
586 self.search_state.search_error = None;
587 self.search_state.results = self.search(&matcher);
588 }
589 Err(e) => {
590 self.search_state.search_error = Some(e);
591 self.search_state.results = Vec::new();
592 }
593 }
594 self.search_state.selected = 0;
595 }
596
597 fn search(&self, matcher: &crate::tui::app_states::SearchMatcher) -> Vec<SearchResult> {
599 let mut results = Vec::new();
600
601 for (id, comp) in &self.sbom.components {
603 if matcher.is_match(&comp.name) {
604 results.push(SearchResult::Component {
605 id: id.value().to_string(),
606 name: comp.name.clone(),
607 version: comp.version.clone(),
608 match_field: "name".to_string(),
609 });
610 } else if let Some(purl) = &comp.identifiers.purl
611 && matcher.is_match(purl)
612 {
613 results.push(SearchResult::Component {
614 id: id.value().to_string(),
615 name: comp.name.clone(),
616 version: comp.version.clone(),
617 match_field: "purl".to_string(),
618 });
619 }
620 }
621
622 for (_, comp) in &self.sbom.components {
624 for vuln in &comp.vulnerabilities {
625 if matcher.is_match(&vuln.id) {
626 results.push(SearchResult::Vulnerability {
627 id: vuln.id.clone(),
628 component_id: comp.canonical_id.to_string(), component_name: comp.name.clone(),
630 severity: vuln.severity.as_ref().map(std::string::ToString::to_string),
631 });
632 }
633 }
634 }
635
636 results.truncate(50);
638 results
639 }
640
641 #[must_use]
643 pub fn get_selected_component(&self) -> Option<&Component> {
644 self.selected_component.as_ref().and_then(|selected_id| {
645 self.sbom
646 .components
647 .iter()
648 .find(|(id, _)| id.value() == selected_id)
649 .map(|(_, comp)| comp)
650 })
651 }
652
653 pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
655 let group_id = {
656 let Some(comp) = self
657 .sbom
658 .components
659 .iter()
660 .find(|(id, _)| id.value() == component_id)
661 .map(|(_, comp)| comp)
662 else {
663 return false;
664 };
665 self.tree_group_id_for_component(comp)
666 };
667 if let Some(ref group_id) = group_id {
668 self.tree_state.expand(group_id);
669 }
670
671 self.ensure_tree_cache();
672 let mut flat_items = Vec::new();
673 flatten_tree_for_selection(&self.cached_tree_nodes, &self.tree_state, &mut flat_items);
674
675 if let Some(index) = flat_items
676 .iter()
677 .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
678 {
679 self.tree_state.selected = index;
680 return true;
681 }
682
683 if let Some(group_id) = group_id
684 && let Some(index) = flat_items
685 .iter()
686 .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
687 {
688 self.tree_state.selected = index;
689 }
690
691 false
692 }
693
694 pub fn jump_to_vuln_by_id(&mut self, vuln_id: &str) -> bool {
698 if self.vuln_state.cached_data.is_none() {
700 let cache = super::views::build_vuln_cache(self);
701 self.vuln_state.cached_data = Some(std::sync::Arc::new(cache));
702 }
703 let Some(cache) = &self.vuln_state.cached_data else {
704 return false;
705 };
706 if let Some(vuln_idx) = cache.vulns.iter().position(|v| v.vuln_id == vuln_id) {
708 let display_idx = self
711 .vuln_state
712 .cached_display_items
713 .iter()
714 .position(|item| {
715 matches!(item, super::views::VulnDisplayItem::Vuln { idx, .. } if *idx == vuln_idx)
716 })
717 .unwrap_or(vuln_idx);
718 self.vuln_state.selected = display_idx;
719 return true;
720 }
721 false
722 }
723
724 pub fn find_source_item_for_ref(&mut self, ref_value: &str) -> Option<usize> {
727 self.source_state.ensure_flat_cache();
728 let quoted = format!("\"{ref_value}\"");
729 self.source_state
730 .cached_flat_items
731 .iter()
732 .position(|item| item.value_preview == quoted || item.value_preview == ref_value)
733 }
734
735 #[must_use]
738 pub fn get_selected_group_info(&self) -> Option<(String, Vec<String>)> {
739 let nodes = self.build_tree_nodes();
740 let mut flat_items = Vec::new();
741 flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
742
743 let selected = flat_items.get(self.tree_state.selected)?;
744 match selected {
745 SelectedTreeNode::Group(group_id) => {
746 fn find_group_children(
748 nodes: &[crate::tui::widgets::TreeNode],
749 target_id: &str,
750 ) -> Option<(String, Vec<String>)> {
751 for node in nodes {
752 if let crate::tui::widgets::TreeNode::Group {
753 id,
754 label,
755 children,
756 ..
757 } = node
758 {
759 if id == target_id {
760 let child_ids: Vec<String> = children
761 .iter()
762 .filter_map(|c| match c {
763 crate::tui::widgets::TreeNode::Component { id, .. } => {
764 Some(id.clone())
765 }
766 crate::tui::widgets::TreeNode::Group { .. } => None,
767 })
768 .collect();
769 return Some((label.clone(), child_ids));
770 }
771 if let Some(result) = find_group_children(children, target_id) {
773 return Some(result);
774 }
775 }
776 }
777 None
778 }
779 find_group_children(nodes, group_id)
780 }
781 SelectedTreeNode::Component(_) => None,
782 }
783 }
784
785 pub fn toggle_bookmark(&mut self) {
787 if let Some(ref comp_id) = self.selected_component {
788 if self.bookmarked.contains(comp_id) {
789 self.bookmarked.remove(comp_id);
790 } else {
791 self.bookmarked.insert(comp_id.clone());
792 }
793 } else if let Some(node) = self.get_selected_tree_node() {
794 match node {
795 SelectedTreeNode::Component(id) => {
796 if self.bookmarked.contains(&id) {
797 self.bookmarked.remove(&id);
798 } else {
799 self.bookmarked.insert(id);
800 }
801 }
802 SelectedTreeNode::Group(_) => {}
803 }
804 }
805 }
806
807 pub fn toggle_tree_grouping(&mut self) {
809 self.tree_group_by = match self.tree_group_by {
810 TreeGroupBy::Ecosystem => TreeGroupBy::License,
811 TreeGroupBy::License => TreeGroupBy::VulnStatus,
812 TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
813 TreeGroupBy::ComponentType => TreeGroupBy::Flat,
814 TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
815 };
816 self.tree_state = TreeState::new(); }
818
819 pub fn toggle_tree_filter(&mut self) {
821 self.tree_filter = match self.tree_filter {
822 TreeFilter::All => TreeFilter::HasVulnerabilities,
823 TreeFilter::HasVulnerabilities => TreeFilter::Critical,
824 TreeFilter::Critical => TreeFilter::Bookmarked,
825 TreeFilter::Bookmarked => TreeFilter::All,
826 };
827 self.tree_state = TreeState::new();
828 }
829
830 pub fn start_tree_search(&mut self) {
832 self.tree_search_active = true;
833 self.tree_search_query.clear();
834 }
835
836 pub const fn stop_tree_search(&mut self) {
838 self.tree_search_active = false;
839 }
840
841 pub fn clear_tree_search(&mut self) {
843 self.tree_search_query.clear();
844 self.tree_search_active = false;
845 self.tree_state = TreeState::new();
846 }
847
848 pub fn tree_search_push_char(&mut self, c: char) {
850 self.tree_search_query.push(c);
851 self.tree_state = TreeState::new();
852 }
853
854 pub fn tree_search_pop_char(&mut self) {
856 self.tree_search_query.pop();
857 self.tree_state = TreeState::new();
858 }
859
860 pub const fn next_component_tab(&mut self) {
862 self.component_tab = match self.component_tab {
863 ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
864 ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
865 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
866 ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
867 };
868 self.component_detail_scroll = 0;
869 }
870
871 pub const fn prev_component_tab(&mut self) {
873 self.component_tab = match self.component_tab {
874 ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
875 ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
876 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
877 ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
878 };
879 self.component_detail_scroll = 0;
880 }
881
882 pub(crate) const fn select_component_tab(&mut self, tab: ComponentDetailTab) {
884 self.component_tab = tab;
885 self.component_detail_scroll = 0;
886 }
887
888 pub const fn toggle_help(&mut self) {
890 self.show_help = !self.show_help;
891 if self.show_help {
892 self.show_export = false;
893 self.show_legend = false;
894 self.help_scroll = 0;
895 self.help_max_scroll = 0;
896 }
897 }
898
899 pub const fn toggle_export(&mut self) {
901 self.show_export = !self.show_export;
902 if self.show_export {
903 self.show_help = false;
904 self.show_legend = false;
905 }
906 }
907
908 pub const fn toggle_legend(&mut self) {
910 self.show_legend = !self.show_legend;
911 if self.show_legend {
912 self.show_help = false;
913 self.show_export = false;
914 }
915 }
916
917 pub const fn close_overlays(&mut self) {
919 self.show_help = false;
920 self.show_export = false;
921 self.show_legend = false;
922 self.search_state.active = false;
923 self.compliance_state.show_detail = false;
924 }
925
926 #[must_use]
928 pub const fn has_overlay(&self) -> bool {
929 self.show_help
930 || self.show_export
931 || self.show_legend
932 || self.search_state.active
933 || self.compliance_state.show_detail
934 }
935
936 pub fn set_status_message(&mut self, msg: impl Into<String>) {
938 self.status_message = Some(msg.into());
939 }
940
941 pub fn clear_status_message(&mut self) {
946 if self.status_sticky {
947 self.status_sticky = false;
948 } else {
949 self.status_message = None;
950 }
951 }
952
953 pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
958 use crate::reports::ReportConfig;
959 use crate::tui::export::{export_view, view_tab_to_report_type};
960
961 let report_type = view_tab_to_report_type(self.active_tab);
962 let mut cra_checker =
967 crate::quality::ComplianceChecker::new(crate::quality::ComplianceLevel::CraPhase2);
968 if let Some(sidecar) = &self.cra_sidecar {
969 cra_checker = cra_checker.with_sidecar(sidecar.clone());
970 }
971 if let Some(class) = self.cra_product_class {
972 cra_checker = cra_checker.with_product_class(class);
973 }
974 let config = ReportConfig {
975 view_cra_compliance: Some(cra_checker.check(&self.sbom)),
976 ..ReportConfig::with_types(vec![report_type])
977 };
978 let result = export_view(
979 format,
980 &self.sbom,
981 None,
982 &config,
983 self.export_template.as_deref(),
984 );
985
986 if result.success {
987 self.set_status_message(result.message);
988 self.status_sticky = true;
989 } else {
990 self.set_status_message(format!("Export failed: {}", result.message));
991 }
992 }
993
994 pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
996 use crate::tui::export::export_compliance;
997
998 self.ensure_compliance_results();
999 let results = match self.compliance_results.as_ref() {
1000 Some(r) if !r.is_empty() => r,
1001 _ => {
1002 self.set_status_message("No compliance results to export");
1003 return;
1004 }
1005 };
1006
1007 let result = export_compliance(
1008 format,
1009 results,
1010 self.compliance_state.selected_standard,
1011 None,
1012 self.export_template.as_deref(),
1013 );
1014 if result.success {
1015 self.set_status_message(result.message);
1016 self.status_sticky = true;
1017 } else {
1018 self.set_status_message(format!("Export failed: {}", result.message));
1019 }
1020 }
1021
1022 pub fn go_back(&mut self) -> bool {
1024 if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
1025 self.active_tab = breadcrumb.tab;
1026 match breadcrumb.tab {
1028 ViewTab::Vulnerabilities => {
1029 self.vuln_state.selected = breadcrumb.selection_index;
1030 }
1031 ViewTab::Licenses => {
1032 self.license_state.selected = breadcrumb.selection_index;
1033 }
1034 ViewTab::Dependencies => {
1035 self.dependency_state.selected = breadcrumb.selection_index;
1036 }
1037 ViewTab::Tree => {
1038 self.tree_state.selected = breadcrumb.selection_index;
1039 }
1040 ViewTab::Source => {
1041 self.source_state.selected = breadcrumb.selection_index;
1042 }
1043 _ => {}
1044 }
1045 self.focus_panel = FocusPanel::Left;
1046 true
1047 } else {
1048 false
1049 }
1050 }
1051
1052 pub fn navigate_up(&mut self) {
1054 match self.active_tab {
1055 ViewTab::Tree => self.tree_state.select_prev(),
1056 ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
1057 ViewTab::Licenses => self.license_state.select_prev(),
1058 ViewTab::Dependencies => self.dependency_state.select_prev(),
1059 ViewTab::Quality => self.quality_state.select_prev(),
1060 ViewTab::Compliance => self.compliance_state.select_prev(),
1061 ViewTab::Source => self.source_state.select_prev(),
1062 ViewTab::Crypto
1063 | ViewTab::Algorithms
1064 | ViewTab::Certificates
1065 | ViewTab::Keys
1066 | ViewTab::Protocols
1067 | ViewTab::PqcCompliance => {
1068 let sel = self.active_crypto_selected_mut();
1069 *sel = sel.saturating_sub(1);
1070 }
1071 ViewTab::Models => {
1072 self.models_selected = self.models_selected.saturating_sub(1);
1073 self.models_detail_scroll = 0;
1074 }
1075 ViewTab::Datasets => {
1076 self.datasets_selected = self.datasets_selected.saturating_sub(1);
1077 self.datasets_detail_scroll = 0;
1078 }
1079 ViewTab::AiReadiness => {
1080 self.ai_readiness_scroll = self.ai_readiness_scroll.saturating_sub(1);
1081 }
1082 ViewTab::Overview => {}
1083 }
1084 }
1085
1086 pub fn navigate_down(&mut self) {
1088 match self.active_tab {
1089 ViewTab::Tree => self.tree_state.select_next(),
1090 ViewTab::Vulnerabilities => self.vuln_state.select_next(),
1091 ViewTab::Licenses => self.license_state.select_next(),
1092 ViewTab::Dependencies => self.dependency_state.select_next(),
1093 ViewTab::Quality => self.quality_state.select_next(),
1094 ViewTab::Compliance => {
1095 self.ensure_compliance_results();
1096 let max = self.filtered_compliance_violation_count();
1097 self.compliance_state.select_next(max);
1098 }
1099 ViewTab::Source => self.source_state.select_next(),
1100 ViewTab::Crypto
1101 | ViewTab::Algorithms
1102 | ViewTab::Certificates
1103 | ViewTab::Keys
1104 | ViewTab::Protocols
1105 | ViewTab::PqcCompliance => {
1106 let max = self.crypto_count_for_tab().saturating_sub(1);
1107 let sel = self.active_crypto_selected_mut();
1108 *sel = sel.saturating_add(1).min(max);
1109 }
1110 ViewTab::Models => {
1111 let max = self.ml_model_count().saturating_sub(1);
1112 self.models_selected = self.models_selected.saturating_add(1).min(max);
1113 self.models_detail_scroll = 0;
1114 }
1115 ViewTab::Datasets => {
1116 let max = self.dataset_count().saturating_sub(1);
1117 self.datasets_selected = self.datasets_selected.saturating_add(1).min(max);
1118 self.datasets_detail_scroll = 0;
1119 }
1120 ViewTab::AiReadiness => {
1121 self.ai_readiness_scroll = self
1122 .ai_readiness_scroll
1123 .saturating_add(1)
1124 .min(self.ai_readiness_max_scroll());
1125 }
1126 ViewTab::Overview => {}
1127 }
1128 }
1129
1130 pub(crate) fn filtered_compliance_violation_count(&self) -> usize {
1132 self.compliance_results
1133 .as_ref()
1134 .and_then(|r| r.get(self.compliance_state.selected_standard))
1135 .map_or(0, |r| {
1136 if self.compliance_state.grouped {
1137 super::views::build_groups(r, self.compliance_state.severity_filter).len()
1139 } else {
1140 r.violations
1141 .iter()
1142 .filter(|v| self.compliance_state.severity_filter.matches(v.severity))
1143 .count()
1144 }
1145 })
1146 }
1147
1148 pub fn page_up(&mut self) {
1150 use crate::tui::constants::PAGE_SIZE;
1151 if self.active_tab == ViewTab::Source {
1152 self.source_state.page_up();
1153 } else {
1154 for _ in 0..PAGE_SIZE {
1155 self.navigate_up();
1156 }
1157 }
1158 }
1159
1160 pub fn page_down(&mut self) {
1162 use crate::tui::constants::PAGE_SIZE;
1163 if self.active_tab == ViewTab::Source {
1164 self.source_state.page_down();
1165 } else {
1166 for _ in 0..PAGE_SIZE {
1167 self.navigate_down();
1168 }
1169 }
1170 }
1171
1172 pub fn go_first(&mut self) {
1174 match self.active_tab {
1175 ViewTab::Tree => self.tree_state.select_first(),
1176 ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
1177 ViewTab::Licenses => self.license_state.selected = 0,
1178 ViewTab::Dependencies => self.dependency_state.selected = 0,
1179 ViewTab::Quality => self.quality_state.scroll_offset = 0,
1180 ViewTab::Compliance => self.compliance_state.selected_violation = 0,
1181 ViewTab::Source => self.source_state.select_first(),
1182 ViewTab::Crypto
1183 | ViewTab::Algorithms
1184 | ViewTab::Certificates
1185 | ViewTab::Keys
1186 | ViewTab::Protocols
1187 | ViewTab::PqcCompliance => *self.active_crypto_selected_mut() = 0,
1188 ViewTab::Models => self.models_selected = 0,
1189 ViewTab::Datasets => self.datasets_selected = 0,
1190 ViewTab::AiReadiness => self.ai_readiness_scroll = 0,
1191 ViewTab::Overview => {}
1192 }
1193 }
1194
1195 pub fn go_last(&mut self) {
1197 match self.active_tab {
1198 ViewTab::Tree => self.tree_state.select_last(),
1199 ViewTab::Vulnerabilities => {
1200 self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
1201 }
1202 ViewTab::Licenses => {
1203 self.license_state.selected = self.license_state.total.saturating_sub(1);
1204 }
1205 ViewTab::Dependencies => {
1206 self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
1207 }
1208 ViewTab::Quality => {
1209 self.quality_state.scroll_offset =
1210 self.quality_state.total_recommendations.saturating_sub(1);
1211 }
1212 ViewTab::Compliance => {
1213 self.ensure_compliance_results();
1214 let max = self.filtered_compliance_violation_count();
1215 self.compliance_state.selected_violation = max.saturating_sub(1);
1216 }
1217 ViewTab::Source => self.source_state.select_last(),
1218 ViewTab::Crypto
1219 | ViewTab::Algorithms
1220 | ViewTab::Certificates
1221 | ViewTab::Keys
1222 | ViewTab::Protocols
1223 | ViewTab::PqcCompliance => {
1224 let max = self.crypto_count_for_tab();
1225 *self.active_crypto_selected_mut() = max.saturating_sub(1);
1226 }
1227 ViewTab::Models => self.models_selected = self.ml_model_count().saturating_sub(1),
1228 ViewTab::Datasets => self.datasets_selected = self.dataset_count().saturating_sub(1),
1229 ViewTab::AiReadiness => self.ai_readiness_scroll = self.ai_readiness_max_scroll(),
1230 ViewTab::Overview => {}
1231 }
1232 }
1233
1234 pub fn handle_enter(&mut self) {
1236 match self.active_tab {
1237 ViewTab::Tree => {
1238 if let Some(node) = self.get_selected_tree_node() {
1240 match node {
1241 SelectedTreeNode::Group(id) => {
1242 self.tree_state.toggle_expand(&id);
1243 }
1244 SelectedTreeNode::Component(id) => {
1245 self.selected_component = Some(id);
1246 self.focus_panel = FocusPanel::Right;
1247 self.component_tab = ComponentDetailTab::Overview;
1248 }
1249 }
1250 }
1251 }
1252 ViewTab::Vulnerabilities => {
1253 if self.vuln_state.group_by != VulnGroupBy::Flat
1255 && let Some(item) = self
1256 .vuln_state
1257 .cached_display_items
1258 .get(self.vuln_state.selected)
1259 {
1260 match item {
1261 super::views::VulnDisplayItem::GroupHeader { label, .. } => {
1262 let label = label.clone();
1263 self.vuln_state.toggle_vuln_group(&label);
1264 return;
1265 }
1266 super::views::VulnDisplayItem::SubGroupHeader {
1267 parent_label,
1268 label,
1269 ..
1270 } => {
1271 let key = format!("{parent_label}::{label}");
1272 self.vuln_state.toggle_vuln_group(&key);
1273 return;
1274 }
1275 super::views::VulnDisplayItem::Vuln { .. } => {
1276 }
1278 }
1279 }
1280 if let Some(cache) = &self.vuln_state.cached_data.clone()
1282 && let Some((comp_id, vuln_id)) = self.vuln_state.get_nav_component_id(cache)
1283 {
1284 self.navigation_ctx.push_breadcrumb(
1286 ViewTab::Vulnerabilities,
1287 vuln_id.clone(),
1288 self.vuln_state.selected,
1289 );
1290 self.selected_component = Some(comp_id.clone());
1291 self.component_tab = ComponentDetailTab::Overview;
1292 self.active_tab = ViewTab::Tree;
1293 self.focus_panel = FocusPanel::Right;
1294 self.jump_to_component_in_tree(&comp_id);
1295 self.set_status_message(format!("→ {vuln_id} (Backspace to return)"));
1296 }
1297 }
1298 ViewTab::Licenses => {
1299 let license_data = super::views::build_license_data_from_app(self);
1301 let selected_idx = self
1302 .license_state
1303 .selected
1304 .min(license_data.len().saturating_sub(1));
1305 if let Some((license, _, _)) = license_data.get(selected_idx)
1306 && let Some(comp_id) =
1307 super::views::get_first_component_id_for_license(self, license)
1308 {
1309 self.navigation_ctx.push_breadcrumb(
1310 ViewTab::Licenses,
1311 license.clone(),
1312 self.license_state.selected,
1313 );
1314 self.selected_component = Some(comp_id.clone());
1315 self.component_tab = ComponentDetailTab::Overview;
1316 self.active_tab = ViewTab::Tree;
1317 self.focus_panel = FocusPanel::Right;
1318 self.jump_to_component_in_tree(&comp_id);
1319 self.set_status_message(format!("→ {license} (Backspace to return)"));
1320 }
1321 }
1322 ViewTab::Dependencies => {
1323 if let Some(node_id) = self.get_selected_dependency_node_id() {
1324 let is_leaf = self
1326 .dependency_state
1327 .cached_flat_nodes
1328 .get(self.dependency_state.selected)
1329 .is_some_and(|n| !n.has_children);
1330 if is_leaf {
1331 let display_name = self
1333 .dependency_state
1334 .cached_flat_nodes
1335 .get(self.dependency_state.selected)
1336 .map(|n| n.name.clone())
1337 .unwrap_or_default();
1338 self.navigation_ctx.push_breadcrumb(
1339 ViewTab::Dependencies,
1340 display_name.clone(),
1341 self.dependency_state.selected,
1342 );
1343 self.selected_component = Some(node_id.clone());
1344 self.component_tab = ComponentDetailTab::Overview;
1345 self.active_tab = ViewTab::Tree;
1346 self.focus_panel = FocusPanel::Right;
1347 self.jump_to_component_in_tree(&node_id);
1348 self.set_status_message(format!("→ {display_name} (Backspace to return)"));
1349 } else {
1350 self.dependency_state.toggle_expand(&node_id);
1351 }
1352 }
1353 }
1354 ViewTab::Compliance => {
1355 self.ensure_compliance_results();
1357 let idx = self.compliance_state.selected_standard;
1358 let has_violations = self
1359 .compliance_results
1360 .as_ref()
1361 .and_then(|r| r.get(idx))
1362 .is_some_and(|r| !r.violations.is_empty());
1363 if has_violations {
1364 self.compliance_state.show_detail = !self.compliance_state.show_detail;
1365 }
1366 }
1367 ViewTab::Source => {
1368 if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree
1370 && let Some(ref tree) = self.source_state.json_tree
1371 {
1372 let mut items = Vec::new();
1373 crate::tui::shared::source::flatten_json_tree(
1374 tree,
1375 "",
1376 0,
1377 &self.source_state.expanded,
1378 &mut items,
1379 true,
1380 &[],
1381 self.source_state.sort_mode,
1382 "",
1383 );
1384 if let Some(item) = items.get(self.source_state.selected)
1385 && item.is_expandable
1386 {
1387 let node_id = item.node_id.clone();
1388 self.source_state.toggle_expand(&node_id);
1389 }
1390 }
1391 }
1392 ViewTab::Quality => {
1393 if self.quality_state.view_mode == QualityViewMode::Summary {
1394 self.quality_state.view_mode = QualityViewMode::Recommendations;
1396 }
1397 }
1398 ViewTab::Overview
1399 | ViewTab::Crypto
1400 | ViewTab::Algorithms
1401 | ViewTab::Certificates
1402 | ViewTab::Keys
1403 | ViewTab::Protocols
1404 | ViewTab::PqcCompliance
1405 | ViewTab::Models
1406 | ViewTab::Datasets
1407 | ViewTab::AiReadiness => {}
1408 }
1409 }
1410
1411 pub fn handle_source_map_enter(&mut self) {
1413 let Some(tree) = &self.source_state.json_tree else {
1415 return;
1416 };
1417 let Some(children) = tree.children() else {
1418 return;
1419 };
1420
1421 let expandable: Vec<_> = children.iter().filter(|c| c.is_expandable()).collect();
1423
1424 let target = match expandable.get(self.source_state.map_selected) {
1425 Some(t) => *t,
1426 None => return,
1427 };
1428
1429 let target_id = target.node_id("root");
1430
1431 match self.source_state.view_mode {
1432 crate::tui::app_states::SourceViewMode::Tree => {
1433 if !self.source_state.expanded.contains(&target_id) {
1435 self.source_state.expanded.insert(target_id.clone());
1436 }
1437 let mut items = Vec::new();
1439 crate::tui::shared::source::flatten_json_tree(
1440 tree,
1441 "",
1442 0,
1443 &self.source_state.expanded,
1444 &mut items,
1445 true,
1446 &[],
1447 self.source_state.sort_mode,
1448 "",
1449 );
1450 if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
1451 self.source_state.selected = idx;
1452 self.source_state.scroll_offset = idx.saturating_sub(2);
1453 }
1454 }
1455 crate::tui::app_states::SourceViewMode::Raw => {
1456 let key = match target {
1458 crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
1459 | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
1460 | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
1461 };
1462 for (i, line) in self.source_state.raw_lines.iter().enumerate() {
1464 let search = format!("\"{key}\":");
1465 if line.contains(&search) && line.starts_with(" ") && !line.starts_with(" ")
1466 {
1467 self.source_state.selected = i;
1468 self.source_state.scroll_offset = i.saturating_sub(2);
1469 break;
1470 }
1471 }
1472 }
1473 }
1474
1475 self.focus_panel = FocusPanel::Left;
1477 }
1478
1479 #[must_use]
1482 pub fn get_map_context_component_id(&self) -> Option<String> {
1483 let tree = self.source_state.json_tree.as_ref()?;
1484 let mut items = Vec::new();
1485 crate::tui::shared::source::flatten_json_tree(
1486 tree,
1487 "",
1488 0,
1489 &self.source_state.expanded,
1490 &mut items,
1491 true,
1492 &[],
1493 self.source_state.sort_mode,
1494 "",
1495 );
1496 let item = items.get(self.source_state.selected)?;
1497 let parts: Vec<&str> = item.node_id.split('.').collect();
1498 if parts.len() < 3 || parts[1] != "components" {
1499 return None;
1500 }
1501 let idx_part = parts[2];
1502 if idx_part.starts_with('[') && idx_part.ends_with(']') {
1503 let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
1504 let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
1505 Some(canon_id.value().to_string())
1506 } else {
1507 None
1508 }
1509 }
1510
1511 #[must_use]
1513 pub fn get_selected_dependency_node_id(&self) -> Option<String> {
1514 if !self.dependency_state.cached_flat_nodes.is_empty() {
1516 return self
1517 .dependency_state
1518 .cached_flat_nodes
1519 .get(self.dependency_state.selected)
1520 .map(|n| n.id.clone());
1521 }
1522 let mut visible_nodes = Vec::new();
1524 self.collect_visible_dependency_nodes(&mut visible_nodes);
1525 visible_nodes.get(self.dependency_state.selected).cloned()
1526 }
1527
1528 fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
1530 let mut edges: std::collections::HashMap<String, Vec<String>> =
1532 std::collections::HashMap::new();
1533 let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
1534 let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
1535
1536 for (id, _) in &self.sbom.components {
1537 all_nodes.insert(id.value().to_string());
1538 }
1539
1540 for edge in &self.sbom.edges {
1541 let from = edge.from.value().to_string();
1542 let to = edge.to.value().to_string();
1543 if all_nodes.contains(&from) && all_nodes.contains(&to) {
1544 edges.entry(from).or_default().push(to.clone());
1545 has_parent.insert(to);
1546 }
1547 }
1548
1549 let mut roots: Vec<_> = all_nodes
1551 .iter()
1552 .filter(|id| !has_parent.contains(*id))
1553 .cloned()
1554 .collect();
1555 roots.sort();
1556
1557 for root in roots {
1559 self.collect_dep_nodes_recursive(
1560 &root,
1561 &edges,
1562 nodes,
1563 &mut std::collections::HashSet::new(),
1564 );
1565 }
1566 }
1567
1568 fn collect_dep_nodes_recursive(
1569 &self,
1570 node_id: &str,
1571 edges: &std::collections::HashMap<String, Vec<String>>,
1572 nodes: &mut Vec<String>,
1573 visited: &mut std::collections::HashSet<String>,
1574 ) {
1575 if visited.contains(node_id) {
1576 return;
1577 }
1578 visited.insert(node_id.to_string());
1579 nodes.push(node_id.to_string());
1580
1581 if self.dependency_state.is_expanded(node_id)
1582 && let Some(children) = edges.get(node_id)
1583 {
1584 for child in children {
1585 self.collect_dep_nodes_recursive(child, edges, nodes, visited);
1586 }
1587 }
1588 }
1589
1590 pub(crate) fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
1592 let nodes = self.build_tree_nodes();
1593 let mut flat_items = Vec::new();
1594 flatten_tree_for_selection(nodes, &self.tree_state, &mut flat_items);
1595
1596 flat_items.get(self.tree_state.selected).cloned()
1597 }
1598
1599 pub fn ensure_tree_cache(&mut self) {
1601 let current_key = TreeCacheKey {
1602 group_by: self.tree_group_by,
1603 filter: self.tree_filter,
1604 search_query: self.tree_search_query.clone(),
1605 };
1606 if self.tree_cache_key.as_ref() != Some(¤t_key) {
1607 self.cached_tree_nodes = match self.tree_group_by {
1608 TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
1609 TreeGroupBy::License => self.build_license_tree(),
1610 TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
1611 TreeGroupBy::ComponentType => self.build_type_tree(),
1612 TreeGroupBy::Flat => self.build_flat_tree(),
1613 };
1614 self.tree_cache_key = Some(current_key);
1615 }
1616 }
1617
1618 pub fn build_tree_nodes(&self) -> &[crate::tui::widgets::TreeNode] {
1622 &self.cached_tree_nodes
1623 }
1624
1625 fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1626 use crate::tui::widgets::TreeNode;
1627
1628 let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
1629
1630 for comp in self.sbom.components.values() {
1631 if !self.matches_filter(comp) {
1632 continue;
1633 }
1634 let eco = comp
1635 .ecosystem
1636 .as_ref()
1637 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1638 ecosystem_map.entry(eco).or_default().push(comp);
1639 }
1640
1641 let mut groups: Vec<TreeNode> = ecosystem_map
1642 .into_iter()
1643 .map(|(eco, mut components)| {
1644 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1645 components.sort_by(|a, b| a.name.cmp(&b.name));
1646 let children: Vec<TreeNode> = components
1647 .into_iter()
1648 .map(|c| TreeNode::Component {
1649 id: c.canonical_id.value().to_string(),
1650 name: c.name.clone(),
1651 version: c.version.clone(),
1652 vuln_count: c.vulnerabilities.len(),
1653 max_severity: get_max_severity(c),
1654 component_type: Some(
1655 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1656 ),
1657 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1658 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1659 })
1660 .collect();
1661 let count = children.len();
1662 TreeNode::Group {
1663 id: format!("eco:{eco}"),
1664 label: eco,
1665 children,
1666 item_count: count,
1667 vuln_count,
1668 }
1669 })
1670 .collect();
1671
1672 groups.sort_by(|a, b| match (a, b) {
1673 (
1674 TreeNode::Group {
1675 item_count: ac,
1676 label: al,
1677 ..
1678 },
1679 TreeNode::Group {
1680 item_count: bc,
1681 label: bl,
1682 ..
1683 },
1684 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1685 _ => std::cmp::Ordering::Equal,
1686 });
1687
1688 groups
1689 }
1690
1691 fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1692 use crate::tui::widgets::TreeNode;
1693
1694 let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
1695
1696 for comp in self.sbom.components.values() {
1697 if !self.matches_filter(comp) {
1698 continue;
1699 }
1700 let license = if comp.licenses.declared.is_empty() {
1701 "Unknown".to_string()
1702 } else {
1703 comp.licenses.declared[0].expression.clone()
1704 };
1705 license_map.entry(license).or_default().push(comp);
1706 }
1707
1708 let mut groups: Vec<TreeNode> = license_map
1709 .into_iter()
1710 .map(|(license, mut components)| {
1711 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1712 components.sort_by(|a, b| a.name.cmp(&b.name));
1713 let children: Vec<TreeNode> = components
1714 .into_iter()
1715 .map(|c| TreeNode::Component {
1716 id: c.canonical_id.value().to_string(),
1717 name: c.name.clone(),
1718 version: c.version.clone(),
1719 vuln_count: c.vulnerabilities.len(),
1720 max_severity: get_max_severity(c),
1721 component_type: Some(
1722 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1723 ),
1724 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1725 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1726 })
1727 .collect();
1728 let count = children.len();
1729 TreeNode::Group {
1730 id: format!("lic:{license}"),
1731 label: license,
1732 children,
1733 item_count: count,
1734 vuln_count,
1735 }
1736 })
1737 .collect();
1738
1739 groups.sort_by(|a, b| match (a, b) {
1740 (
1741 TreeNode::Group {
1742 item_count: ac,
1743 label: al,
1744 ..
1745 },
1746 TreeNode::Group {
1747 item_count: bc,
1748 label: bl,
1749 ..
1750 },
1751 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1752 _ => std::cmp::Ordering::Equal,
1753 });
1754
1755 groups
1756 }
1757
1758 fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1759 use super::severity::severity_category;
1760 use crate::tui::widgets::TreeNode;
1761
1762 let mut critical_comps = Vec::new();
1763 let mut high_comps = Vec::new();
1764 let mut other_vuln_comps = Vec::new();
1765 let mut clean_comps = Vec::new();
1766
1767 for comp in self.sbom.components.values() {
1768 if !self.matches_filter(comp) {
1769 continue;
1770 }
1771
1772 match severity_category(&comp.vulnerabilities) {
1773 "critical" => critical_comps.push(comp),
1774 "high" => high_comps.push(comp),
1775 "clean" => clean_comps.push(comp),
1776 _ => other_vuln_comps.push(comp),
1777 }
1778 }
1779
1780 let build_group = |label: &str,
1781 id: &str,
1782 comps: Vec<&Component>,
1783 bookmarked: &HashSet<String>|
1784 -> TreeNode {
1785 let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1786 let children: Vec<TreeNode> = comps
1787 .into_iter()
1788 .map(|c| TreeNode::Component {
1789 id: c.canonical_id.value().to_string(),
1790 name: c.name.clone(),
1791 version: c.version.clone(),
1792 vuln_count: c.vulnerabilities.len(),
1793 max_severity: get_max_severity(c),
1794 component_type: Some(
1795 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1796 ),
1797 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1798 is_bookmarked: bookmarked.contains(c.canonical_id.value()),
1799 })
1800 .collect();
1801 let count = children.len();
1802 TreeNode::Group {
1803 id: id.to_string(),
1804 label: label.to_string(),
1805 children,
1806 item_count: count,
1807 vuln_count,
1808 }
1809 };
1810
1811 let mut groups = Vec::new();
1812 if !critical_comps.is_empty() {
1813 groups.push(build_group(
1814 "Critical",
1815 "vuln:critical",
1816 critical_comps,
1817 &self.bookmarked,
1818 ));
1819 }
1820 if !high_comps.is_empty() {
1821 groups.push(build_group(
1822 "High",
1823 "vuln:high",
1824 high_comps,
1825 &self.bookmarked,
1826 ));
1827 }
1828 if !other_vuln_comps.is_empty() {
1829 groups.push(build_group(
1830 "Other Vulnerabilities",
1831 "vuln:other",
1832 other_vuln_comps,
1833 &self.bookmarked,
1834 ));
1835 }
1836 if !clean_comps.is_empty() {
1837 groups.push(build_group(
1838 "No Vulnerabilities",
1839 "vuln:clean",
1840 clean_comps,
1841 &self.bookmarked,
1842 ));
1843 }
1844
1845 groups
1846 }
1847
1848 fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1849 use crate::tui::widgets::TreeNode;
1850
1851 let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1852
1853 for comp in self.sbom.components.values() {
1854 if !self.matches_filter(comp) {
1855 continue;
1856 }
1857 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1858 type_map.entry(comp_type).or_default().push(comp);
1859 }
1860
1861 let type_order = vec![
1863 ("lib", "Libraries"),
1864 ("bin", "Binaries"),
1865 ("cert", "Certificates"),
1866 ("fs", "Filesystems"),
1867 ("file", "Other Files"),
1868 ];
1869
1870 let mut groups = Vec::new();
1871 for (type_key, type_label) in type_order {
1872 if let Some(mut components) = type_map.remove(type_key) {
1873 if components.is_empty() {
1874 continue;
1875 }
1876 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1877 components.sort_by(|a, b| a.name.cmp(&b.name));
1878 let children: Vec<TreeNode> = components
1879 .into_iter()
1880 .map(|c| TreeNode::Component {
1881 id: c.canonical_id.value().to_string(),
1882 name: c.name.clone(),
1883 version: c.version.clone(),
1884 vuln_count: c.vulnerabilities.len(),
1885 max_severity: get_max_severity(c),
1886 component_type: Some(type_key.to_string()),
1887 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1888 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1889 })
1890 .collect();
1891 let count = children.len();
1892 groups.push(TreeNode::Group {
1893 id: format!("type:{type_key}"),
1894 label: type_label.to_string(),
1895 children,
1896 item_count: count,
1897 vuln_count,
1898 });
1899 }
1900 }
1901
1902 groups
1903 }
1904
1905 fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1906 use crate::tui::widgets::TreeNode;
1907
1908 self.sbom
1909 .components
1910 .values()
1911 .filter(|c| self.matches_filter(c))
1912 .map(|c| TreeNode::Component {
1913 id: c.canonical_id.value().to_string(),
1914 name: c.name.clone(),
1915 version: c.version.clone(),
1916 vuln_count: c.vulnerabilities.len(),
1917 max_severity: get_max_severity(c),
1918 component_type: Some(
1919 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1920 ),
1921 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1922 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1923 })
1924 .collect()
1925 }
1926
1927 fn matches_filter(&self, comp: &Component) -> bool {
1928 use super::severity::severity_matches;
1929
1930 let passes_filter = match self.tree_filter {
1932 TreeFilter::All => true,
1933 TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1934 TreeFilter::Critical => comp
1935 .vulnerabilities
1936 .iter()
1937 .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1938 TreeFilter::Bookmarked => self.bookmarked.contains(comp.canonical_id.value()),
1939 };
1940
1941 if !passes_filter {
1942 return false;
1943 }
1944
1945 if self.tree_search_query.is_empty() {
1947 return true;
1948 }
1949
1950 let query_lower = self.tree_search_query.to_lowercase();
1951 let name_lower = comp.name.to_lowercase();
1952
1953 if name_lower.contains(&query_lower) {
1955 return true;
1956 }
1957
1958 if let Some(ref version) = comp.version
1960 && version.to_lowercase().contains(&query_lower)
1961 {
1962 return true;
1963 }
1964
1965 if let Some(ref eco) = comp.ecosystem
1967 && eco.to_string().to_lowercase().contains(&query_lower)
1968 {
1969 return true;
1970 }
1971
1972 false
1973 }
1974
1975 fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1976 match self.tree_group_by {
1977 TreeGroupBy::Ecosystem => {
1978 let eco = comp
1979 .ecosystem
1980 .as_ref()
1981 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1982 Some(format!("eco:{eco}"))
1983 }
1984 TreeGroupBy::License => {
1985 let license = if comp.licenses.declared.is_empty() {
1986 "Unknown".to_string()
1987 } else {
1988 comp.licenses.declared[0].expression.clone()
1989 };
1990 Some(format!("lic:{license}"))
1991 }
1992 TreeGroupBy::VulnStatus => {
1993 use super::severity::severity_category;
1994 let group = match severity_category(&comp.vulnerabilities) {
1995 "critical" => "vuln:critical",
1996 "high" => "vuln:high",
1997 "clean" => "vuln:clean",
1998 _ => "vuln:other",
1999 };
2000 Some(group.to_string())
2001 }
2002 TreeGroupBy::ComponentType => {
2003 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
2004 Some(format!("type:{comp_type}"))
2005 }
2006 TreeGroupBy::Flat => None,
2007 }
2008 }
2009}
2010
2011fn get_max_severity(comp: &Component) -> Option<String> {
2013 super::severity::max_severity_from_vulns(&comp.vulnerabilities)
2014}
2015
2016#[derive(Debug, Clone)]
2018pub(crate) enum SelectedTreeNode {
2019 Group(String),
2020 Component(String),
2021}
2022
2023fn flatten_tree_for_selection(
2024 nodes: &[crate::tui::widgets::TreeNode],
2025 state: &TreeState,
2026 items: &mut Vec<SelectedTreeNode>,
2027) {
2028 use crate::tui::widgets::TreeNode;
2029
2030 for node in nodes {
2031 match node {
2032 TreeNode::Group { id, children, .. } => {
2033 items.push(SelectedTreeNode::Group(id.clone()));
2034 if state.is_expanded(id) {
2035 flatten_tree_for_selection(children, state, items);
2036 }
2037 }
2038 TreeNode::Component { id, .. } => {
2039 items.push(SelectedTreeNode::Component(id.clone()));
2040 }
2041 }
2042 }
2043}
2044
2045#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2047pub enum ViewTab {
2048 Overview,
2051 Quality,
2053 Source,
2055
2056 Tree,
2059 Vulnerabilities,
2061 Licenses,
2063 Dependencies,
2065 Compliance,
2067
2068 Algorithms,
2071 Certificates,
2073 Keys,
2075 Protocols,
2077 PqcCompliance,
2079
2080 Models,
2083 Datasets,
2085 AiReadiness,
2087
2088 Crypto,
2091}
2092
2093impl ViewTab {
2094 #[must_use]
2096 pub const fn title(&self) -> &'static str {
2097 match self {
2098 Self::Overview => "Overview",
2099 Self::Quality => "Quality",
2100 Self::Source => "Source",
2101 Self::Tree => "Components",
2104 Self::Vulnerabilities => "Vulnerabilities",
2105 Self::Licenses => "Licenses",
2106 Self::Dependencies => "Dependencies",
2107 Self::Compliance => "Compliance",
2108 Self::Algorithms => "Algorithms",
2109 Self::Certificates => "Certificates",
2110 Self::Keys => "Keys",
2111 Self::Protocols => "Protocols",
2112 Self::PqcCompliance => "PQC Compliance",
2113 Self::Models => "Models",
2114 Self::Datasets => "Datasets",
2115 Self::AiReadiness => "AI-Readiness",
2116 Self::Crypto => "Crypto",
2117 }
2118 }
2119
2120 #[must_use]
2122 pub fn shortcut_for_profile(&self, profile: crate::model::BomProfile) -> Option<usize> {
2123 Self::tabs_for_profile(profile)
2124 .iter()
2125 .position(|t| t == self)
2126 .map(|i| i + 1)
2127 }
2128
2129 #[must_use]
2131 pub const fn as_str(&self) -> &'static str {
2132 match self {
2133 Self::Overview => "overview",
2134 Self::Quality => "quality",
2135 Self::Source => "source",
2136 Self::Tree => "tree",
2137 Self::Vulnerabilities => "vulnerabilities",
2138 Self::Licenses => "licenses",
2139 Self::Dependencies => "dependencies",
2140 Self::Compliance => "compliance",
2141 Self::Algorithms => "algorithms",
2142 Self::Certificates => "certificates",
2143 Self::Keys => "keys",
2144 Self::Protocols => "protocols",
2145 Self::PqcCompliance => "pqc-compliance",
2146 Self::Models => "models",
2147 Self::Datasets => "datasets",
2148 Self::AiReadiness => "ai-readiness",
2149 Self::Crypto => "crypto",
2150 }
2151 }
2152
2153 #[must_use]
2158 pub const fn tabs_for_profile(profile: crate::model::BomProfile) -> &'static [ViewTab] {
2159 match profile {
2160 crate::model::BomProfile::Sbom => &[
2165 Self::Overview,
2166 Self::Tree,
2167 Self::Dependencies,
2168 Self::Licenses,
2169 Self::Vulnerabilities,
2170 Self::Quality,
2171 Self::Compliance,
2172 Self::Source,
2173 ],
2174 crate::model::BomProfile::Cbom => &[
2175 Self::Overview,
2176 Self::Algorithms,
2177 Self::Certificates,
2178 Self::Keys,
2179 Self::Protocols,
2180 Self::Quality,
2181 Self::PqcCompliance,
2182 Self::Source,
2183 ],
2184 crate::model::BomProfile::AiBom => &[
2185 Self::Overview,
2186 Self::Models,
2187 Self::Datasets,
2188 Self::AiReadiness,
2189 Self::Compliance,
2190 Self::Source,
2191 ],
2192 }
2193 }
2194
2195 #[must_use]
2197 pub fn from_str_opt(s: &str) -> Option<Self> {
2198 match s {
2199 "overview" => Some(Self::Overview),
2200 "quality" => Some(Self::Quality),
2201 "source" => Some(Self::Source),
2202 "tree" => Some(Self::Tree),
2203 "vulnerabilities" => Some(Self::Vulnerabilities),
2204 "licenses" => Some(Self::Licenses),
2205 "dependencies" => Some(Self::Dependencies),
2206 "compliance" => Some(Self::Compliance),
2207 "algorithms" => Some(Self::Algorithms),
2208 "certificates" => Some(Self::Certificates),
2209 "keys" => Some(Self::Keys),
2210 "protocols" => Some(Self::Protocols),
2211 "pqc-compliance" => Some(Self::PqcCompliance),
2212 "models" => Some(Self::Models),
2213 "datasets" => Some(Self::Datasets),
2214 "ai-readiness" => Some(Self::AiReadiness),
2215 "crypto" => Some(Self::Crypto),
2216 _ => None,
2217 }
2218 }
2219}
2220
2221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2223pub enum TreeGroupBy {
2224 Ecosystem,
2225 License,
2226 VulnStatus,
2227 ComponentType,
2228 Flat,
2229}
2230
2231impl TreeGroupBy {
2232 #[must_use]
2233 pub const fn label(&self) -> &'static str {
2234 match self {
2235 Self::Ecosystem => "Ecosystem",
2236 Self::License => "License",
2237 Self::VulnStatus => "Vuln Status",
2238 Self::ComponentType => "Type",
2239 Self::Flat => "Flat List",
2240 }
2241 }
2242}
2243
2244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2246pub enum TreeFilter {
2247 All,
2248 HasVulnerabilities,
2249 Critical,
2250 Bookmarked,
2251}
2252
2253impl TreeFilter {
2254 #[must_use]
2255 pub const fn label(&self) -> &'static str {
2256 match self {
2257 Self::All => "All",
2258 Self::HasVulnerabilities => "Has Vulns",
2259 Self::Critical => "Critical",
2260 Self::Bookmarked => "Bookmarked",
2261 }
2262 }
2263}
2264
2265#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2267pub(crate) enum ComponentDetailTab {
2268 #[default]
2269 Overview,
2270 Identifiers,
2271 Vulnerabilities,
2272 Dependencies,
2273}
2274
2275impl ComponentDetailTab {
2276 pub const fn title(self) -> &'static str {
2277 match self {
2278 Self::Overview => "Overview",
2279 Self::Identifiers => "Identifiers",
2280 Self::Vulnerabilities => "Vulnerabilities",
2281 Self::Dependencies => "Dependencies",
2282 }
2283 }
2284
2285 pub const fn shortcut(self) -> &'static str {
2286 match self {
2287 Self::Overview => "1",
2288 Self::Identifiers => "2",
2289 Self::Vulnerabilities => "3",
2290 Self::Dependencies => "4",
2291 }
2292 }
2293
2294 pub const fn all() -> [Self; 4] {
2295 [
2296 Self::Overview,
2297 Self::Identifiers,
2298 Self::Vulnerabilities,
2299 Self::Dependencies,
2300 ]
2301 }
2302}
2303
2304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2306pub(crate) enum FocusPanel {
2307 Left,
2308 Right,
2309}
2310
2311#[derive(Debug, Clone)]
2313pub(crate) struct VulnExplorerState {
2314 pub selected: usize,
2315 pub total: usize,
2316 pub scroll_offset: usize,
2317 pub group_by: VulnGroupBy,
2318 pub sort_by: VulnSortBy,
2319 pub filter_severity: Option<String>,
2320 pub filter_kev: bool,
2322 pub deduplicate: bool,
2324 pub search_query: String,
2326 pub search_active: bool,
2328 pub detail_scroll: u16,
2330 pub expanded_groups: HashSet<String>,
2332 cache_key: Option<VulnCacheKey>,
2334 pub cached_data: Option<super::views::VulnCacheRef>,
2336 pub cached_display_items: Vec<super::views::VulnDisplayItem>,
2339 display_items_expanded_snapshot: HashSet<String>,
2341 display_items_group_by: VulnGroupBy,
2343 pub inspect_component_idx: usize,
2345}
2346
2347#[derive(Debug, Clone, PartialEq, Eq)]
2350struct TreeCacheKey {
2351 group_by: TreeGroupBy,
2352 filter: TreeFilter,
2353 search_query: String,
2354}
2355
2356#[derive(Debug, Clone, PartialEq, Eq)]
2357struct VulnCacheKey {
2358 filter_severity: Option<String>,
2359 filter_kev: bool,
2360 deduplicate: bool,
2361 sort_by: VulnSortBy,
2362 search_query: String,
2363}
2364
2365impl VulnExplorerState {
2366 pub fn new() -> Self {
2367 Self {
2368 selected: 0,
2369 total: 0,
2370 scroll_offset: 0,
2371 group_by: VulnGroupBy::Component,
2372 sort_by: VulnSortBy::Severity,
2373 filter_severity: None,
2374 filter_kev: false,
2375 deduplicate: true,
2376 search_query: String::new(),
2377 search_active: false,
2378 detail_scroll: 0,
2379 expanded_groups: HashSet::new(),
2380 cache_key: None,
2381 cached_data: None,
2382 cached_display_items: Vec::new(),
2383 display_items_expanded_snapshot: HashSet::new(),
2384 display_items_group_by: VulnGroupBy::Component,
2385 inspect_component_idx: 0,
2386 }
2387 }
2388
2389 fn current_cache_key(&self) -> VulnCacheKey {
2391 VulnCacheKey {
2392 filter_severity: self.filter_severity.clone(),
2393 filter_kev: self.filter_kev,
2394 deduplicate: self.deduplicate,
2395 sort_by: self.sort_by,
2396 search_query: self.search_query.clone(),
2397 }
2398 }
2399
2400 pub fn is_cache_valid(&self) -> bool {
2402 if let Some(key) = &self.cache_key {
2403 self.cached_data.is_some()
2404 && key.filter_severity == self.filter_severity
2405 && key.filter_kev == self.filter_kev
2406 && key.deduplicate == self.deduplicate
2407 && key.sort_by == self.sort_by
2408 && key.search_query == self.search_query
2409 } else {
2410 false
2411 }
2412 }
2413
2414 pub fn set_cache(&mut self, cache: super::views::VulnCache) {
2416 self.cache_key = Some(self.current_cache_key());
2417 self.cached_data = Some(std::sync::Arc::new(cache));
2418 }
2419
2420 pub fn invalidate_cache(&mut self) {
2422 self.cache_key = None;
2423 self.cached_data = None;
2424 self.cached_display_items.clear();
2425 }
2426
2427 pub fn are_display_items_valid(&self) -> bool {
2429 !self.cached_display_items.is_empty()
2430 && self.display_items_expanded_snapshot == self.expanded_groups
2431 && self.display_items_group_by == self.group_by
2432 }
2433
2434 pub fn rebuild_display_items(&mut self) {
2436 if let Some(cache) = &self.cached_data {
2437 self.cached_display_items = super::views::build_display_items(
2438 &cache.vulns,
2439 &self.group_by,
2440 &self.expanded_groups,
2441 );
2442 self.display_items_expanded_snapshot = self.expanded_groups.clone();
2443 self.display_items_group_by = self.group_by;
2444 }
2445 }
2446
2447 pub const fn select_next(&mut self) {
2448 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2449 self.selected += 1;
2450 self.detail_scroll = 0;
2451 self.inspect_component_idx = 0;
2452 }
2453 }
2454
2455 pub const fn select_prev(&mut self) {
2456 if self.selected > 0 {
2457 self.selected -= 1;
2458 self.detail_scroll = 0;
2459 self.inspect_component_idx = 0;
2460 }
2461 }
2462
2463 pub const fn detail_scroll_down(&mut self) {
2465 self.detail_scroll = self.detail_scroll.saturating_add(1);
2466 }
2467
2468 pub const fn detail_scroll_up(&mut self) {
2470 self.detail_scroll = self.detail_scroll.saturating_sub(1);
2471 }
2472
2473 pub const fn clamp_selection(&mut self) {
2475 if self.total == 0 {
2476 self.selected = 0;
2477 } else if self.selected >= self.total {
2478 self.selected = self.total.saturating_sub(1);
2479 }
2480 }
2481
2482 pub fn get_selected_vuln_row<'a>(
2485 &self,
2486 cache: &'a super::views::VulnCache,
2487 ) -> Option<&'a super::views::VulnRow> {
2488 let item = self.cached_display_items.get(self.selected)?;
2489 match item {
2490 super::views::VulnDisplayItem::Vuln { idx, .. } => cache.vulns.get(*idx),
2491 _ => None,
2492 }
2493 }
2494
2495 pub fn get_nav_component_id(
2498 &self,
2499 cache: &super::views::VulnCache,
2500 ) -> Option<(String, String)> {
2501 let vuln = self.get_selected_vuln_row(cache)?;
2502 let idx = self
2503 .inspect_component_idx
2504 .min(vuln.affected_component_ids.len().saturating_sub(1));
2505 let comp_id = vuln.affected_component_ids.get(idx)?;
2506 Some((comp_id.clone(), vuln.vuln_id.clone()))
2507 }
2508
2509 pub fn toggle_group(&mut self) {
2510 self.group_by = match self.group_by {
2511 VulnGroupBy::Severity => VulnGroupBy::Component,
2512 VulnGroupBy::Component => VulnGroupBy::Flat,
2513 VulnGroupBy::Flat => VulnGroupBy::Severity,
2514 };
2515 self.selected = 0;
2516 self.expanded_groups.clear();
2517 self.invalidate_cache();
2518 }
2519
2520 pub fn toggle_vuln_group(&mut self, group_id: &str) {
2522 if self.expanded_groups.contains(group_id) {
2523 self.expanded_groups.remove(group_id);
2524 } else {
2525 self.expanded_groups.insert(group_id.to_string());
2526 }
2527 }
2528
2529 pub fn expand_all_groups(&mut self, labels: &[String]) {
2531 for label in labels {
2532 self.expanded_groups.insert(label.clone());
2533 }
2534 }
2535
2536 pub fn collapse_all_groups(&mut self) {
2538 self.expanded_groups.clear();
2539 }
2540
2541 pub fn jump_next_group_cached(&mut self) {
2543 for (i, item) in self
2544 .cached_display_items
2545 .iter()
2546 .enumerate()
2547 .skip(self.selected + 1)
2548 {
2549 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2550 self.selected = i;
2551 self.detail_scroll = 0;
2552 return;
2553 }
2554 }
2555 for (i, item) in self.cached_display_items.iter().enumerate() {
2557 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2558 self.selected = i;
2559 self.detail_scroll = 0;
2560 return;
2561 }
2562 }
2563 }
2564
2565 pub fn jump_prev_group_cached(&mut self) {
2567 for (i, item) in self
2568 .cached_display_items
2569 .iter()
2570 .enumerate()
2571 .take(self.selected)
2572 .rev()
2573 {
2574 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2575 self.selected = i;
2576 self.detail_scroll = 0;
2577 return;
2578 }
2579 }
2580 for (i, item) in self.cached_display_items.iter().enumerate().rev() {
2582 if matches!(item, super::views::VulnDisplayItem::GroupHeader { .. }) {
2583 self.selected = i;
2584 self.detail_scroll = 0;
2585 return;
2586 }
2587 }
2588 }
2589
2590 pub fn toggle_filter(&mut self) {
2591 self.filter_severity = match &self.filter_severity {
2592 None => Some("critical".to_string()),
2593 Some(s) if s == "critical" => Some("high".to_string()),
2594 Some(s) if s == "high" => Some("medium".to_string()),
2595 Some(s) if s == "medium" => Some("low".to_string()),
2596 Some(s) if s == "low" => Some("unknown".to_string()),
2597 Some(s) if s == "unknown" => None,
2598 _ => None,
2599 };
2600 self.selected = 0;
2601 self.invalidate_cache();
2602 }
2603
2604 pub fn toggle_kev_filter(&mut self) {
2607 self.filter_kev = !self.filter_kev;
2608 if self.filter_kev {
2612 self.selected = 0;
2613 }
2614 self.invalidate_cache();
2615 }
2616
2617 pub fn clear_severity_filter(&mut self) {
2620 self.filter_severity = None;
2621 self.selected = 0;
2622 self.invalidate_cache();
2623 }
2624
2625 pub fn toggle_sort(&mut self) {
2626 self.sort_by = self.sort_by.next();
2627 self.selected = 0;
2628 self.invalidate_cache();
2629 }
2630
2631 pub fn toggle_deduplicate(&mut self) {
2632 self.deduplicate = !self.deduplicate;
2633 self.selected = 0;
2634 self.invalidate_cache();
2635 }
2636
2637 pub fn start_vuln_search(&mut self) {
2639 self.search_active = true;
2640 self.search_query.clear();
2641 }
2642
2643 pub const fn stop_vuln_search(&mut self) {
2645 self.search_active = false;
2646 }
2647
2648 pub fn clear_vuln_search(&mut self) {
2650 self.search_active = false;
2651 self.search_query.clear();
2652 self.selected = 0;
2653 self.invalidate_cache();
2654 }
2655
2656 pub fn search_push(&mut self, c: char) {
2658 self.search_query.push(c);
2659 self.selected = 0;
2660 self.invalidate_cache();
2661 }
2662
2663 pub fn search_pop(&mut self) {
2665 self.search_query.pop();
2666 self.selected = 0;
2667 self.invalidate_cache();
2668 }
2669
2670 }
2675
2676impl Default for VulnExplorerState {
2677 fn default() -> Self {
2678 Self::new()
2679 }
2680}
2681
2682impl ListNavigation for VulnExplorerState {
2683 fn selected(&self) -> usize {
2684 self.selected
2685 }
2686
2687 fn set_selected(&mut self, idx: usize) {
2688 self.selected = idx;
2689 }
2690
2691 fn total(&self) -> usize {
2692 self.total
2693 }
2694
2695 fn set_total(&mut self, total: usize) {
2696 self.total = total;
2697 }
2698}
2699
2700#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2702pub(crate) enum QualityViewMode {
2703 #[default]
2704 Summary,
2705 Breakdown,
2706 Metrics,
2707 Recommendations,
2708}
2709
2710pub(crate) struct QualityViewState {
2712 pub view_mode: QualityViewMode,
2713 pub selected_recommendation: usize,
2714 pub total_recommendations: usize,
2715 pub scroll_offset: usize,
2716}
2717
2718impl QualityViewState {
2719 pub const fn new(total_recommendations: usize) -> Self {
2720 Self {
2721 view_mode: QualityViewMode::Summary,
2722 selected_recommendation: 0,
2723 total_recommendations,
2724 scroll_offset: 0,
2725 }
2726 }
2727
2728 pub const fn toggle_view(&mut self) {
2729 self.view_mode = match self.view_mode {
2730 QualityViewMode::Summary => QualityViewMode::Breakdown,
2731 QualityViewMode::Breakdown => QualityViewMode::Metrics,
2732 QualityViewMode::Metrics => QualityViewMode::Recommendations,
2733 QualityViewMode::Recommendations => QualityViewMode::Summary,
2734 };
2735 self.selected_recommendation = 0;
2736 self.scroll_offset = 0;
2737 }
2738}
2739
2740impl ListNavigation for QualityViewState {
2741 fn selected(&self) -> usize {
2742 self.selected_recommendation
2743 }
2744
2745 fn set_selected(&mut self, idx: usize) {
2746 self.selected_recommendation = idx;
2747 }
2748
2749 fn total(&self) -> usize {
2750 self.total_recommendations
2751 }
2752
2753 fn set_total(&mut self, total: usize) {
2754 self.total_recommendations = total;
2755 }
2756}
2757
2758impl Default for QualityViewState {
2759 fn default() -> Self {
2760 Self::new(0)
2761 }
2762}
2763
2764#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2766pub(crate) enum VulnGroupBy {
2767 Severity,
2768 Component,
2769 Flat,
2770}
2771
2772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2774pub(crate) enum VulnSortBy {
2775 Severity,
2776 Cvss,
2777 CveId,
2778 Component,
2779}
2780
2781impl VulnSortBy {
2782 pub const fn next(self) -> Self {
2783 match self {
2784 Self::Severity => Self::Cvss,
2785 Self::Cvss => Self::CveId,
2786 Self::CveId => Self::Component,
2787 Self::Component => Self::Severity,
2788 }
2789 }
2790
2791 pub const fn label(self) -> &'static str {
2792 match self {
2793 Self::Severity => "Severity",
2794 Self::Cvss => "CVSS",
2795 Self::CveId => "CVE ID",
2796 Self::Component => "Component",
2797 }
2798 }
2799}
2800
2801#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2803pub(crate) enum AlgorithmSortBy {
2804 #[default]
2805 Name,
2806 Family,
2807 QuantumLevel,
2808 Strength,
2809}
2810
2811impl AlgorithmSortBy {
2812 pub const fn next(self) -> Self {
2813 match self {
2814 Self::Name => Self::Family,
2815 Self::Family => Self::QuantumLevel,
2816 Self::QuantumLevel => Self::Strength,
2817 Self::Strength => Self::Name,
2818 }
2819 }
2820
2821 pub const fn label(self) -> &'static str {
2822 match self {
2823 Self::Name => "Name",
2824 Self::Family => "Family",
2825 Self::QuantumLevel => "Quantum",
2826 Self::Strength => "Strength",
2827 }
2828 }
2829}
2830
2831#[derive(Debug, Clone)]
2833pub(crate) struct LicenseViewState {
2834 pub selected: usize,
2835 pub total: usize,
2836 pub scroll_offset: usize,
2837 pub group_by: LicenseGroupBy,
2838 pub component_scroll: usize,
2840 pub component_total: usize,
2842 pub compat_report:
2846 Option<std::sync::Arc<crate::tui::license_utils::LicenseCompatibilityReport>>,
2847}
2848
2849impl LicenseViewState {
2850 pub const fn new() -> Self {
2851 Self {
2852 selected: 0,
2853 total: 0,
2854 scroll_offset: 0,
2855 group_by: LicenseGroupBy::License,
2856 component_scroll: 0,
2857 component_total: 0,
2858 compat_report: None,
2859 }
2860 }
2861
2862 pub const fn scroll_components_up(&mut self) {
2864 if self.component_scroll > 0 {
2865 self.component_scroll -= 1;
2866 }
2867 }
2868
2869 pub const fn scroll_components_down(&mut self, visible_count: usize) {
2871 if self.component_total > visible_count
2872 && self.component_scroll < self.component_total - visible_count
2873 {
2874 self.component_scroll += 1;
2875 }
2876 }
2877
2878 pub const fn reset_component_scroll(&mut self) {
2880 self.component_scroll = 0;
2881 }
2882
2883 pub const fn select_next(&mut self) {
2884 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2885 self.selected += 1;
2886 self.reset_component_scroll();
2887 }
2888 }
2889
2890 pub const fn select_prev(&mut self) {
2891 if self.selected > 0 {
2892 self.selected -= 1;
2893 self.reset_component_scroll();
2894 }
2895 }
2896
2897 pub const fn clamp_selection(&mut self) {
2899 if self.total == 0 {
2900 self.selected = 0;
2901 } else if self.selected >= self.total {
2902 self.selected = self.total.saturating_sub(1);
2903 }
2904 }
2905
2906 pub const fn toggle_group(&mut self) {
2907 self.group_by = match self.group_by {
2908 LicenseGroupBy::License => LicenseGroupBy::Category,
2909 LicenseGroupBy::Category => LicenseGroupBy::License,
2910 };
2911 self.selected = 0;
2912 self.reset_component_scroll();
2913 }
2914}
2915
2916impl Default for LicenseViewState {
2917 fn default() -> Self {
2918 Self::new()
2919 }
2920}
2921
2922impl ListNavigation for LicenseViewState {
2923 fn selected(&self) -> usize {
2924 self.selected
2925 }
2926
2927 fn set_selected(&mut self, idx: usize) {
2928 self.selected = idx;
2929 }
2930
2931 fn total(&self) -> usize {
2932 self.total
2933 }
2934
2935 fn set_total(&mut self, total: usize) {
2936 self.total = total;
2937 }
2938}
2939
2940#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2942pub(crate) enum LicenseGroupBy {
2943 License,
2944 Category,
2945}
2946
2947#[derive(Debug, Clone)]
2949pub(crate) struct DependencyViewState {
2950 pub selected: usize,
2952 pub total: usize,
2954 pub expanded: HashSet<String>,
2956 pub scroll_offset: usize,
2958 pub search_query: String,
2960 pub search_active: bool,
2962 pub detail_scroll: u16,
2964 pub roots_initialized: bool,
2966 expanded_snapshot: HashSet<String>,
2968 pub cached_flat_nodes: Vec<super::views::FlatDepNode>,
2970 cached_search_match: (String, Option<usize>),
2972 pub(crate) cached_graph: Option<super::views::DependencyGraph>,
2974}
2975
2976impl DependencyViewState {
2977 pub fn new() -> Self {
2978 Self {
2979 selected: 0,
2980 total: 0,
2981 expanded: HashSet::new(),
2982 scroll_offset: 0,
2983 search_query: String::new(),
2984 search_active: false,
2985 detail_scroll: 0,
2986 roots_initialized: false,
2987 expanded_snapshot: HashSet::new(),
2988 cached_flat_nodes: Vec::new(),
2989 cached_search_match: (String::new(), None),
2990 cached_graph: None,
2991 }
2992 }
2993
2994 pub fn toggle_expand(&mut self, node_id: &str) {
2995 if self.expanded.contains(node_id) {
2996 self.expanded.remove(node_id);
2997 } else {
2998 self.expanded.insert(node_id.to_string());
2999 }
3000 }
3001
3002 pub fn is_expanded(&self, node_id: &str) -> bool {
3003 self.expanded.contains(node_id)
3004 }
3005
3006 pub fn expand_all(&mut self, all_node_ids: &[String]) {
3007 self.expanded.extend(all_node_ids.iter().cloned());
3008 }
3009
3010 pub fn collapse_all(&mut self) {
3011 self.expanded.clear();
3012 }
3013
3014 pub fn start_search(&mut self) {
3015 self.search_active = true;
3016 }
3017
3018 pub fn stop_search(&mut self) {
3019 self.search_active = false;
3020 }
3021
3022 pub fn clear_search(&mut self) {
3023 self.search_query.clear();
3024 self.search_active = false;
3025 }
3026
3027 pub fn search_push(&mut self, c: char) {
3028 self.search_query.push(c);
3029 }
3030
3031 pub fn search_pop(&mut self) {
3032 self.search_query.pop();
3033 }
3034
3035 pub fn are_flat_nodes_valid(&self) -> bool {
3037 !self.cached_flat_nodes.is_empty() && self.expanded_snapshot == self.expanded
3038 }
3039
3040 pub fn set_cached_flat_nodes(&mut self, nodes: Vec<super::views::FlatDepNode>) {
3042 self.cached_flat_nodes = nodes;
3043 self.expanded_snapshot = self.expanded.clone();
3044 }
3045
3046 pub fn get_search_match_count(&mut self) -> Option<usize> {
3048 if self.search_query.is_empty() {
3049 return None;
3050 }
3051 if self.cached_search_match.0 == self.search_query {
3052 return self.cached_search_match.1;
3053 }
3054 let q = self.search_query.to_lowercase();
3055 let count = self
3056 .cached_flat_nodes
3057 .iter()
3058 .filter(|n| n.name.to_lowercase().contains(&q))
3059 .count();
3060 self.cached_search_match = (self.search_query.clone(), Some(count));
3061 Some(count)
3062 }
3063}
3064
3065impl Default for DependencyViewState {
3066 fn default() -> Self {
3067 Self::new()
3068 }
3069}
3070
3071impl ListNavigation for DependencyViewState {
3072 fn selected(&self) -> usize {
3073 self.selected
3074 }
3075
3076 fn set_selected(&mut self, idx: usize) {
3077 self.selected = idx;
3078 }
3079
3080 fn total(&self) -> usize {
3081 self.total
3082 }
3083
3084 fn set_total(&mut self, total: usize) {
3085 self.total = total;
3086 }
3087}
3088
3089#[derive(Debug, Clone)]
3091pub(crate) struct SearchState {
3092 pub active: bool,
3093 pub query: String,
3094 pub results: Vec<SearchResult>,
3095 pub selected: usize,
3096 pub mode: crate::tui::app_states::SearchMode,
3098 pub search_error: Option<String>,
3100}
3101
3102impl SearchState {
3103 pub const fn new() -> Self {
3104 Self {
3105 active: false,
3106 query: String::new(),
3107 results: Vec::new(),
3108 selected: 0,
3109 mode: crate::tui::app_states::SearchMode::Substring,
3110 search_error: None,
3111 }
3112 }
3113
3114 pub fn push_char(&mut self, c: char) {
3115 self.query.push(c);
3116 }
3117
3118 pub fn pop_char(&mut self) {
3119 self.query.pop();
3120 }
3121
3122 pub fn select_next(&mut self) {
3123 if !self.results.is_empty() && self.selected < self.results.len() - 1 {
3124 self.selected += 1;
3125 }
3126 }
3127
3128 pub const fn select_prev(&mut self) {
3129 if self.selected > 0 {
3130 self.selected -= 1;
3131 }
3132 }
3133}
3134
3135impl Default for SearchState {
3136 fn default() -> Self {
3137 Self::new()
3138 }
3139}
3140
3141#[derive(Debug, Clone)]
3143pub(crate) enum SearchResult {
3144 Component {
3145 id: String,
3146 name: String,
3147 version: Option<String>,
3148 match_field: String,
3149 },
3150 Vulnerability {
3151 id: String,
3152 component_id: String,
3154 component_name: String,
3156 severity: Option<String>,
3157 },
3158}
3159
3160#[derive(Debug, Clone)]
3162pub struct SbomStats {
3163 pub component_count: usize,
3164 pub vuln_count: usize,
3165 pub license_count: usize,
3166 pub ecosystem_counts: HashMap<String, usize>,
3167 pub vuln_by_severity: HashMap<String, usize>,
3168 pub license_counts: HashMap<String, usize>,
3169 pub critical_count: usize,
3170 pub high_count: usize,
3171 pub medium_count: usize,
3172 pub low_count: usize,
3173 pub unknown_count: usize,
3174 pub eol_count: usize,
3175 pub eol_approaching_count: usize,
3176 pub eol_supported_count: usize,
3177 pub eol_security_only_count: usize,
3178 pub eol_enriched: bool,
3179}
3180
3181impl SbomStats {
3182 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
3183 let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
3184 let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
3185 let mut license_counts: HashMap<String, usize> = HashMap::new();
3186 let mut vuln_count = 0;
3187 let mut critical_count = 0;
3188 let mut high_count = 0;
3189 let mut medium_count = 0;
3190 let mut low_count = 0;
3191 let mut unknown_count = 0;
3192 let mut eol_count = 0;
3193 let mut eol_approaching_count = 0;
3194 let mut eol_supported_count = 0;
3195 let mut eol_security_only_count = 0;
3196 let mut eol_enriched = false;
3197
3198 for comp in sbom.components.values() {
3199 let eco = comp
3201 .ecosystem
3202 .as_ref()
3203 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
3204 *ecosystem_counts.entry(eco).or_insert(0) += 1;
3205
3206 for lic in &comp.licenses.declared {
3208 *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
3209 }
3210 if comp.licenses.declared.is_empty() {
3211 *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
3212 }
3213
3214 for vuln in &comp.vulnerabilities {
3216 vuln_count += 1;
3217 let sev = vuln
3218 .severity
3219 .as_ref()
3220 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
3221 *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
3222
3223 match sev.to_lowercase().as_str() {
3224 "critical" => critical_count += 1,
3225 "high" => high_count += 1,
3226 "medium" => medium_count += 1,
3227 "low" => low_count += 1,
3228 _ => unknown_count += 1,
3229 }
3230 }
3231
3232 if let Some(eol) = &comp.eol {
3234 use crate::model::EolStatus;
3235 eol_enriched = true;
3236 match eol.status {
3237 EolStatus::EndOfLife => eol_count += 1,
3238 EolStatus::ApproachingEol => eol_approaching_count += 1,
3239 EolStatus::Supported => eol_supported_count += 1,
3240 EolStatus::SecurityOnly => eol_security_only_count += 1,
3241 _ => {}
3242 }
3243 }
3244 }
3245
3246 Self {
3247 component_count: sbom.components.len(),
3248 vuln_count,
3249 license_count: license_counts
3252 .keys()
3253 .filter(|k| k.as_str() != "Unknown")
3254 .count(),
3255 ecosystem_counts,
3256 vuln_by_severity,
3257 license_counts,
3258 critical_count,
3259 high_count,
3260 medium_count,
3261 low_count,
3262 unknown_count,
3263 eol_count,
3264 eol_approaching_count,
3265 eol_supported_count,
3266 eol_security_only_count,
3267 eol_enriched,
3268 }
3269 }
3270}
3271
3272#[derive(Debug, Clone)]
3274pub struct ViewBreadcrumb {
3275 pub tab: ViewTab,
3277 pub label: String,
3279 pub selection_index: usize,
3281}
3282
3283#[derive(Debug, Clone, Default)]
3285pub struct ViewNavigationContext {
3286 pub breadcrumbs: Vec<ViewBreadcrumb>,
3288 pub target_component: Option<String>,
3290 pub target_vulnerability: Option<String>,
3292}
3293
3294impl ViewNavigationContext {
3295 #[must_use]
3296 pub const fn new() -> Self {
3297 Self {
3298 breadcrumbs: Vec::new(),
3299 target_component: None,
3300 target_vulnerability: None,
3301 }
3302 }
3303
3304 pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
3306 self.breadcrumbs.push(ViewBreadcrumb {
3307 tab,
3308 label,
3309 selection_index,
3310 });
3311 }
3312
3313 pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
3315 self.breadcrumbs.pop()
3316 }
3317
3318 pub fn clear_breadcrumbs(&mut self) {
3320 self.breadcrumbs.clear();
3321 }
3322
3323 #[must_use]
3325 pub fn has_history(&self) -> bool {
3326 !self.breadcrumbs.is_empty()
3327 }
3328
3329 #[must_use]
3331 pub fn breadcrumb_trail(&self) -> String {
3332 self.breadcrumbs
3333 .iter()
3334 .map(|b| format!("{}: {}", b.tab.title(), b.label))
3335 .collect::<Vec<_>>()
3336 .join(" > ")
3337 }
3338
3339 pub fn clear_targets(&mut self) {
3341 self.target_component = None;
3342 self.target_vulnerability = None;
3343 }
3344}
3345
3346#[cfg(test)]
3347mod tests {
3348 use super::*;
3349 use crate::model::NormalizedSbom;
3350
3351 #[test]
3352 fn test_view_app_creation() {
3353 let sbom = NormalizedSbom::default();
3354 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3355 app.active_tab = ViewTab::Overview;
3357 assert_eq!(app.active_tab, ViewTab::Overview);
3358 assert!(!app.should_quit);
3359 }
3360
3361 #[test]
3362 fn with_cra_product_class_reaches_the_compliance_results() {
3363 use crate::quality::ComplianceLevel;
3368 fn cra_error_count(app: &mut ViewApp) -> usize {
3369 app.ensure_compliance_results();
3370 app.compliance_results
3371 .as_ref()
3372 .unwrap()
3373 .iter()
3374 .find(|r| r.level == ComplianceLevel::CraPhase2)
3375 .expect("CraPhase2 result present")
3376 .error_count
3377 }
3378 let mut plain = ViewApp::new(
3379 NormalizedSbom::default(),
3380 "",
3381 crate::model::BomProfile::Sbom,
3382 );
3383 let mut critical = ViewApp::new(
3384 NormalizedSbom::default(),
3385 "",
3386 crate::model::BomProfile::Sbom,
3387 )
3388 .with_cra_product_class(crate::model::CraProductClass::Critical);
3389 let plain_errors = cra_error_count(&mut plain);
3390 let critical_errors = cra_error_count(&mut critical);
3391 assert!(
3392 critical_errors > plain_errors,
3393 "Critical class must escalate CRA severities in the TUI results \
3394 (plain={plain_errors} critical={critical_errors})"
3395 );
3396 }
3397
3398 #[test]
3399 fn test_tab_navigation() {
3400 let sbom = NormalizedSbom::default();
3401 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Sbom);
3402 app.active_tab = ViewTab::Overview;
3404
3405 app.next_tab();
3406 assert_eq!(app.active_tab, ViewTab::Tree);
3407
3408 app.next_tab();
3409 assert_eq!(app.active_tab, ViewTab::Dependencies);
3410
3411 app.prev_tab();
3412 assert_eq!(app.active_tab, ViewTab::Tree);
3413 }
3414
3415 #[test]
3416 fn test_vuln_state_navigation_with_zero_total() {
3417 let mut state = VulnExplorerState::new();
3419 assert_eq!(state.total, 0);
3420 assert_eq!(state.selected, 0);
3421
3422 state.select_next();
3424 assert_eq!(state.selected, 0);
3425
3426 state.select_prev();
3427 assert_eq!(state.selected, 0);
3428 }
3429
3430 #[test]
3431 fn test_vuln_state_clamp_selection() {
3432 let mut state = VulnExplorerState::new();
3433 state.total = 5;
3434 state.selected = 10; state.clamp_selection();
3437 assert_eq!(state.selected, 4); state.total = 0;
3440 state.clamp_selection();
3441 assert_eq!(state.selected, 0); }
3443
3444 #[test]
3445 fn test_license_state_navigation_with_zero_total() {
3446 let mut state = LicenseViewState::new();
3447 assert_eq!(state.total, 0);
3448 assert_eq!(state.selected, 0);
3449
3450 state.select_next();
3452 assert_eq!(state.selected, 0);
3453
3454 state.select_prev();
3455 assert_eq!(state.selected, 0);
3456 }
3457
3458 #[test]
3459 fn test_license_state_clamp_selection() {
3460 let mut state = LicenseViewState::new();
3461 state.total = 3;
3462 state.selected = 5; state.clamp_selection();
3465 assert_eq!(state.selected, 2); }
3467
3468 #[test]
3469 fn test_dependency_state_navigation() {
3470 let mut state = DependencyViewState::new();
3471 assert_eq!(state.total, 0);
3472 assert_eq!(state.selected, 0);
3473
3474 state.select_next();
3476 assert_eq!(state.selected, 0);
3477
3478 state.total = 5;
3480 state.select_next();
3481 assert_eq!(state.selected, 1);
3482
3483 state.select_next();
3484 state.select_next();
3485 state.select_next();
3486 assert_eq!(state.selected, 4); state.select_next();
3489 assert_eq!(state.selected, 4); state.select_prev();
3492 assert_eq!(state.selected, 3);
3493 }
3494
3495 #[test]
3496 fn test_dependency_state_expand_collapse() {
3497 let mut state = DependencyViewState::new();
3498
3499 assert!(!state.is_expanded("node1"));
3500
3501 state.toggle_expand("node1");
3502 assert!(state.is_expanded("node1"));
3503
3504 state.toggle_expand("node1");
3505 assert!(!state.is_expanded("node1"));
3506 }
3507
3508 #[test]
3509 fn test_tabs_for_profile_sbom() {
3510 let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Sbom);
3511 assert_eq!(tabs.len(), 8);
3512 assert_eq!(tabs[0], ViewTab::Overview);
3513 assert_eq!(tabs[1], ViewTab::Tree);
3514 assert_eq!(tabs[2], ViewTab::Dependencies);
3517 assert_eq!(tabs[3], ViewTab::Licenses);
3518 assert_eq!(tabs[4], ViewTab::Vulnerabilities);
3519 assert_eq!(tabs[7], ViewTab::Source);
3520 assert!(!tabs.contains(&ViewTab::Algorithms));
3521 }
3522
3523 #[test]
3524 fn test_tabs_for_profile_cbom() {
3525 let tabs = ViewTab::tabs_for_profile(crate::model::BomProfile::Cbom);
3526 assert_eq!(tabs.len(), 8);
3527 assert_eq!(tabs[0], ViewTab::Overview);
3528 assert_eq!(tabs[1], ViewTab::Algorithms);
3529 assert_eq!(tabs[2], ViewTab::Certificates);
3530 assert_eq!(tabs[3], ViewTab::Keys);
3531 assert_eq!(tabs[4], ViewTab::Protocols);
3532 assert_eq!(tabs[5], ViewTab::Quality);
3533 assert_eq!(tabs[6], ViewTab::PqcCompliance);
3534 assert_eq!(tabs[7], ViewTab::Source);
3535 assert!(!tabs.contains(&ViewTab::Tree));
3536 }
3537
3538 #[test]
3539 fn test_cbom_tab_navigation_cycles() {
3540 let sbom = NormalizedSbom::default();
3541 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3542 app.active_tab = ViewTab::Overview;
3543
3544 app.next_tab();
3545 assert_eq!(app.active_tab, ViewTab::Algorithms);
3546
3547 app.next_tab();
3548 assert_eq!(app.active_tab, ViewTab::Certificates);
3549
3550 app.active_tab = ViewTab::Source;
3552 app.next_tab();
3553 assert_eq!(app.active_tab, ViewTab::Overview);
3554
3555 app.prev_tab();
3557 assert_eq!(app.active_tab, ViewTab::Source);
3558 }
3559
3560 #[test]
3561 fn test_per_tab_selection_independent() {
3562 let mut sbom = NormalizedSbom::default();
3563 for i in 0..5 {
3565 let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3566 c.component_type = crate::model::ComponentType::Cryptographic;
3567 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3568 crate::model::CryptoAssetType::Algorithm,
3569 ));
3570 sbom.add_component(c);
3571 }
3572 for i in 0..2 {
3573 let mut c = crate::model::Component::new(format!("cert-{i}"), format!("cert-{i}@1.0"));
3574 c.component_type = crate::model::ComponentType::Cryptographic;
3575 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3576 crate::model::CryptoAssetType::Certificate,
3577 ));
3578 sbom.add_component(c);
3579 }
3580
3581 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3582
3583 app.active_tab = ViewTab::Algorithms;
3585 app.navigate_down();
3586 app.navigate_down();
3587 assert_eq!(app.algorithms_selected, 2);
3588
3589 app.active_tab = ViewTab::Certificates;
3591 assert_eq!(app.certificates_selected, 0);
3592
3593 app.navigate_down();
3595 assert_eq!(app.certificates_selected, 1);
3596
3597 app.active_tab = ViewTab::Algorithms;
3599 assert_eq!(app.algorithms_selected, 2);
3600 }
3601
3602 #[test]
3603 fn test_crypto_count_for_tab() {
3604 let mut sbom = NormalizedSbom::default();
3605 for i in 0..3 {
3606 let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3607 c.component_type = crate::model::ComponentType::Cryptographic;
3608 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3609 crate::model::CryptoAssetType::Algorithm,
3610 ));
3611 sbom.add_component(c);
3612 }
3613 let mut c = crate::model::Component::new("cert-0".to_string(), "cert-0@1.0".to_string());
3614 c.component_type = crate::model::ComponentType::Cryptographic;
3615 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3616 crate::model::CryptoAssetType::Certificate,
3617 ));
3618 sbom.add_component(c);
3619
3620 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3621
3622 app.active_tab = ViewTab::Algorithms;
3623 assert_eq!(app.crypto_count_for_tab(), 3);
3624
3625 app.active_tab = ViewTab::Certificates;
3626 assert_eq!(app.crypto_count_for_tab(), 1);
3627
3628 app.active_tab = ViewTab::Keys;
3629 assert_eq!(app.crypto_count_for_tab(), 0);
3630
3631 app.active_tab = ViewTab::Crypto;
3633 assert_eq!(app.crypto_count_for_tab(), 4);
3634 }
3635
3636 #[test]
3639 fn pqc_compliance_navigation_clamps_selection() {
3640 let mut sbom = NormalizedSbom::default();
3641 for i in 0..5 {
3642 let mut c = crate::model::Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
3643 c.component_type = crate::model::ComponentType::Cryptographic;
3644 c.crypto_properties = Some(crate::model::CryptoProperties::new(
3645 crate::model::CryptoAssetType::Algorithm,
3646 ));
3647 sbom.add_component(c);
3648 }
3649 let mut app = ViewApp::new(sbom, "", crate::model::BomProfile::Cbom);
3650 app.active_tab = ViewTab::PqcCompliance;
3651
3652 app.navigate_down();
3653 app.navigate_down();
3654 assert_eq!(app.pqc_selected, 2);
3655 app.go_last();
3656 assert_eq!(app.pqc_selected, 4);
3657 app.navigate_down();
3658 assert_eq!(app.pqc_selected, 4, "selection must clamp at the last row");
3659 app.go_first();
3660 assert_eq!(app.pqc_selected, 0);
3661 }
3662
3663 #[test]
3666 fn ai_readiness_navigation_scrolls_and_clamps() {
3667 let (sbom, profile) = crate::tui::test_support::aibom_single();
3668 let mut app = ViewApp::new(sbom, "", profile);
3669 app.active_tab = ViewTab::AiReadiness;
3670
3671 let max = app.ai_readiness_max_scroll();
3672 assert!(max > 0, "AIBOM fixture must produce scrollable content");
3673 for _ in 0..(max + 5) {
3674 app.navigate_down();
3675 }
3676 assert_eq!(app.ai_readiness_scroll, max, "scroll must clamp at max");
3677 app.go_first();
3678 assert_eq!(app.ai_readiness_scroll, 0);
3679 app.go_last();
3680 assert_eq!(app.ai_readiness_scroll, max);
3681 app.navigate_up();
3682 assert_eq!(app.ai_readiness_scroll, max - 1);
3683 }
3684}