1use crate::model::{Component, NormalizedSbom, NormalizedSbomIndex, VulnerabilityRef};
7use crate::quality::{ComplianceResult, QualityReport, QualityScorer, ScoringProfile};
8use crate::tui::app_states::SourcePanelState;
9use crate::tui::state::ListNavigation;
10use crate::tui::widgets::TreeState;
11use std::collections::{HashMap, HashSet};
12
13use super::views::{StandardComplianceState, compute_compliance_results};
14
15pub struct ViewApp {
17 pub(crate) sbom: NormalizedSbom,
19
20 pub(crate) active_tab: ViewTab,
22
23 pub(crate) tree_state: TreeState,
25
26 pub(crate) tree_group_by: TreeGroupBy,
28
29 pub(crate) tree_filter: TreeFilter,
31
32 pub(crate) tree_search_query: String,
34
35 pub(crate) tree_search_active: bool,
37
38 pub(crate) selected_component: Option<String>,
40
41 pub(crate) component_tab: ComponentDetailTab,
43
44 pub(crate) vuln_state: VulnExplorerState,
46
47 pub(crate) license_state: LicenseViewState,
49
50 pub(crate) dependency_state: DependencyViewState,
52
53 pub(crate) search_state: SearchState,
55
56 pub(crate) focus_panel: FocusPanel,
58
59 pub(crate) show_help: bool,
61
62 pub(crate) show_export: bool,
64
65 pub(crate) show_legend: bool,
67
68 pub(crate) status_message: Option<String>,
70
71 pub(crate) status_sticky: bool,
73
74 pub(crate) navigation_ctx: ViewNavigationContext,
76
77 pub(crate) should_quit: bool,
79
80 pub(crate) tick: u64,
82
83 pub(crate) stats: SbomStats,
85
86 pub(crate) quality_report: QualityReport,
88
89 pub(crate) quality_state: QualityViewState,
91
92 pub(crate) compliance_results: Option<Vec<ComplianceResult>>,
94
95 pub(crate) compliance_state: StandardComplianceState,
97
98 pub(crate) sbom_index: NormalizedSbomIndex,
100
101 pub(crate) source_state: SourcePanelState,
103
104 pub(crate) bookmarked: HashSet<String>,
106
107 pub(crate) export_template: Option<String>,
109}
110
111impl ViewApp {
112 #[must_use]
114 pub fn new(sbom: NormalizedSbom, raw_content: &str) -> Self {
115 let stats = SbomStats::from_sbom(&sbom);
116
117 let scorer = QualityScorer::new(ScoringProfile::Standard);
119 let quality_report = scorer.score(&sbom);
120 let quality_state = QualityViewState::new(quality_report.recommendations.len());
121
122 let compliance_state = StandardComplianceState::new();
123
124 let sbom_index = sbom.build_index();
126
127 let source_state = SourcePanelState::new(raw_content);
129
130 let mut tree_state = TreeState::new();
132 for eco in stats.ecosystem_counts.keys().take(3) {
133 tree_state.expand(&format!("eco:{eco}"));
134 }
135
136 let initial_tab = crate::config::TuiPreferences::load()
138 .last_view_tab
139 .as_deref()
140 .and_then(ViewTab::from_str_opt)
141 .unwrap_or(ViewTab::Overview);
142
143 let mut app = Self {
144 sbom,
145 active_tab: initial_tab,
146 tree_state,
147 tree_group_by: TreeGroupBy::Ecosystem,
148 tree_filter: TreeFilter::All,
149 tree_search_query: String::new(),
150 tree_search_active: false,
151 selected_component: None,
152 component_tab: ComponentDetailTab::Overview,
153 vuln_state: VulnExplorerState::new(),
154 license_state: LicenseViewState::new(),
155 dependency_state: DependencyViewState::new(),
156 search_state: SearchState::new(),
157 focus_panel: FocusPanel::Left,
158 show_help: false,
159 show_export: false,
160 show_legend: false,
161 status_message: None,
162 status_sticky: false,
163 navigation_ctx: ViewNavigationContext::new(),
164 should_quit: false,
165 tick: 0,
166 stats,
167 quality_report,
168 quality_state,
169 compliance_results: None,
170 compliance_state,
171 sbom_index,
172 source_state,
173 bookmarked: HashSet::new(),
174 export_template: None,
175 };
176
177 let cache = super::views::build_vuln_cache(&app);
179 app.vuln_state.set_cache(cache);
180
181 app
182 }
183
184 pub fn ensure_compliance_results(&mut self) {
186 if self.compliance_results.is_none() {
187 self.compliance_results = Some(compute_compliance_results(&self.sbom));
188 }
189 }
190
191 pub const fn next_tab(&mut self) {
193 self.active_tab = match self.active_tab {
194 ViewTab::Overview => ViewTab::Tree,
195 ViewTab::Tree => ViewTab::Vulnerabilities,
196 ViewTab::Vulnerabilities => ViewTab::Licenses,
197 ViewTab::Licenses => ViewTab::Dependencies,
198 ViewTab::Dependencies => ViewTab::Quality,
199 ViewTab::Quality => ViewTab::Compliance,
200 ViewTab::Compliance => ViewTab::Source,
201 ViewTab::Source => ViewTab::Overview,
202 };
203 }
204
205 pub const fn prev_tab(&mut self) {
207 self.active_tab = match self.active_tab {
208 ViewTab::Overview => ViewTab::Source,
209 ViewTab::Tree => ViewTab::Overview,
210 ViewTab::Vulnerabilities => ViewTab::Tree,
211 ViewTab::Licenses => ViewTab::Vulnerabilities,
212 ViewTab::Dependencies => ViewTab::Licenses,
213 ViewTab::Quality => ViewTab::Dependencies,
214 ViewTab::Compliance => ViewTab::Quality,
215 ViewTab::Source => ViewTab::Compliance,
216 };
217 }
218
219 pub const fn select_tab(&mut self, tab: ViewTab) {
221 self.active_tab = tab;
222 }
223
224 #[must_use]
232 pub fn get_sort_key(
233 &self,
234 id: &crate::model::CanonicalId,
235 ) -> Option<&crate::model::ComponentSortKey> {
236 self.sbom_index.sort_key(id)
237 }
238
239 #[must_use]
241 pub fn get_dependencies(
242 &self,
243 id: &crate::model::CanonicalId,
244 ) -> Vec<&crate::model::DependencyEdge> {
245 self.sbom_index.dependencies_of(id, &self.sbom.edges)
246 }
247
248 #[must_use]
250 pub fn get_dependents(
251 &self,
252 id: &crate::model::CanonicalId,
253 ) -> Vec<&crate::model::DependencyEdge> {
254 self.sbom_index.dependents_of(id, &self.sbom.edges)
255 }
256
257 #[must_use]
259 pub fn search_components_by_name(&self, query: &str) -> Vec<&crate::model::Component> {
260 self.sbom.search_by_name_indexed(query, &self.sbom_index)
261 }
262
263 pub const fn toggle_focus(&mut self) {
265 self.focus_panel = match self.focus_panel {
266 FocusPanel::Left => FocusPanel::Right,
267 FocusPanel::Right => FocusPanel::Left,
268 };
269 }
270
271 pub fn start_search(&mut self) {
273 self.search_state.active = true;
274 self.search_state.query.clear();
275 self.search_state.results.clear();
276 }
277
278 pub const fn stop_search(&mut self) {
280 self.search_state.active = false;
281 }
282
283 pub fn execute_search(&mut self) {
285 self.search_state.results = self.search(&self.search_state.query.clone());
286 self.search_state.selected = 0;
287 }
288
289 fn search(&self, query: &str) -> Vec<SearchResult> {
291 if query.len() < 2 {
292 return Vec::new();
293 }
294
295 let query_lower = query.to_lowercase();
296 let mut results = Vec::new();
297
298 for (id, comp) in &self.sbom.components {
300 if comp.name.to_lowercase().contains(&query_lower) {
301 results.push(SearchResult::Component {
302 id: id.value().to_string(),
303 name: comp.name.clone(),
304 version: comp.version.clone(),
305 match_field: "name".to_string(),
306 });
307 } else if let Some(purl) = &comp.identifiers.purl
308 && purl.to_lowercase().contains(&query_lower)
309 {
310 results.push(SearchResult::Component {
311 id: id.value().to_string(),
312 name: comp.name.clone(),
313 version: comp.version.clone(),
314 match_field: "purl".to_string(),
315 });
316 }
317 }
318
319 for (_, comp) in &self.sbom.components {
321 for vuln in &comp.vulnerabilities {
322 if vuln.id.to_lowercase().contains(&query_lower) {
323 results.push(SearchResult::Vulnerability {
324 id: vuln.id.clone(),
325 component_id: comp.canonical_id.to_string(), component_name: comp.name.clone(),
327 severity: vuln.severity.as_ref().map(std::string::ToString::to_string),
328 });
329 }
330 }
331 }
332
333 results.truncate(50);
335 results
336 }
337
338 #[must_use]
340 pub fn get_selected_component(&self) -> Option<&Component> {
341 self.selected_component.as_ref().and_then(|selected_id| {
342 self.sbom
343 .components
344 .iter()
345 .find(|(id, _)| id.value() == selected_id)
346 .map(|(_, comp)| comp)
347 })
348 }
349
350 pub fn jump_to_component_in_tree(&mut self, component_id: &str) -> bool {
352 let group_id = {
353 let Some(comp) = self
354 .sbom
355 .components
356 .iter()
357 .find(|(id, _)| id.value() == component_id)
358 .map(|(_, comp)| comp)
359 else {
360 return false;
361 };
362 self.tree_group_id_for_component(comp)
363 };
364 if let Some(ref group_id) = group_id {
365 self.tree_state.expand(group_id);
366 }
367
368 let nodes = self.build_tree_nodes();
369 let mut flat_items = Vec::new();
370 flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
371
372 if let Some(index) = flat_items
373 .iter()
374 .position(|item| matches!(item, SelectedTreeNode::Component(id) if id == component_id))
375 {
376 self.tree_state.selected = index;
377 return true;
378 }
379
380 if let Some(group_id) = group_id
381 && let Some(index) = flat_items
382 .iter()
383 .position(|item| matches!(item, SelectedTreeNode::Group(id) if id == &group_id))
384 {
385 self.tree_state.selected = index;
386 }
387
388 false
389 }
390
391 #[must_use]
394 pub fn get_selected_group_info(&self) -> Option<(String, Vec<String>)> {
395 let nodes = self.build_tree_nodes();
396 let mut flat_items = Vec::new();
397 flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
398
399 let selected = flat_items.get(self.tree_state.selected)?;
400 match selected {
401 SelectedTreeNode::Group(group_id) => {
402 fn find_group_children(
404 nodes: &[crate::tui::widgets::TreeNode],
405 target_id: &str,
406 ) -> Option<(String, Vec<String>)> {
407 for node in nodes {
408 if let crate::tui::widgets::TreeNode::Group {
409 id,
410 label,
411 children,
412 ..
413 } = node
414 {
415 if id == target_id {
416 let child_ids: Vec<String> = children
417 .iter()
418 .filter_map(|c| match c {
419 crate::tui::widgets::TreeNode::Component { id, .. } => {
420 Some(id.clone())
421 }
422 crate::tui::widgets::TreeNode::Group { .. } => None,
423 })
424 .collect();
425 return Some((label.clone(), child_ids));
426 }
427 if let Some(result) = find_group_children(children, target_id) {
429 return Some(result);
430 }
431 }
432 }
433 None
434 }
435 find_group_children(&nodes, group_id)
436 }
437 SelectedTreeNode::Component(_) => None,
438 }
439 }
440
441 pub fn toggle_bookmark(&mut self) {
443 if let Some(ref comp_id) = self.selected_component {
444 if self.bookmarked.contains(comp_id) {
445 self.bookmarked.remove(comp_id);
446 } else {
447 self.bookmarked.insert(comp_id.clone());
448 }
449 } else if let Some(node) = self.get_selected_tree_node() {
450 match node {
451 SelectedTreeNode::Component(id) => {
452 if self.bookmarked.contains(&id) {
453 self.bookmarked.remove(&id);
454 } else {
455 self.bookmarked.insert(id);
456 }
457 }
458 SelectedTreeNode::Group(_) => {}
459 }
460 }
461 }
462
463 pub fn toggle_tree_grouping(&mut self) {
465 self.tree_group_by = match self.tree_group_by {
466 TreeGroupBy::Ecosystem => TreeGroupBy::License,
467 TreeGroupBy::License => TreeGroupBy::VulnStatus,
468 TreeGroupBy::VulnStatus => TreeGroupBy::ComponentType,
469 TreeGroupBy::ComponentType => TreeGroupBy::Flat,
470 TreeGroupBy::Flat => TreeGroupBy::Ecosystem,
471 };
472 self.tree_state = TreeState::new(); }
474
475 pub fn toggle_tree_filter(&mut self) {
477 self.tree_filter = match self.tree_filter {
478 TreeFilter::All => TreeFilter::HasVulnerabilities,
479 TreeFilter::HasVulnerabilities => TreeFilter::Critical,
480 TreeFilter::Critical => TreeFilter::Bookmarked,
481 TreeFilter::Bookmarked => TreeFilter::All,
482 };
483 self.tree_state = TreeState::new();
484 }
485
486 pub fn start_tree_search(&mut self) {
488 self.tree_search_active = true;
489 self.tree_search_query.clear();
490 }
491
492 pub const fn stop_tree_search(&mut self) {
494 self.tree_search_active = false;
495 }
496
497 pub fn clear_tree_search(&mut self) {
499 self.tree_search_query.clear();
500 self.tree_search_active = false;
501 self.tree_state = TreeState::new();
502 }
503
504 pub fn tree_search_push_char(&mut self, c: char) {
506 self.tree_search_query.push(c);
507 self.tree_state = TreeState::new();
508 }
509
510 pub fn tree_search_pop_char(&mut self) {
512 self.tree_search_query.pop();
513 self.tree_state = TreeState::new();
514 }
515
516 pub const fn next_component_tab(&mut self) {
518 self.component_tab = match self.component_tab {
519 ComponentDetailTab::Overview => ComponentDetailTab::Identifiers,
520 ComponentDetailTab::Identifiers => ComponentDetailTab::Vulnerabilities,
521 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Dependencies,
522 ComponentDetailTab::Dependencies => ComponentDetailTab::Overview,
523 };
524 }
525
526 pub const fn prev_component_tab(&mut self) {
528 self.component_tab = match self.component_tab {
529 ComponentDetailTab::Overview => ComponentDetailTab::Dependencies,
530 ComponentDetailTab::Identifiers => ComponentDetailTab::Overview,
531 ComponentDetailTab::Vulnerabilities => ComponentDetailTab::Identifiers,
532 ComponentDetailTab::Dependencies => ComponentDetailTab::Vulnerabilities,
533 };
534 }
535
536 pub(crate) const fn select_component_tab(&mut self, tab: ComponentDetailTab) {
538 self.component_tab = tab;
539 }
540
541 pub const fn toggle_help(&mut self) {
543 self.show_help = !self.show_help;
544 if self.show_help {
545 self.show_export = false;
546 self.show_legend = false;
547 }
548 }
549
550 pub const fn toggle_export(&mut self) {
552 self.show_export = !self.show_export;
553 if self.show_export {
554 self.show_help = false;
555 self.show_legend = false;
556 }
557 }
558
559 pub const fn toggle_legend(&mut self) {
561 self.show_legend = !self.show_legend;
562 if self.show_legend {
563 self.show_help = false;
564 self.show_export = false;
565 }
566 }
567
568 pub const fn close_overlays(&mut self) {
570 self.show_help = false;
571 self.show_export = false;
572 self.show_legend = false;
573 self.search_state.active = false;
574 self.compliance_state.show_detail = false;
575 }
576
577 #[must_use]
579 pub const fn has_overlay(&self) -> bool {
580 self.show_help
581 || self.show_export
582 || self.show_legend
583 || self.search_state.active
584 || self.compliance_state.show_detail
585 }
586
587 pub fn set_status_message(&mut self, msg: impl Into<String>) {
589 self.status_message = Some(msg.into());
590 }
591
592 pub fn clear_status_message(&mut self) {
597 if self.status_sticky {
598 self.status_sticky = false;
599 } else {
600 self.status_message = None;
601 }
602 }
603
604 pub fn export(&mut self, format: crate::tui::export::ExportFormat) {
609 use crate::reports::ReportConfig;
610 use crate::tui::export::{export_view, view_tab_to_report_type};
611
612 let report_type = view_tab_to_report_type(self.active_tab);
613 let config = ReportConfig::with_types(vec![report_type]);
614 let result = export_view(
615 format,
616 &self.sbom,
617 None,
618 &config,
619 self.export_template.as_deref(),
620 );
621
622 if result.success {
623 self.set_status_message(result.message);
624 self.status_sticky = true;
625 } else {
626 self.set_status_message(format!("Export failed: {}", result.message));
627 }
628 }
629
630 pub fn export_compliance(&mut self, format: crate::tui::export::ExportFormat) {
632 use crate::tui::export::export_compliance;
633
634 self.ensure_compliance_results();
635 let results = match self.compliance_results.as_ref() {
636 Some(r) if !r.is_empty() => r,
637 _ => {
638 self.set_status_message("No compliance results to export");
639 return;
640 }
641 };
642
643 let result = export_compliance(
644 format,
645 results,
646 self.compliance_state.selected_standard,
647 None,
648 self.export_template.as_deref(),
649 );
650 if result.success {
651 self.set_status_message(result.message);
652 self.status_sticky = true;
653 } else {
654 self.set_status_message(format!("Export failed: {}", result.message));
655 }
656 }
657
658 pub fn go_back(&mut self) -> bool {
660 if let Some(breadcrumb) = self.navigation_ctx.pop_breadcrumb() {
661 self.active_tab = breadcrumb.tab;
662 match breadcrumb.tab {
664 ViewTab::Vulnerabilities => {
665 self.vuln_state.selected = breadcrumb.selection_index;
666 }
667 ViewTab::Licenses => {
668 self.license_state.selected = breadcrumb.selection_index;
669 }
670 ViewTab::Dependencies => {
671 self.dependency_state.selected = breadcrumb.selection_index;
672 }
673 ViewTab::Tree => {
674 self.tree_state.selected = breadcrumb.selection_index;
675 }
676 ViewTab::Source => {
677 self.source_state.selected = breadcrumb.selection_index;
678 }
679 _ => {}
680 }
681 self.focus_panel = FocusPanel::Left;
682 true
683 } else {
684 false
685 }
686 }
687
688 pub fn navigate_up(&mut self) {
690 match self.active_tab {
691 ViewTab::Tree => self.tree_state.select_prev(),
692 ViewTab::Vulnerabilities => self.vuln_state.select_prev(),
693 ViewTab::Licenses => self.license_state.select_prev(),
694 ViewTab::Dependencies => self.dependency_state.select_prev(),
695 ViewTab::Quality => self.quality_state.select_prev(),
696 ViewTab::Compliance => self.compliance_state.select_prev(),
697 ViewTab::Source => self.source_state.select_prev(),
698 ViewTab::Overview => {} }
700 }
701
702 pub fn navigate_down(&mut self) {
704 match self.active_tab {
705 ViewTab::Tree => self.tree_state.select_next(),
706 ViewTab::Vulnerabilities => self.vuln_state.select_next(),
707 ViewTab::Licenses => self.license_state.select_next(),
708 ViewTab::Dependencies => self.dependency_state.select_next(),
709 ViewTab::Quality => self.quality_state.select_next(),
710 ViewTab::Compliance => {
711 self.ensure_compliance_results();
712 let max = self.filtered_compliance_violation_count();
713 self.compliance_state.select_next(max);
714 }
715 ViewTab::Source => self.source_state.select_next(),
716 ViewTab::Overview => {} }
718 }
719
720 pub(crate) fn filtered_compliance_violation_count(&self) -> usize {
722 self.compliance_results
723 .as_ref()
724 .and_then(|r| r.get(self.compliance_state.selected_standard))
725 .map_or(0, |r| {
726 r.violations
727 .iter()
728 .filter(|v| self.compliance_state.severity_filter.matches(v.severity))
729 .count()
730 })
731 }
732
733 pub fn page_up(&mut self) {
735 use crate::tui::constants::PAGE_SIZE;
736 if self.active_tab == ViewTab::Source {
737 self.source_state.page_up();
738 } else {
739 for _ in 0..PAGE_SIZE {
740 self.navigate_up();
741 }
742 }
743 }
744
745 pub fn page_down(&mut self) {
747 use crate::tui::constants::PAGE_SIZE;
748 if self.active_tab == ViewTab::Source {
749 self.source_state.page_down();
750 } else {
751 for _ in 0..PAGE_SIZE {
752 self.navigate_down();
753 }
754 }
755 }
756
757 pub const fn go_first(&mut self) {
759 match self.active_tab {
760 ViewTab::Tree => self.tree_state.select_first(),
761 ViewTab::Vulnerabilities => self.vuln_state.selected = 0,
762 ViewTab::Licenses => self.license_state.selected = 0,
763 ViewTab::Dependencies => self.dependency_state.selected = 0,
764 ViewTab::Quality => self.quality_state.scroll_offset = 0,
765 ViewTab::Compliance => self.compliance_state.selected_violation = 0,
766 ViewTab::Source => self.source_state.select_first(),
767 ViewTab::Overview => {}
768 }
769 }
770
771 pub fn go_last(&mut self) {
773 match self.active_tab {
774 ViewTab::Tree => self.tree_state.select_last(),
775 ViewTab::Vulnerabilities => {
776 self.vuln_state.selected = self.vuln_state.total.saturating_sub(1);
777 }
778 ViewTab::Licenses => {
779 self.license_state.selected = self.license_state.total.saturating_sub(1);
780 }
781 ViewTab::Dependencies => {
782 self.dependency_state.selected = self.dependency_state.total.saturating_sub(1);
783 }
784 ViewTab::Quality => {
785 self.quality_state.scroll_offset =
786 self.quality_state.total_recommendations.saturating_sub(1);
787 }
788 ViewTab::Compliance => {
789 self.ensure_compliance_results();
790 let max = self.filtered_compliance_violation_count();
791 self.compliance_state.selected_violation = max.saturating_sub(1);
792 }
793 ViewTab::Source => self.source_state.select_last(),
794 ViewTab::Overview => {}
795 }
796 }
797
798 pub fn handle_enter(&mut self) {
800 match self.active_tab {
801 ViewTab::Tree => {
802 if let Some(node) = self.get_selected_tree_node() {
804 match node {
805 SelectedTreeNode::Group(id) => {
806 self.tree_state.toggle_expand(&id);
807 }
808 SelectedTreeNode::Component(id) => {
809 self.selected_component = Some(id);
810 self.focus_panel = FocusPanel::Right;
811 self.component_tab = ComponentDetailTab::Overview;
812 }
813 }
814 }
815 }
816 ViewTab::Vulnerabilities => {
817 if self.vuln_state.group_by != VulnGroupBy::Flat
819 && let Some(cache) = &self.vuln_state.cached_data
820 {
821 let items = super::views::build_display_items(
822 &cache.vulns,
823 &self.vuln_state.group_by,
824 &self.vuln_state.expanded_groups,
825 );
826 if let Some(item) = items.get(self.vuln_state.selected) {
827 match item {
828 super::views::VulnDisplayItem::GroupHeader { label, .. } => {
829 let label = label.clone();
830 self.vuln_state.toggle_vuln_group(&label);
831 return;
832 }
833 super::views::VulnDisplayItem::Vuln(_) => {
834 }
836 }
837 }
838 }
839 if let Some((comp_id, vuln)) = self.vuln_state.get_selected(&self.sbom) {
841 self.navigation_ctx.push_breadcrumb(
843 ViewTab::Vulnerabilities,
844 vuln.id.clone(),
845 self.vuln_state.selected,
846 );
847 self.selected_component = Some(comp_id);
848 self.component_tab = ComponentDetailTab::Overview;
849 self.active_tab = ViewTab::Tree;
850 }
851 }
852 ViewTab::Dependencies => {
853 if let Some(node_id) = self.get_selected_dependency_node_id() {
856 self.dependency_state.toggle_expand(&node_id);
857 }
858 }
859 ViewTab::Compliance => {
860 self.ensure_compliance_results();
862 let idx = self.compliance_state.selected_standard;
863 let has_violations = self
864 .compliance_results
865 .as_ref()
866 .and_then(|r| r.get(idx))
867 .is_some_and(|r| !r.violations.is_empty());
868 if has_violations {
869 self.compliance_state.show_detail = !self.compliance_state.show_detail;
870 }
871 }
872 ViewTab::Source => {
873 if self.source_state.view_mode == crate::tui::app_states::SourceViewMode::Tree
875 && let Some(ref tree) = self.source_state.json_tree
876 {
877 let mut items = Vec::new();
878 crate::tui::shared::source::flatten_json_tree(
879 tree,
880 "",
881 0,
882 &self.source_state.expanded,
883 &mut items,
884 true,
885 &[],
886 self.source_state.sort_mode,
887 );
888 if let Some(item) = items.get(self.source_state.selected)
889 && item.is_expandable
890 {
891 let node_id = item.node_id.clone();
892 self.source_state.toggle_expand(&node_id);
893 }
894 }
895 }
896 ViewTab::Quality => {
897 if self.quality_state.view_mode == QualityViewMode::Summary {
898 self.quality_state.view_mode = QualityViewMode::Recommendations;
900 }
901 }
902 ViewTab::Licenses | ViewTab::Overview => {}
903 }
904 }
905
906 pub fn handle_source_map_enter(&mut self) {
908 let Some(tree) = &self.source_state.json_tree else {
910 return;
911 };
912 let Some(children) = tree.children() else {
913 return;
914 };
915
916 let expandable: Vec<_> = children.iter().filter(|c| c.is_expandable()).collect();
918
919 let target = match expandable.get(self.source_state.map_selected) {
920 Some(t) => *t,
921 None => return,
922 };
923
924 let target_id = target.node_id("root");
925
926 match self.source_state.view_mode {
927 crate::tui::app_states::SourceViewMode::Tree => {
928 if !self.source_state.expanded.contains(&target_id) {
930 self.source_state.expanded.insert(target_id.clone());
931 }
932 let mut items = Vec::new();
934 crate::tui::shared::source::flatten_json_tree(
935 tree,
936 "",
937 0,
938 &self.source_state.expanded,
939 &mut items,
940 true,
941 &[],
942 self.source_state.sort_mode,
943 );
944 if let Some(idx) = items.iter().position(|item| item.node_id == target_id) {
945 self.source_state.selected = idx;
946 self.source_state.scroll_offset = idx.saturating_sub(2);
947 }
948 }
949 crate::tui::app_states::SourceViewMode::Raw => {
950 let key = match target {
952 crate::tui::app_states::source::JsonTreeNode::Object { key, .. }
953 | crate::tui::app_states::source::JsonTreeNode::Array { key, .. }
954 | crate::tui::app_states::source::JsonTreeNode::Leaf { key, .. } => key.clone(),
955 };
956 for (i, line) in self.source_state.raw_lines.iter().enumerate() {
958 let search = format!("\"{key}\":");
959 if line.contains(&search) && line.starts_with(" ") && !line.starts_with(" ")
960 {
961 self.source_state.selected = i;
962 self.source_state.scroll_offset = i.saturating_sub(2);
963 break;
964 }
965 }
966 }
967 }
968
969 self.focus_panel = FocusPanel::Left;
971 }
972
973 #[must_use]
976 pub fn get_map_context_component_id(&self) -> Option<String> {
977 let tree = self.source_state.json_tree.as_ref()?;
978 let mut items = Vec::new();
979 crate::tui::shared::source::flatten_json_tree(
980 tree,
981 "",
982 0,
983 &self.source_state.expanded,
984 &mut items,
985 true,
986 &[],
987 self.source_state.sort_mode,
988 );
989 let item = items.get(self.source_state.selected)?;
990 let parts: Vec<&str> = item.node_id.split('.').collect();
991 if parts.len() < 3 || parts[1] != "components" {
992 return None;
993 }
994 let idx_part = parts[2];
995 if idx_part.starts_with('[') && idx_part.ends_with(']') {
996 let idx: usize = idx_part[1..idx_part.len() - 1].parse().ok()?;
997 let (canon_id, _) = self.sbom.components.iter().nth(idx)?;
998 Some(canon_id.value().to_string())
999 } else {
1000 None
1001 }
1002 }
1003
1004 #[must_use]
1006 pub fn get_selected_dependency_node_id(&self) -> Option<String> {
1007 let mut visible_nodes = Vec::new();
1009 self.collect_visible_dependency_nodes(&mut visible_nodes);
1010 visible_nodes.get(self.dependency_state.selected).cloned()
1011 }
1012
1013 fn collect_visible_dependency_nodes(&self, nodes: &mut Vec<String>) {
1015 let mut edges: std::collections::HashMap<String, Vec<String>> =
1017 std::collections::HashMap::new();
1018 let mut has_parent: std::collections::HashSet<String> = std::collections::HashSet::new();
1019 let mut all_nodes: std::collections::HashSet<String> = std::collections::HashSet::new();
1020
1021 for (id, _) in &self.sbom.components {
1022 all_nodes.insert(id.value().to_string());
1023 }
1024
1025 for edge in &self.sbom.edges {
1026 let from = edge.from.value().to_string();
1027 let to = edge.to.value().to_string();
1028 if all_nodes.contains(&from) && all_nodes.contains(&to) {
1029 edges.entry(from).or_default().push(to.clone());
1030 has_parent.insert(to);
1031 }
1032 }
1033
1034 let mut roots: Vec<_> = all_nodes
1036 .iter()
1037 .filter(|id| !has_parent.contains(*id))
1038 .cloned()
1039 .collect();
1040 roots.sort();
1041
1042 for root in roots {
1044 self.collect_dep_nodes_recursive(
1045 &root,
1046 &edges,
1047 nodes,
1048 &mut std::collections::HashSet::new(),
1049 );
1050 }
1051 }
1052
1053 fn collect_dep_nodes_recursive(
1054 &self,
1055 node_id: &str,
1056 edges: &std::collections::HashMap<String, Vec<String>>,
1057 nodes: &mut Vec<String>,
1058 visited: &mut std::collections::HashSet<String>,
1059 ) {
1060 if visited.contains(node_id) {
1061 return;
1062 }
1063 visited.insert(node_id.to_string());
1064 nodes.push(node_id.to_string());
1065
1066 if self.dependency_state.is_expanded(node_id)
1067 && let Some(children) = edges.get(node_id)
1068 {
1069 for child in children {
1070 self.collect_dep_nodes_recursive(child, edges, nodes, visited);
1071 }
1072 }
1073 }
1074
1075 fn get_selected_tree_node(&self) -> Option<SelectedTreeNode> {
1077 let nodes = self.build_tree_nodes();
1078 let mut flat_items = Vec::new();
1079 flatten_tree_for_selection(&nodes, &self.tree_state, &mut flat_items);
1080
1081 flat_items.get(self.tree_state.selected).cloned()
1082 }
1083
1084 #[must_use]
1086 pub fn build_tree_nodes(&self) -> Vec<crate::tui::widgets::TreeNode> {
1087 match self.tree_group_by {
1088 TreeGroupBy::Ecosystem => self.build_ecosystem_tree(),
1089 TreeGroupBy::License => self.build_license_tree(),
1090 TreeGroupBy::VulnStatus => self.build_vuln_status_tree(),
1091 TreeGroupBy::ComponentType => self.build_type_tree(),
1092 TreeGroupBy::Flat => self.build_flat_tree(),
1093 }
1094 }
1095
1096 fn build_ecosystem_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1097 use crate::tui::widgets::TreeNode;
1098
1099 let mut ecosystem_map: HashMap<String, Vec<&Component>> = HashMap::new();
1100
1101 for comp in self.sbom.components.values() {
1102 if !self.matches_filter(comp) {
1103 continue;
1104 }
1105 let eco = comp
1106 .ecosystem
1107 .as_ref()
1108 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1109 ecosystem_map.entry(eco).or_default().push(comp);
1110 }
1111
1112 let mut groups: Vec<TreeNode> = ecosystem_map
1113 .into_iter()
1114 .map(|(eco, mut components)| {
1115 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1116 components.sort_by(|a, b| a.name.cmp(&b.name));
1117 let children: Vec<TreeNode> = components
1118 .into_iter()
1119 .map(|c| TreeNode::Component {
1120 id: c.canonical_id.value().to_string(),
1121 name: c.name.clone(),
1122 version: c.version.clone(),
1123 vuln_count: c.vulnerabilities.len(),
1124 max_severity: get_max_severity(c),
1125 component_type: Some(
1126 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1127 ),
1128 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1129 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1130 })
1131 .collect();
1132 let count = children.len();
1133 TreeNode::Group {
1134 id: format!("eco:{eco}"),
1135 label: eco,
1136 children,
1137 item_count: count,
1138 vuln_count,
1139 }
1140 })
1141 .collect();
1142
1143 groups.sort_by(|a, b| match (a, b) {
1144 (
1145 TreeNode::Group {
1146 item_count: ac,
1147 label: al,
1148 ..
1149 },
1150 TreeNode::Group {
1151 item_count: bc,
1152 label: bl,
1153 ..
1154 },
1155 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1156 _ => std::cmp::Ordering::Equal,
1157 });
1158
1159 groups
1160 }
1161
1162 fn build_license_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1163 use crate::tui::widgets::TreeNode;
1164
1165 let mut license_map: HashMap<String, Vec<&Component>> = HashMap::new();
1166
1167 for comp in self.sbom.components.values() {
1168 if !self.matches_filter(comp) {
1169 continue;
1170 }
1171 let license = if comp.licenses.declared.is_empty() {
1172 "Unknown".to_string()
1173 } else {
1174 comp.licenses.declared[0].expression.clone()
1175 };
1176 license_map.entry(license).or_default().push(comp);
1177 }
1178
1179 let mut groups: Vec<TreeNode> = license_map
1180 .into_iter()
1181 .map(|(license, mut components)| {
1182 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1183 components.sort_by(|a, b| a.name.cmp(&b.name));
1184 let children: Vec<TreeNode> = components
1185 .into_iter()
1186 .map(|c| TreeNode::Component {
1187 id: c.canonical_id.value().to_string(),
1188 name: c.name.clone(),
1189 version: c.version.clone(),
1190 vuln_count: c.vulnerabilities.len(),
1191 max_severity: get_max_severity(c),
1192 component_type: Some(
1193 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1194 ),
1195 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1196 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1197 })
1198 .collect();
1199 let count = children.len();
1200 TreeNode::Group {
1201 id: format!("lic:{license}"),
1202 label: license,
1203 children,
1204 item_count: count,
1205 vuln_count,
1206 }
1207 })
1208 .collect();
1209
1210 groups.sort_by(|a, b| match (a, b) {
1211 (
1212 TreeNode::Group {
1213 item_count: ac,
1214 label: al,
1215 ..
1216 },
1217 TreeNode::Group {
1218 item_count: bc,
1219 label: bl,
1220 ..
1221 },
1222 ) => bc.cmp(ac).then_with(|| al.cmp(bl)),
1223 _ => std::cmp::Ordering::Equal,
1224 });
1225
1226 groups
1227 }
1228
1229 fn build_vuln_status_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1230 use super::severity::severity_category;
1231 use crate::tui::widgets::TreeNode;
1232
1233 let mut critical_comps = Vec::new();
1234 let mut high_comps = Vec::new();
1235 let mut other_vuln_comps = Vec::new();
1236 let mut clean_comps = Vec::new();
1237
1238 for comp in self.sbom.components.values() {
1239 if !self.matches_filter(comp) {
1240 continue;
1241 }
1242
1243 match severity_category(&comp.vulnerabilities) {
1244 "critical" => critical_comps.push(comp),
1245 "high" => high_comps.push(comp),
1246 "clean" => clean_comps.push(comp),
1247 _ => other_vuln_comps.push(comp),
1248 }
1249 }
1250
1251 let build_group = |label: &str,
1252 id: &str,
1253 comps: Vec<&Component>,
1254 bookmarked: &HashSet<String>|
1255 -> TreeNode {
1256 let vuln_count: usize = comps.iter().map(|c| c.vulnerabilities.len()).sum();
1257 let children: Vec<TreeNode> = comps
1258 .into_iter()
1259 .map(|c| TreeNode::Component {
1260 id: c.canonical_id.value().to_string(),
1261 name: c.name.clone(),
1262 version: c.version.clone(),
1263 vuln_count: c.vulnerabilities.len(),
1264 max_severity: get_max_severity(c),
1265 component_type: Some(
1266 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1267 ),
1268 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1269 is_bookmarked: bookmarked.contains(c.canonical_id.value()),
1270 })
1271 .collect();
1272 let count = children.len();
1273 TreeNode::Group {
1274 id: id.to_string(),
1275 label: label.to_string(),
1276 children,
1277 item_count: count,
1278 vuln_count,
1279 }
1280 };
1281
1282 let mut groups = Vec::new();
1283 if !critical_comps.is_empty() {
1284 groups.push(build_group(
1285 "Critical",
1286 "vuln:critical",
1287 critical_comps,
1288 &self.bookmarked,
1289 ));
1290 }
1291 if !high_comps.is_empty() {
1292 groups.push(build_group(
1293 "High",
1294 "vuln:high",
1295 high_comps,
1296 &self.bookmarked,
1297 ));
1298 }
1299 if !other_vuln_comps.is_empty() {
1300 groups.push(build_group(
1301 "Other Vulnerabilities",
1302 "vuln:other",
1303 other_vuln_comps,
1304 &self.bookmarked,
1305 ));
1306 }
1307 if !clean_comps.is_empty() {
1308 groups.push(build_group(
1309 "No Vulnerabilities",
1310 "vuln:clean",
1311 clean_comps,
1312 &self.bookmarked,
1313 ));
1314 }
1315
1316 groups
1317 }
1318
1319 fn build_type_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1320 use crate::tui::widgets::TreeNode;
1321
1322 let mut type_map: HashMap<&'static str, Vec<&Component>> = HashMap::new();
1323
1324 for comp in self.sbom.components.values() {
1325 if !self.matches_filter(comp) {
1326 continue;
1327 }
1328 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1329 type_map.entry(comp_type).or_default().push(comp);
1330 }
1331
1332 let type_order = vec![
1334 ("lib", "Libraries"),
1335 ("bin", "Binaries"),
1336 ("cert", "Certificates"),
1337 ("fs", "Filesystems"),
1338 ("file", "Other Files"),
1339 ];
1340
1341 let mut groups = Vec::new();
1342 for (type_key, type_label) in type_order {
1343 if let Some(mut components) = type_map.remove(type_key) {
1344 if components.is_empty() {
1345 continue;
1346 }
1347 let vuln_count: usize = components.iter().map(|c| c.vulnerabilities.len()).sum();
1348 components.sort_by(|a, b| a.name.cmp(&b.name));
1349 let children: Vec<TreeNode> = components
1350 .into_iter()
1351 .map(|c| TreeNode::Component {
1352 id: c.canonical_id.value().to_string(),
1353 name: c.name.clone(),
1354 version: c.version.clone(),
1355 vuln_count: c.vulnerabilities.len(),
1356 max_severity: get_max_severity(c),
1357 component_type: Some(type_key.to_string()),
1358 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1359 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1360 })
1361 .collect();
1362 let count = children.len();
1363 groups.push(TreeNode::Group {
1364 id: format!("type:{type_key}"),
1365 label: type_label.to_string(),
1366 children,
1367 item_count: count,
1368 vuln_count,
1369 });
1370 }
1371 }
1372
1373 groups
1374 }
1375
1376 fn build_flat_tree(&self) -> Vec<crate::tui::widgets::TreeNode> {
1377 use crate::tui::widgets::TreeNode;
1378
1379 self.sbom
1380 .components
1381 .values()
1382 .filter(|c| self.matches_filter(c))
1383 .map(|c| TreeNode::Component {
1384 id: c.canonical_id.value().to_string(),
1385 name: c.name.clone(),
1386 version: c.version.clone(),
1387 vuln_count: c.vulnerabilities.len(),
1388 max_severity: get_max_severity(c),
1389 component_type: Some(
1390 crate::tui::widgets::detect_component_type(&c.name).to_string(),
1391 ),
1392 ecosystem: c.ecosystem.as_ref().map(std::string::ToString::to_string),
1393 is_bookmarked: self.bookmarked.contains(c.canonical_id.value()),
1394 })
1395 .collect()
1396 }
1397
1398 fn matches_filter(&self, comp: &Component) -> bool {
1399 use super::severity::severity_matches;
1400
1401 let passes_filter = match self.tree_filter {
1403 TreeFilter::All => true,
1404 TreeFilter::HasVulnerabilities => !comp.vulnerabilities.is_empty(),
1405 TreeFilter::Critical => comp
1406 .vulnerabilities
1407 .iter()
1408 .any(|v| severity_matches(v.severity.as_ref(), "critical")),
1409 TreeFilter::Bookmarked => self.bookmarked.contains(comp.canonical_id.value()),
1410 };
1411
1412 if !passes_filter {
1413 return false;
1414 }
1415
1416 if self.tree_search_query.is_empty() {
1418 return true;
1419 }
1420
1421 let query_lower = self.tree_search_query.to_lowercase();
1422 let name_lower = comp.name.to_lowercase();
1423
1424 if name_lower.contains(&query_lower) {
1426 return true;
1427 }
1428
1429 if let Some(ref version) = comp.version
1431 && version.to_lowercase().contains(&query_lower)
1432 {
1433 return true;
1434 }
1435
1436 if let Some(ref eco) = comp.ecosystem
1438 && eco.to_string().to_lowercase().contains(&query_lower)
1439 {
1440 return true;
1441 }
1442
1443 false
1444 }
1445
1446 fn tree_group_id_for_component(&self, comp: &Component) -> Option<String> {
1447 match self.tree_group_by {
1448 TreeGroupBy::Ecosystem => {
1449 let eco = comp
1450 .ecosystem
1451 .as_ref()
1452 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
1453 Some(format!("eco:{eco}"))
1454 }
1455 TreeGroupBy::License => {
1456 let license = if comp.licenses.declared.is_empty() {
1457 "Unknown".to_string()
1458 } else {
1459 comp.licenses.declared[0].expression.clone()
1460 };
1461 Some(format!("lic:{license}"))
1462 }
1463 TreeGroupBy::VulnStatus => {
1464 use super::severity::severity_category;
1465 let group = match severity_category(&comp.vulnerabilities) {
1466 "critical" => "vuln:critical",
1467 "high" => "vuln:high",
1468 "clean" => "vuln:clean",
1469 _ => "vuln:other",
1470 };
1471 Some(group.to_string())
1472 }
1473 TreeGroupBy::ComponentType => {
1474 let comp_type = crate::tui::widgets::detect_component_type(&comp.name);
1475 Some(format!("type:{comp_type}"))
1476 }
1477 TreeGroupBy::Flat => None,
1478 }
1479 }
1480}
1481
1482fn get_max_severity(comp: &Component) -> Option<String> {
1484 super::severity::max_severity_from_vulns(&comp.vulnerabilities)
1485}
1486
1487#[derive(Debug, Clone)]
1489enum SelectedTreeNode {
1490 Group(String),
1491 Component(String),
1492}
1493
1494fn flatten_tree_for_selection(
1495 nodes: &[crate::tui::widgets::TreeNode],
1496 state: &TreeState,
1497 items: &mut Vec<SelectedTreeNode>,
1498) {
1499 use crate::tui::widgets::TreeNode;
1500
1501 for node in nodes {
1502 match node {
1503 TreeNode::Group { id, children, .. } => {
1504 items.push(SelectedTreeNode::Group(id.clone()));
1505 if state.is_expanded(id) {
1506 flatten_tree_for_selection(children, state, items);
1507 }
1508 }
1509 TreeNode::Component { id, .. } => {
1510 items.push(SelectedTreeNode::Component(id.clone()));
1511 }
1512 }
1513 }
1514}
1515
1516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1518pub enum ViewTab {
1519 Overview,
1521 Tree,
1523 Vulnerabilities,
1525 Licenses,
1527 Dependencies,
1529 Quality,
1531 Compliance,
1533 Source,
1535}
1536
1537impl ViewTab {
1538 #[must_use]
1539 pub const fn title(&self) -> &'static str {
1540 match self {
1541 Self::Overview => "Overview",
1542 Self::Tree => "Components",
1543 Self::Vulnerabilities => "Vulnerabilities",
1544 Self::Licenses => "Licenses",
1545 Self::Dependencies => "Dependencies",
1546 Self::Quality => "Quality",
1547 Self::Compliance => "Compliance",
1548 Self::Source => "Source",
1549 }
1550 }
1551
1552 #[must_use]
1553 pub const fn shortcut(&self) -> &'static str {
1554 match self {
1555 Self::Overview => "1",
1556 Self::Tree => "2",
1557 Self::Vulnerabilities => "3",
1558 Self::Licenses => "4",
1559 Self::Dependencies => "5",
1560 Self::Quality => "6",
1561 Self::Compliance => "7",
1562 Self::Source => "8",
1563 }
1564 }
1565
1566 #[must_use]
1568 pub const fn as_str(&self) -> &'static str {
1569 match self {
1570 Self::Overview => "overview",
1571 Self::Tree => "tree",
1572 Self::Vulnerabilities => "vulnerabilities",
1573 Self::Licenses => "licenses",
1574 Self::Dependencies => "dependencies",
1575 Self::Quality => "quality",
1576 Self::Compliance => "compliance",
1577 Self::Source => "source",
1578 }
1579 }
1580
1581 #[must_use]
1583 pub fn from_str_opt(s: &str) -> Option<Self> {
1584 match s {
1585 "overview" => Some(Self::Overview),
1586 "tree" => Some(Self::Tree),
1587 "vulnerabilities" => Some(Self::Vulnerabilities),
1588 "licenses" => Some(Self::Licenses),
1589 "dependencies" => Some(Self::Dependencies),
1590 "quality" => Some(Self::Quality),
1591 "compliance" => Some(Self::Compliance),
1592 "source" => Some(Self::Source),
1593 _ => None,
1594 }
1595 }
1596}
1597
1598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1600pub enum TreeGroupBy {
1601 Ecosystem,
1602 License,
1603 VulnStatus,
1604 ComponentType,
1605 Flat,
1606}
1607
1608impl TreeGroupBy {
1609 #[must_use]
1610 pub const fn label(&self) -> &'static str {
1611 match self {
1612 Self::Ecosystem => "Ecosystem",
1613 Self::License => "License",
1614 Self::VulnStatus => "Vuln Status",
1615 Self::ComponentType => "Type",
1616 Self::Flat => "Flat List",
1617 }
1618 }
1619}
1620
1621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1623pub enum TreeFilter {
1624 All,
1625 HasVulnerabilities,
1626 Critical,
1627 Bookmarked,
1628}
1629
1630impl TreeFilter {
1631 #[must_use]
1632 pub const fn label(&self) -> &'static str {
1633 match self {
1634 Self::All => "All",
1635 Self::HasVulnerabilities => "Has Vulns",
1636 Self::Critical => "Critical",
1637 Self::Bookmarked => "Bookmarked",
1638 }
1639 }
1640}
1641
1642#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1644pub(crate) enum ComponentDetailTab {
1645 #[default]
1646 Overview,
1647 Identifiers,
1648 Vulnerabilities,
1649 Dependencies,
1650}
1651
1652impl ComponentDetailTab {
1653 pub const fn title(self) -> &'static str {
1654 match self {
1655 Self::Overview => "Overview",
1656 Self::Identifiers => "Identifiers",
1657 Self::Vulnerabilities => "Vulnerabilities",
1658 Self::Dependencies => "Dependencies",
1659 }
1660 }
1661
1662 pub const fn shortcut(self) -> &'static str {
1663 match self {
1664 Self::Overview => "1",
1665 Self::Identifiers => "2",
1666 Self::Vulnerabilities => "3",
1667 Self::Dependencies => "4",
1668 }
1669 }
1670
1671 pub const fn all() -> [Self; 4] {
1672 [
1673 Self::Overview,
1674 Self::Identifiers,
1675 Self::Vulnerabilities,
1676 Self::Dependencies,
1677 ]
1678 }
1679}
1680
1681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1683pub(crate) enum FocusPanel {
1684 Left,
1685 Right,
1686}
1687
1688#[derive(Debug, Clone)]
1690pub(crate) struct VulnExplorerState {
1691 pub selected: usize,
1692 pub total: usize,
1693 pub scroll_offset: usize,
1694 pub group_by: VulnGroupBy,
1695 pub sort_by: VulnSortBy,
1696 pub filter_severity: Option<String>,
1697 pub deduplicate: bool,
1699 pub search_query: String,
1701 pub search_active: bool,
1703 pub detail_scroll: u16,
1705 pub expanded_groups: HashSet<String>,
1707 cache_key: Option<VulnCacheKey>,
1709 pub cached_data: Option<super::views::VulnCacheRef>,
1711}
1712
1713#[derive(Debug, Clone, PartialEq, Eq)]
1715struct VulnCacheKey {
1716 filter_severity: Option<String>,
1717 deduplicate: bool,
1718 sort_by: VulnSortBy,
1719 search_query: String,
1720}
1721
1722impl VulnExplorerState {
1723 pub fn new() -> Self {
1724 Self {
1725 selected: 0,
1726 total: 0,
1727 scroll_offset: 0,
1728 group_by: VulnGroupBy::Severity,
1729 sort_by: VulnSortBy::Severity,
1730 filter_severity: None,
1731 deduplicate: true,
1732 search_query: String::new(),
1733 search_active: false,
1734 detail_scroll: 0,
1735 expanded_groups: HashSet::new(),
1736 cache_key: None,
1737 cached_data: None,
1738 }
1739 }
1740
1741 fn current_cache_key(&self) -> VulnCacheKey {
1743 VulnCacheKey {
1744 filter_severity: self.filter_severity.clone(),
1745 deduplicate: self.deduplicate,
1746 sort_by: self.sort_by,
1747 search_query: self.search_query.clone(),
1748 }
1749 }
1750
1751 pub fn is_cache_valid(&self) -> bool {
1753 self.cache_key.as_ref() == Some(&self.current_cache_key()) && self.cached_data.is_some()
1754 }
1755
1756 pub fn set_cache(&mut self, cache: super::views::VulnCache) {
1758 self.cache_key = Some(self.current_cache_key());
1759 self.cached_data = Some(std::sync::Arc::new(cache));
1760 }
1761
1762 pub fn invalidate_cache(&mut self) {
1764 self.cache_key = None;
1765 self.cached_data = None;
1766 }
1767
1768 pub const fn select_next(&mut self) {
1769 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
1770 self.selected += 1;
1771 self.detail_scroll = 0;
1772 }
1773 }
1774
1775 pub const fn select_prev(&mut self) {
1776 if self.selected > 0 {
1777 self.selected -= 1;
1778 self.detail_scroll = 0;
1779 }
1780 }
1781
1782 pub const fn detail_scroll_down(&mut self) {
1784 self.detail_scroll = self.detail_scroll.saturating_add(1);
1785 }
1786
1787 pub const fn detail_scroll_up(&mut self) {
1789 self.detail_scroll = self.detail_scroll.saturating_sub(1);
1790 }
1791
1792 pub const fn clamp_selection(&mut self) {
1794 if self.total == 0 {
1795 self.selected = 0;
1796 } else if self.selected >= self.total {
1797 self.selected = self.total.saturating_sub(1);
1798 }
1799 }
1800
1801 pub fn toggle_group(&mut self) {
1802 self.group_by = match self.group_by {
1803 VulnGroupBy::Severity => VulnGroupBy::Component,
1804 VulnGroupBy::Component => VulnGroupBy::Flat,
1805 VulnGroupBy::Flat => VulnGroupBy::Severity,
1806 };
1807 self.selected = 0;
1808 self.expanded_groups.clear();
1809 self.invalidate_cache();
1810 }
1811
1812 pub fn toggle_vuln_group(&mut self, group_id: &str) {
1814 if self.expanded_groups.contains(group_id) {
1815 self.expanded_groups.remove(group_id);
1816 } else {
1817 self.expanded_groups.insert(group_id.to_string());
1818 }
1819 }
1820
1821 pub fn toggle_filter(&mut self) {
1822 self.filter_severity = match &self.filter_severity {
1823 None => Some("critical".to_string()),
1824 Some(s) if s == "critical" => Some("high".to_string()),
1825 Some(s) if s == "high" => Some("medium".to_string()),
1826 Some(s) if s == "medium" => Some("low".to_string()),
1827 Some(s) if s == "low" => Some("unknown".to_string()),
1828 Some(s) if s == "unknown" => None,
1829 _ => None,
1830 };
1831 self.selected = 0;
1832 self.invalidate_cache();
1833 }
1834
1835 pub fn toggle_sort(&mut self) {
1836 self.sort_by = self.sort_by.next();
1837 self.selected = 0;
1838 self.invalidate_cache();
1839 }
1840
1841 pub fn toggle_deduplicate(&mut self) {
1842 self.deduplicate = !self.deduplicate;
1843 self.selected = 0;
1844 self.invalidate_cache();
1845 }
1846
1847 pub fn start_vuln_search(&mut self) {
1849 self.search_active = true;
1850 self.search_query.clear();
1851 }
1852
1853 pub const fn stop_vuln_search(&mut self) {
1855 self.search_active = false;
1856 }
1857
1858 pub fn clear_vuln_search(&mut self) {
1860 self.search_active = false;
1861 self.search_query.clear();
1862 self.selected = 0;
1863 self.invalidate_cache();
1864 }
1865
1866 pub fn search_push(&mut self, c: char) {
1868 self.search_query.push(c);
1869 self.selected = 0;
1870 self.invalidate_cache();
1871 }
1872
1873 pub fn search_pop(&mut self) {
1875 self.search_query.pop();
1876 self.selected = 0;
1877 self.invalidate_cache();
1878 }
1879
1880 pub fn get_selected<'a>(
1882 &self,
1883 sbom: &'a NormalizedSbom,
1884 ) -> Option<(String, &'a VulnerabilityRef)> {
1885 let mut idx = 0;
1886 for (comp_id, comp) in &sbom.components {
1887 for vuln in &comp.vulnerabilities {
1888 if let Some(ref filter) = self.filter_severity {
1889 let sev = vuln.severity.as_ref().map(|s| s.to_string().to_lowercase());
1890 if sev.as_deref() != Some(filter) {
1891 continue;
1892 }
1893 }
1894 if idx == self.selected {
1895 return Some((comp_id.value().to_string(), vuln));
1896 }
1897 idx += 1;
1898 }
1899 }
1900 None
1901 }
1902}
1903
1904impl Default for VulnExplorerState {
1905 fn default() -> Self {
1906 Self::new()
1907 }
1908}
1909
1910impl ListNavigation for VulnExplorerState {
1911 fn selected(&self) -> usize {
1912 self.selected
1913 }
1914
1915 fn set_selected(&mut self, idx: usize) {
1916 self.selected = idx;
1917 }
1918
1919 fn total(&self) -> usize {
1920 self.total
1921 }
1922
1923 fn set_total(&mut self, total: usize) {
1924 self.total = total;
1925 }
1926}
1927
1928#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1930pub(crate) enum QualityViewMode {
1931 #[default]
1932 Summary,
1933 Breakdown,
1934 Metrics,
1935 Recommendations,
1936}
1937
1938pub(crate) struct QualityViewState {
1940 pub view_mode: QualityViewMode,
1941 pub selected_recommendation: usize,
1942 pub total_recommendations: usize,
1943 pub scroll_offset: usize,
1944}
1945
1946impl QualityViewState {
1947 pub const fn new(total_recommendations: usize) -> Self {
1948 Self {
1949 view_mode: QualityViewMode::Summary,
1950 selected_recommendation: 0,
1951 total_recommendations,
1952 scroll_offset: 0,
1953 }
1954 }
1955
1956 pub const fn toggle_view(&mut self) {
1957 self.view_mode = match self.view_mode {
1958 QualityViewMode::Summary => QualityViewMode::Breakdown,
1959 QualityViewMode::Breakdown => QualityViewMode::Metrics,
1960 QualityViewMode::Metrics => QualityViewMode::Recommendations,
1961 QualityViewMode::Recommendations => QualityViewMode::Summary,
1962 };
1963 self.selected_recommendation = 0;
1964 self.scroll_offset = 0;
1965 }
1966}
1967
1968impl ListNavigation for QualityViewState {
1969 fn selected(&self) -> usize {
1970 self.selected_recommendation
1971 }
1972
1973 fn set_selected(&mut self, idx: usize) {
1974 self.selected_recommendation = idx;
1975 }
1976
1977 fn total(&self) -> usize {
1978 self.total_recommendations
1979 }
1980
1981 fn set_total(&mut self, total: usize) {
1982 self.total_recommendations = total;
1983 }
1984}
1985
1986impl Default for QualityViewState {
1987 fn default() -> Self {
1988 Self::new(0)
1989 }
1990}
1991
1992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1994pub(crate) enum VulnGroupBy {
1995 Severity,
1996 Component,
1997 Flat,
1998}
1999
2000#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2002pub(crate) enum VulnSortBy {
2003 Severity,
2004 Cvss,
2005 CveId,
2006 Component,
2007}
2008
2009impl VulnSortBy {
2010 pub const fn next(self) -> Self {
2011 match self {
2012 Self::Severity => Self::Cvss,
2013 Self::Cvss => Self::CveId,
2014 Self::CveId => Self::Component,
2015 Self::Component => Self::Severity,
2016 }
2017 }
2018
2019 pub const fn label(self) -> &'static str {
2020 match self {
2021 Self::Severity => "Severity",
2022 Self::Cvss => "CVSS",
2023 Self::CveId => "CVE ID",
2024 Self::Component => "Component",
2025 }
2026 }
2027}
2028
2029#[derive(Debug, Clone)]
2031pub(crate) struct LicenseViewState {
2032 pub selected: usize,
2033 pub total: usize,
2034 pub scroll_offset: usize,
2035 pub group_by: LicenseGroupBy,
2036 pub component_scroll: usize,
2038 pub component_total: usize,
2040}
2041
2042impl LicenseViewState {
2043 pub const fn new() -> Self {
2044 Self {
2045 selected: 0,
2046 total: 0,
2047 scroll_offset: 0,
2048 group_by: LicenseGroupBy::License,
2049 component_scroll: 0,
2050 component_total: 0,
2051 }
2052 }
2053
2054 pub const fn scroll_components_up(&mut self) {
2056 if self.component_scroll > 0 {
2057 self.component_scroll -= 1;
2058 }
2059 }
2060
2061 pub const fn scroll_components_down(&mut self, visible_count: usize) {
2063 if self.component_total > visible_count
2064 && self.component_scroll < self.component_total - visible_count
2065 {
2066 self.component_scroll += 1;
2067 }
2068 }
2069
2070 pub const fn reset_component_scroll(&mut self) {
2072 self.component_scroll = 0;
2073 }
2074
2075 pub const fn select_next(&mut self) {
2076 if self.total > 0 && self.selected < self.total.saturating_sub(1) {
2077 self.selected += 1;
2078 self.reset_component_scroll();
2079 }
2080 }
2081
2082 pub const fn select_prev(&mut self) {
2083 if self.selected > 0 {
2084 self.selected -= 1;
2085 self.reset_component_scroll();
2086 }
2087 }
2088
2089 pub const fn clamp_selection(&mut self) {
2091 if self.total == 0 {
2092 self.selected = 0;
2093 } else if self.selected >= self.total {
2094 self.selected = self.total.saturating_sub(1);
2095 }
2096 }
2097
2098 pub const fn toggle_group(&mut self) {
2099 self.group_by = match self.group_by {
2100 LicenseGroupBy::License => LicenseGroupBy::Category,
2101 LicenseGroupBy::Category => LicenseGroupBy::License,
2102 };
2103 self.selected = 0;
2104 self.reset_component_scroll();
2105 }
2106}
2107
2108impl Default for LicenseViewState {
2109 fn default() -> Self {
2110 Self::new()
2111 }
2112}
2113
2114impl ListNavigation for LicenseViewState {
2115 fn selected(&self) -> usize {
2116 self.selected
2117 }
2118
2119 fn set_selected(&mut self, idx: usize) {
2120 self.selected = idx;
2121 }
2122
2123 fn total(&self) -> usize {
2124 self.total
2125 }
2126
2127 fn set_total(&mut self, total: usize) {
2128 self.total = total;
2129 }
2130}
2131
2132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2134pub(crate) enum LicenseGroupBy {
2135 License,
2136 Category,
2137}
2138
2139#[derive(Debug, Clone)]
2141pub(crate) struct DependencyViewState {
2142 pub selected: usize,
2144 pub total: usize,
2146 pub expanded: HashSet<String>,
2148 pub scroll_offset: usize,
2150 pub search_query: String,
2152 pub search_active: bool,
2154 pub detail_scroll: u16,
2156 pub roots_initialized: bool,
2158}
2159
2160impl DependencyViewState {
2161 pub fn new() -> Self {
2162 Self {
2163 selected: 0,
2164 total: 0,
2165 expanded: HashSet::new(),
2166 scroll_offset: 0,
2167 search_query: String::new(),
2168 search_active: false,
2169 detail_scroll: 0,
2170 roots_initialized: false,
2171 }
2172 }
2173
2174 pub fn toggle_expand(&mut self, node_id: &str) {
2175 if self.expanded.contains(node_id) {
2176 self.expanded.remove(node_id);
2177 } else {
2178 self.expanded.insert(node_id.to_string());
2179 }
2180 }
2181
2182 pub fn is_expanded(&self, node_id: &str) -> bool {
2183 self.expanded.contains(node_id)
2184 }
2185
2186 pub fn expand_all(&mut self, all_node_ids: &[String]) {
2187 self.expanded.extend(all_node_ids.iter().cloned());
2188 }
2189
2190 pub fn collapse_all(&mut self) {
2191 self.expanded.clear();
2192 }
2193
2194 pub fn start_search(&mut self) {
2195 self.search_active = true;
2196 }
2197
2198 pub fn stop_search(&mut self) {
2199 self.search_active = false;
2200 }
2201
2202 pub fn clear_search(&mut self) {
2203 self.search_query.clear();
2204 self.search_active = false;
2205 }
2206
2207 pub fn search_push(&mut self, c: char) {
2208 self.search_query.push(c);
2209 }
2210
2211 pub fn search_pop(&mut self) {
2212 self.search_query.pop();
2213 }
2214}
2215
2216impl Default for DependencyViewState {
2217 fn default() -> Self {
2218 Self::new()
2219 }
2220}
2221
2222impl ListNavigation for DependencyViewState {
2223 fn selected(&self) -> usize {
2224 self.selected
2225 }
2226
2227 fn set_selected(&mut self, idx: usize) {
2228 self.selected = idx;
2229 }
2230
2231 fn total(&self) -> usize {
2232 self.total
2233 }
2234
2235 fn set_total(&mut self, total: usize) {
2236 self.total = total;
2237 }
2238}
2239
2240#[derive(Debug, Clone)]
2242pub(crate) struct SearchState {
2243 pub active: bool,
2244 pub query: String,
2245 pub results: Vec<SearchResult>,
2246 pub selected: usize,
2247}
2248
2249impl SearchState {
2250 pub const fn new() -> Self {
2251 Self {
2252 active: false,
2253 query: String::new(),
2254 results: Vec::new(),
2255 selected: 0,
2256 }
2257 }
2258
2259 pub fn push_char(&mut self, c: char) {
2260 self.query.push(c);
2261 }
2262
2263 pub fn pop_char(&mut self) {
2264 self.query.pop();
2265 }
2266
2267 pub fn select_next(&mut self) {
2268 if !self.results.is_empty() && self.selected < self.results.len() - 1 {
2269 self.selected += 1;
2270 }
2271 }
2272
2273 pub const fn select_prev(&mut self) {
2274 if self.selected > 0 {
2275 self.selected -= 1;
2276 }
2277 }
2278}
2279
2280impl Default for SearchState {
2281 fn default() -> Self {
2282 Self::new()
2283 }
2284}
2285
2286#[derive(Debug, Clone)]
2288pub(crate) enum SearchResult {
2289 Component {
2290 id: String,
2291 name: String,
2292 version: Option<String>,
2293 match_field: String,
2294 },
2295 Vulnerability {
2296 id: String,
2297 component_id: String,
2299 component_name: String,
2301 severity: Option<String>,
2302 },
2303}
2304
2305#[derive(Debug, Clone)]
2307pub struct SbomStats {
2308 pub component_count: usize,
2309 pub vuln_count: usize,
2310 pub license_count: usize,
2311 pub ecosystem_counts: HashMap<String, usize>,
2312 pub vuln_by_severity: HashMap<String, usize>,
2313 pub license_counts: HashMap<String, usize>,
2314 pub critical_count: usize,
2315 pub high_count: usize,
2316 pub medium_count: usize,
2317 pub low_count: usize,
2318 pub unknown_count: usize,
2319 pub eol_count: usize,
2320 pub eol_approaching_count: usize,
2321 pub eol_supported_count: usize,
2322 pub eol_security_only_count: usize,
2323 pub eol_enriched: bool,
2324}
2325
2326impl SbomStats {
2327 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
2328 let mut ecosystem_counts: HashMap<String, usize> = HashMap::new();
2329 let mut vuln_by_severity: HashMap<String, usize> = HashMap::new();
2330 let mut license_counts: HashMap<String, usize> = HashMap::new();
2331 let mut vuln_count = 0;
2332 let mut critical_count = 0;
2333 let mut high_count = 0;
2334 let mut medium_count = 0;
2335 let mut low_count = 0;
2336 let mut unknown_count = 0;
2337 let mut eol_count = 0;
2338 let mut eol_approaching_count = 0;
2339 let mut eol_supported_count = 0;
2340 let mut eol_security_only_count = 0;
2341 let mut eol_enriched = false;
2342
2343 for comp in sbom.components.values() {
2344 let eco = comp
2346 .ecosystem
2347 .as_ref()
2348 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
2349 *ecosystem_counts.entry(eco).or_insert(0) += 1;
2350
2351 for lic in &comp.licenses.declared {
2353 *license_counts.entry(lic.expression.clone()).or_insert(0) += 1;
2354 }
2355 if comp.licenses.declared.is_empty() {
2356 *license_counts.entry("Unknown".to_string()).or_insert(0) += 1;
2357 }
2358
2359 for vuln in &comp.vulnerabilities {
2361 vuln_count += 1;
2362 let sev = vuln
2363 .severity
2364 .as_ref()
2365 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string);
2366 *vuln_by_severity.entry(sev.clone()).or_insert(0) += 1;
2367
2368 match sev.to_lowercase().as_str() {
2369 "critical" => critical_count += 1,
2370 "high" => high_count += 1,
2371 "medium" => medium_count += 1,
2372 "low" => low_count += 1,
2373 _ => unknown_count += 1,
2374 }
2375 }
2376
2377 if let Some(eol) = &comp.eol {
2379 use crate::model::EolStatus;
2380 eol_enriched = true;
2381 match eol.status {
2382 EolStatus::EndOfLife => eol_count += 1,
2383 EolStatus::ApproachingEol => eol_approaching_count += 1,
2384 EolStatus::Supported => eol_supported_count += 1,
2385 EolStatus::SecurityOnly => eol_security_only_count += 1,
2386 _ => {}
2387 }
2388 }
2389 }
2390
2391 Self {
2392 component_count: sbom.components.len(),
2393 vuln_count,
2394 license_count: license_counts.len(),
2395 ecosystem_counts,
2396 vuln_by_severity,
2397 license_counts,
2398 critical_count,
2399 high_count,
2400 medium_count,
2401 low_count,
2402 unknown_count,
2403 eol_count,
2404 eol_approaching_count,
2405 eol_supported_count,
2406 eol_security_only_count,
2407 eol_enriched,
2408 }
2409 }
2410}
2411
2412#[derive(Debug, Clone)]
2414pub struct ViewBreadcrumb {
2415 pub tab: ViewTab,
2417 pub label: String,
2419 pub selection_index: usize,
2421}
2422
2423#[derive(Debug, Clone, Default)]
2425pub struct ViewNavigationContext {
2426 pub breadcrumbs: Vec<ViewBreadcrumb>,
2428 pub target_component: Option<String>,
2430 pub target_vulnerability: Option<String>,
2432}
2433
2434impl ViewNavigationContext {
2435 #[must_use]
2436 pub const fn new() -> Self {
2437 Self {
2438 breadcrumbs: Vec::new(),
2439 target_component: None,
2440 target_vulnerability: None,
2441 }
2442 }
2443
2444 pub fn push_breadcrumb(&mut self, tab: ViewTab, label: String, selection_index: usize) {
2446 self.breadcrumbs.push(ViewBreadcrumb {
2447 tab,
2448 label,
2449 selection_index,
2450 });
2451 }
2452
2453 pub fn pop_breadcrumb(&mut self) -> Option<ViewBreadcrumb> {
2455 self.breadcrumbs.pop()
2456 }
2457
2458 pub fn clear_breadcrumbs(&mut self) {
2460 self.breadcrumbs.clear();
2461 }
2462
2463 #[must_use]
2465 pub fn has_history(&self) -> bool {
2466 !self.breadcrumbs.is_empty()
2467 }
2468
2469 #[must_use]
2471 pub fn breadcrumb_trail(&self) -> String {
2472 self.breadcrumbs
2473 .iter()
2474 .map(|b| format!("{}: {}", b.tab.title(), b.label))
2475 .collect::<Vec<_>>()
2476 .join(" > ")
2477 }
2478
2479 pub fn clear_targets(&mut self) {
2481 self.target_component = None;
2482 self.target_vulnerability = None;
2483 }
2484}
2485
2486#[cfg(test)]
2487mod tests {
2488 use super::*;
2489 use crate::model::NormalizedSbom;
2490
2491 #[test]
2492 fn test_view_app_creation() {
2493 let sbom = NormalizedSbom::default();
2494 let mut app = ViewApp::new(sbom, "");
2495 app.active_tab = ViewTab::Overview;
2497 assert_eq!(app.active_tab, ViewTab::Overview);
2498 assert!(!app.should_quit);
2499 }
2500
2501 #[test]
2502 fn test_tab_navigation() {
2503 let sbom = NormalizedSbom::default();
2504 let mut app = ViewApp::new(sbom, "");
2505 app.active_tab = ViewTab::Overview;
2507
2508 app.next_tab();
2509 assert_eq!(app.active_tab, ViewTab::Tree);
2510
2511 app.next_tab();
2512 assert_eq!(app.active_tab, ViewTab::Vulnerabilities);
2513
2514 app.prev_tab();
2515 assert_eq!(app.active_tab, ViewTab::Tree);
2516 }
2517
2518 #[test]
2519 fn test_vuln_state_navigation_with_zero_total() {
2520 let mut state = VulnExplorerState::new();
2522 assert_eq!(state.total, 0);
2523 assert_eq!(state.selected, 0);
2524
2525 state.select_next();
2527 assert_eq!(state.selected, 0);
2528
2529 state.select_prev();
2530 assert_eq!(state.selected, 0);
2531 }
2532
2533 #[test]
2534 fn test_vuln_state_clamp_selection() {
2535 let mut state = VulnExplorerState::new();
2536 state.total = 5;
2537 state.selected = 10; state.clamp_selection();
2540 assert_eq!(state.selected, 4); state.total = 0;
2543 state.clamp_selection();
2544 assert_eq!(state.selected, 0); }
2546
2547 #[test]
2548 fn test_license_state_navigation_with_zero_total() {
2549 let mut state = LicenseViewState::new();
2550 assert_eq!(state.total, 0);
2551 assert_eq!(state.selected, 0);
2552
2553 state.select_next();
2555 assert_eq!(state.selected, 0);
2556
2557 state.select_prev();
2558 assert_eq!(state.selected, 0);
2559 }
2560
2561 #[test]
2562 fn test_license_state_clamp_selection() {
2563 let mut state = LicenseViewState::new();
2564 state.total = 3;
2565 state.selected = 5; state.clamp_selection();
2568 assert_eq!(state.selected, 2); }
2570
2571 #[test]
2572 fn test_dependency_state_navigation() {
2573 let mut state = DependencyViewState::new();
2574 assert_eq!(state.total, 0);
2575 assert_eq!(state.selected, 0);
2576
2577 state.select_next();
2579 assert_eq!(state.selected, 0);
2580
2581 state.total = 5;
2583 state.select_next();
2584 assert_eq!(state.selected, 1);
2585
2586 state.select_next();
2587 state.select_next();
2588 state.select_next();
2589 assert_eq!(state.selected, 4); state.select_next();
2592 assert_eq!(state.selected, 4); state.select_prev();
2595 assert_eq!(state.selected, 3);
2596 }
2597
2598 #[test]
2599 fn test_dependency_state_expand_collapse() {
2600 let mut state = DependencyViewState::new();
2601
2602 assert!(!state.is_expanded("node1"));
2603
2604 state.toggle_expand("node1");
2605 assert!(state.is_expanded("node1"));
2606
2607 state.toggle_expand("node1");
2608 assert!(!state.is_expanded("node1"));
2609 }
2610}