1use crate::diff::{DiffResult, MatrixResult, MultiDiffResult, TimelineResult};
4#[cfg(feature = "enrichment")]
5use crate::enrichment::EnrichmentStats;
6use crate::model::{NormalizedSbom, NormalizedSbomIndex};
7use crate::quality::{ComplianceResult, QualityReport};
8use crate::tui::views::ThresholdTuningState;
9
10#[allow(unused_imports)]
12pub use super::app_states::{
13 AlignmentMode,
15 Breadcrumb,
17 ChangeType,
19 ChangeTypeFilter,
20 ComponentDeepDiveData,
22 ComponentDeepDiveState,
23 ComponentFilter,
25 ComponentSimilarityInfo,
26 ComponentSort,
27 ComponentTargetPresence,
28 ComponentVersionEntry,
29 ComponentVulnInfo,
30 ComponentsState,
31 DependenciesState,
33 DiffSearchResult,
34 DiffSearchState,
35 DiffVulnItem,
37 DiffVulnStatus,
38 GraphChangesState,
40 LicenseGroupBy,
42 LicenseRiskFilter,
43 LicenseSort,
44 LicensesState,
45 MatrixSortBy,
47 MatrixState,
48 MultiDiffState,
50 MultiViewFilterPreset,
51 MultiViewSearchState,
52 MultiViewSortBy,
53 MultiViewType,
55 NavigationContext,
56 QualityState,
58 QualityViewMode,
59 ScrollSyncMode,
60 ShortcutsContext,
62 ShortcutsOverlayState,
63 SideBySideState,
64 SimilarityThreshold,
65 SortDirection,
66 TimelineComponentFilter,
68 TimelineSortBy,
69 TimelineState,
70 ViewSwitcherState,
71 VulnChangeType,
72 VulnFilter,
73 VulnSort,
74 VulnerabilitiesState,
75 sort_component_changes,
76 sort_components,
77};
78
79pub struct TabStates {
84 pub(crate) components: ComponentsState,
85 pub(crate) dependencies: DependenciesState,
86 pub(crate) licenses: LicensesState,
87 pub(crate) vulnerabilities: VulnerabilitiesState,
88 pub(crate) quality: QualityState,
89 pub(crate) graph_changes: GraphChangesState,
90 pub(crate) side_by_side: SideBySideState,
91 pub(crate) diff_compliance: crate::tui::app_states::DiffComplianceState,
92 pub(crate) multi_diff: MultiDiffState,
93 pub(crate) timeline: TimelineState,
94 pub(crate) matrix: MatrixState,
95 pub(crate) source: crate::tui::app_states::SourceDiffState,
96}
97
98pub struct AppOverlays {
102 pub(crate) show_help: bool,
103 pub(crate) show_export: bool,
104 pub(crate) show_legend: bool,
105 pub(crate) search: DiffSearchState,
106 pub(crate) threshold_tuning: ThresholdTuningState,
107 pub(crate) view_switcher: ViewSwitcherState,
108 pub(crate) shortcuts: ShortcutsOverlayState,
109 pub(crate) component_deep_dive: ComponentDeepDiveState,
110}
111
112impl AppOverlays {
113 pub fn new() -> Self {
114 Self {
115 show_help: false,
116 show_export: false,
117 show_legend: false,
118 search: DiffSearchState::new(),
119 threshold_tuning: ThresholdTuningState::default(),
120 view_switcher: ViewSwitcherState::new(),
121 shortcuts: ShortcutsOverlayState::new(),
122 component_deep_dive: ComponentDeepDiveState::new(),
123 }
124 }
125
126 pub const fn toggle_help(&mut self) {
127 self.show_help = !self.show_help;
128 if self.show_help {
129 self.show_export = false;
130 self.show_legend = false;
131 }
132 }
133
134 pub const fn toggle_export(&mut self) {
135 self.show_export = !self.show_export;
136 if self.show_export {
137 self.show_help = false;
138 self.show_legend = false;
139 }
140 }
141
142 pub const fn toggle_legend(&mut self) {
143 self.show_legend = !self.show_legend;
144 if self.show_legend {
145 self.show_help = false;
146 self.show_export = false;
147 }
148 }
149
150 pub const fn close_all(&mut self) {
151 self.show_help = false;
152 self.show_export = false;
153 self.show_legend = false;
154 self.search.active = false;
155 self.threshold_tuning.visible = false;
156 }
157
158 pub const fn has_active(&self) -> bool {
159 self.show_help
160 || self.show_export
161 || self.show_legend
162 || self.search.active
163 || self.threshold_tuning.visible
164 }
165}
166
167pub struct DataContext {
171 pub(crate) diff_result: Option<DiffResult>,
172 pub(crate) old_sbom: Option<NormalizedSbom>,
173 pub(crate) new_sbom: Option<NormalizedSbom>,
174 pub(crate) sbom: Option<NormalizedSbom>,
175 pub(crate) multi_diff_result: Option<MultiDiffResult>,
176 pub(crate) timeline_result: Option<TimelineResult>,
177 pub(crate) matrix_result: Option<MatrixResult>,
178 pub(crate) old_sbom_index: Option<NormalizedSbomIndex>,
179 pub(crate) new_sbom_index: Option<NormalizedSbomIndex>,
180 pub(crate) sbom_index: Option<NormalizedSbomIndex>,
181 pub(crate) old_quality: Option<QualityReport>,
182 pub(crate) new_quality: Option<QualityReport>,
183 pub(crate) quality_report: Option<QualityReport>,
184 pub(crate) old_cra_compliance: Option<ComplianceResult>,
185 pub(crate) new_cra_compliance: Option<ComplianceResult>,
186 pub(crate) old_compliance_results: Option<Vec<ComplianceResult>>,
187 pub(crate) new_compliance_results: Option<Vec<ComplianceResult>>,
188 pub(crate) matching_threshold: f64,
189 #[cfg(feature = "enrichment")]
190 pub(crate) enrichment_stats_old: Option<EnrichmentStats>,
191 #[cfg(feature = "enrichment")]
192 pub(crate) enrichment_stats_new: Option<EnrichmentStats>,
193}
194
195pub struct App {
197 pub(crate) mode: AppMode,
199 pub(crate) active_tab: TabKind,
201 pub(crate) data: DataContext,
203 pub(crate) tabs: TabStates,
205 pub(crate) overlays: AppOverlays,
207 pub(crate) should_quit: bool,
209 pub(crate) status_message: Option<String>,
211 pub(crate) status_sticky: bool,
213 pub(crate) tick: u64,
215 pub(crate) last_export_path: Option<String>,
217 pub(crate) navigation_ctx: NavigationContext,
219 pub(crate) security_cache: crate::tui::security::SecurityAnalysisCache,
221 pub(crate) compliance_state: crate::tui::app_states::PolicyComplianceState,
223 pub(crate) export_template: Option<String>,
225 pub(crate) quality_view: Option<crate::tui::view_states::QualityView>,
231}
232
233impl App {
234 pub fn ensure_compliance_results(&mut self) {
236 if self.data.old_compliance_results.is_none()
237 && let Some(old_sbom) = &self.data.old_sbom
238 {
239 self.data.old_compliance_results = Some(
240 crate::quality::ComplianceLevel::all()
241 .iter()
242 .map(|level| crate::quality::ComplianceChecker::new(*level).check(old_sbom))
243 .collect(),
244 );
245 }
246 if self.data.new_compliance_results.is_none()
247 && let Some(new_sbom) = &self.data.new_sbom
248 {
249 self.data.new_compliance_results = Some(
250 crate::quality::ComplianceLevel::all()
251 .iter()
252 .map(|level| crate::quality::ComplianceChecker::new(*level).check(new_sbom))
253 .collect(),
254 );
255 }
256 }
257
258 pub const fn toggle_help(&mut self) {
260 self.overlays.toggle_help();
261 }
262
263 pub const fn toggle_export(&mut self) {
265 self.overlays.toggle_export();
266 }
267
268 pub const fn toggle_legend(&mut self) {
270 self.overlays.toggle_legend();
271 }
272
273 pub const fn close_overlays(&mut self) {
275 self.overlays.close_all();
276 }
277
278 #[must_use]
280 pub const fn has_overlay(&self) -> bool {
281 self.overlays.has_active()
282 }
283
284 pub fn toggle_threshold_tuning(&mut self) {
286 if self.overlays.threshold_tuning.visible {
287 self.overlays.threshold_tuning.visible = false;
288 } else {
289 self.show_threshold_tuning();
290 }
291 }
292
293 pub fn show_threshold_tuning(&mut self) {
295 self.overlays.close_all();
297
298 let total = match self.mode {
300 AppMode::Diff => {
301 self.data
302 .old_sbom
303 .as_ref()
304 .map_or(0, crate::model::NormalizedSbom::component_count)
305 + self
306 .data
307 .new_sbom
308 .as_ref()
309 .map_or(0, crate::model::NormalizedSbom::component_count)
310 }
311 AppMode::View => self
312 .data
313 .sbom
314 .as_ref()
315 .map_or(0, crate::model::NormalizedSbom::component_count),
316 _ => 0,
317 };
318
319 self.overlays.threshold_tuning =
321 ThresholdTuningState::new(self.data.matching_threshold, total);
322 self.update_threshold_preview();
323 }
324
325 pub fn update_threshold_preview(&mut self) {
327 if !self.overlays.threshold_tuning.visible {
328 return;
329 }
330
331 let estimated = if let Some(ref result) = self.data.diff_result {
334 let current_matches = result.components.modified.len();
336 let threshold = self.overlays.threshold_tuning.threshold;
337 let base_threshold = self.data.matching_threshold;
338
339 let ratio = if threshold < base_threshold {
341 (base_threshold - threshold).mul_add(2.0, 1.0)
342 } else {
343 (threshold - base_threshold).mul_add(-1.5, 1.0)
344 };
345 ((current_matches as f64 * ratio).max(0.0)) as usize
346 } else {
347 0
348 };
349
350 self.overlays
351 .threshold_tuning
352 .set_estimated_matches(estimated);
353 }
354
355 pub fn apply_threshold(&mut self) {
357 self.data.matching_threshold = self.overlays.threshold_tuning.threshold;
358 self.overlays.threshold_tuning.visible = false;
359 self.set_status_message(format!(
360 "Threshold set to {:.0}% - Re-run diff to apply",
361 self.data.matching_threshold * 100.0
362 ));
363 }
364
365 pub fn set_status_message(&mut self, msg: impl Into<String>) {
367 self.status_message = Some(msg.into());
368 }
369
370 pub fn clear_status_message(&mut self) {
375 if self.status_sticky {
376 self.status_sticky = false;
377 } else {
378 self.status_message = None;
379 }
380 }
381
382 pub fn export(&mut self, format: super::export::ExportFormat) {
387 use super::export::{export_diff, export_view, tab_to_report_type};
388 use crate::reports::ReportConfig;
389
390 let report_type = tab_to_report_type(self.active_tab);
391 let config = ReportConfig::with_types(vec![report_type]);
392
393 let result = match self.mode {
394 AppMode::Diff => {
395 if let (Some(diff_result), Some(old_sbom), Some(new_sbom)) = (
396 &self.data.diff_result,
397 &self.data.old_sbom,
398 &self.data.new_sbom,
399 ) {
400 export_diff(
401 format,
402 diff_result,
403 old_sbom,
404 new_sbom,
405 None,
406 &config,
407 self.export_template.as_deref(),
408 )
409 } else {
410 self.set_status_message("No diff data to export");
411 return;
412 }
413 }
414 AppMode::View => {
415 if let Some(ref sbom) = self.data.sbom {
416 export_view(format, sbom, None, &config, self.export_template.as_deref())
417 } else {
418 self.set_status_message("No SBOM data to export");
419 return;
420 }
421 }
422 _ => {
423 self.set_status_message("Export not supported for this mode");
424 return;
425 }
426 };
427
428 if result.success {
429 self.last_export_path = Some(result.path.display().to_string());
430 self.set_status_message(result.message);
431 self.status_sticky = true;
432 } else {
433 self.set_status_message(format!("Export failed: {}", result.message));
434 }
435 }
436
437 pub fn export_compliance(&mut self, format: super::export::ExportFormat) {
439 use super::export::export_compliance;
440
441 self.ensure_compliance_results();
442
443 let (results, selected) = if let Some(ref results) = self.data.new_compliance_results {
445 if !results.is_empty() {
446 (results, self.tabs.diff_compliance.selected_standard)
447 } else if let Some(ref old_results) = self.data.old_compliance_results {
448 if old_results.is_empty() {
449 self.set_status_message("No compliance results to export");
450 return;
451 }
452 (old_results, self.tabs.diff_compliance.selected_standard)
453 } else {
454 self.set_status_message("No compliance results to export");
455 return;
456 }
457 } else if let Some(ref old_results) = self.data.old_compliance_results {
458 if old_results.is_empty() {
459 self.set_status_message("No compliance results to export");
460 return;
461 }
462 (old_results, self.tabs.diff_compliance.selected_standard)
463 } else {
464 self.set_status_message("No compliance results to export");
465 return;
466 };
467
468 let result = export_compliance(
469 format,
470 results,
471 selected,
472 None,
473 self.export_template.as_deref(),
474 );
475 if result.success {
476 self.last_export_path = Some(result.path.display().to_string());
477 self.set_status_message(result.message);
478 self.status_sticky = true;
479 } else {
480 self.set_status_message(format!("Export failed: {}", result.message));
481 }
482 }
483
484 pub fn export_matrix(&mut self, format: super::export::ExportFormat) {
486 use super::export::export_matrix;
487
488 let Some(ref matrix_result) = self.data.matrix_result else {
489 self.set_status_message("No matrix data to export");
490 return;
491 };
492
493 let result = export_matrix(format, matrix_result, self.export_template.as_deref());
494 if result.success {
495 self.last_export_path = Some(result.path.display().to_string());
496 self.set_status_message(result.message);
497 self.status_sticky = true;
498 } else {
499 self.set_status_message(format!("Export failed: {}", result.message));
500 }
501 }
502
503 pub fn run_compliance_check(&mut self) {
509 use crate::tui::security::{SecurityPolicy, check_compliance};
510
511 let preset = self.compliance_state.policy_preset;
512
513 if preset.is_standards_based() {
515 self.run_standards_compliance_check(preset);
516 return;
517 }
518
519 let policy = match preset {
520 super::app_states::PolicyPreset::Enterprise => SecurityPolicy::enterprise_default(),
521 super::app_states::PolicyPreset::Strict => SecurityPolicy::strict(),
522 super::app_states::PolicyPreset::Permissive => SecurityPolicy::permissive(),
523 _ => unreachable!(),
525 };
526
527 let components = self.collect_compliance_data();
529
530 if components.is_empty() {
531 self.set_status_message("No components to check");
532 return;
533 }
534
535 let result = check_compliance(&policy, &components);
536 let passes = result.passes;
537 let score = result.score;
538 let violation_count = result.violations.len();
539
540 self.compliance_state.result = Some(result);
541 self.compliance_state.checked = true;
542 self.compliance_state.selected_violation = 0;
543
544 if passes {
545 self.set_status_message(format!("Policy: {} - PASS (score: {})", policy.name, score));
546 } else {
547 self.set_status_message(format!(
548 "Policy: {} - FAIL ({} violations, score: {})",
549 policy.name, violation_count, score
550 ));
551 }
552 }
553
554 fn run_standards_compliance_check(&mut self, preset: super::app_states::PolicyPreset) {
557 use crate::quality::{ComplianceChecker, ViolationSeverity};
558 use crate::tui::security::{
559 ComplianceResult as PolicyResult, PolicySeverity, PolicyViolation,
560 };
561
562 let Some(level) = preset.compliance_level() else {
563 return;
564 };
565
566 let sbom = match self.mode {
568 AppMode::Diff => self.data.new_sbom.as_ref(),
569 _ => self.data.sbom.as_ref(),
570 };
571 let Some(sbom) = sbom else {
572 self.set_status_message("No SBOM loaded to check");
573 return;
574 };
575
576 let checker = ComplianceChecker::new(level);
577 let std_result = checker.check(sbom);
578
579 let violations: Vec<PolicyViolation> = std_result
581 .violations
582 .iter()
583 .map(|v| {
584 let severity = match v.severity {
585 ViolationSeverity::Error => PolicySeverity::High,
586 ViolationSeverity::Warning => PolicySeverity::Medium,
587 ViolationSeverity::Info => PolicySeverity::Low,
588 };
589 PolicyViolation {
590 rule_name: v.requirement.clone(),
591 severity,
592 component: v.element.clone(),
593 description: v.message.clone(),
594 remediation: v.remediation_guidance().to_string(),
595 }
596 })
597 .collect();
598
599 let penalty: u32 = violations
601 .iter()
602 .map(|v| match v.severity {
603 PolicySeverity::High | PolicySeverity::Critical => 10,
604 PolicySeverity::Medium => 5,
605 PolicySeverity::Low => 1,
606 })
607 .sum();
608 let score = 100u8.saturating_sub(penalty.min(100) as u8);
609
610 let passes = std_result.is_compliant;
611 let policy_name = format!("{} Compliance", preset.label());
612 let violation_count = violations.len();
613
614 let result = PolicyResult {
615 policy_name: policy_name.clone(),
616 components_checked: sbom.components.len(),
617 violations,
618 score,
619 passes,
620 };
621
622 self.compliance_state.result = Some(result);
623 self.compliance_state.checked = true;
624 self.compliance_state.selected_violation = 0;
625
626 if passes {
627 self.set_status_message(format!("{policy_name} - COMPLIANT (score: {score})"));
628 } else {
629 self.set_status_message(format!(
630 "{policy_name} - NON-COMPLIANT ({violation_count} violations, score: {score})"
631 ));
632 }
633 }
634
635 fn collect_compliance_data(&self) -> Vec<crate::tui::security::ComplianceComponentData> {
637 let mut components = Vec::new();
638
639 match self.mode {
640 AppMode::Diff => {
641 if let Some(sbom) = &self.data.new_sbom {
642 for comp in sbom.components.values() {
643 let licenses: Vec<String> = comp
644 .licenses
645 .declared
646 .iter()
647 .map(std::string::ToString::to_string)
648 .collect();
649 let vulns: Vec<(String, String)> = comp
650 .vulnerabilities
651 .iter()
652 .map(|v| {
653 let severity = v.severity.as_ref().map_or_else(
654 || "Unknown".to_string(),
655 std::string::ToString::to_string,
656 );
657 (v.id.clone(), severity)
658 })
659 .collect();
660 components.push((comp.name.clone(), comp.version.clone(), licenses, vulns));
661 }
662 }
663 }
664 AppMode::View => {
665 if let Some(sbom) = &self.data.sbom {
666 for comp in sbom.components.values() {
667 let licenses: Vec<String> = comp
668 .licenses
669 .declared
670 .iter()
671 .map(std::string::ToString::to_string)
672 .collect();
673 let vulns: Vec<(String, String)> = comp
674 .vulnerabilities
675 .iter()
676 .map(|v| {
677 let severity = v.severity.as_ref().map_or_else(
678 || "Unknown".to_string(),
679 std::string::ToString::to_string,
680 );
681 (v.id.clone(), severity)
682 })
683 .collect();
684 components.push((comp.name.clone(), comp.version.clone(), licenses, vulns));
685 }
686 }
687 }
688 _ => {}
689 }
690
691 components
692 }
693
694 pub const fn toggle_compliance_details(&mut self) {
696 self.compliance_state.toggle_details();
697 }
698
699 pub fn next_policy(&mut self) {
701 self.compliance_state.toggle_policy();
702 if self.compliance_state.checked {
704 self.run_compliance_check();
705 }
706 }
707
708 #[must_use]
714 pub const fn view_mode(&self) -> super::traits::ViewMode {
715 super::traits::ViewMode::from_app_mode(self.mode)
716 }
717
718 pub fn handle_event_result(&mut self, result: super::traits::EventResult) {
723 use super::traits::EventResult;
724
725 match result {
726 EventResult::Consumed | EventResult::Ignored => {
727 }
729 EventResult::NavigateTo(target) => {
730 self.navigate_to_target(target);
731 }
732 EventResult::Exit => {
733 self.should_quit = true;
734 }
735 EventResult::ShowOverlay(kind) => {
736 self.show_overlay_kind(&kind);
737 }
738 EventResult::StatusMessage(msg) => {
739 self.set_status_message(msg);
740 }
741 }
742 }
743
744 fn show_overlay_kind(&mut self, kind: &super::traits::OverlayKind) {
746 use super::traits::OverlayKind;
747
748 self.overlays.close_all();
750
751 match kind {
752 OverlayKind::Help => self.overlays.show_help = true,
753 OverlayKind::Export => self.overlays.show_export = true,
754 OverlayKind::Legend => self.overlays.show_legend = true,
755 OverlayKind::Search => {
756 self.overlays.search.active = true;
757 self.overlays.search.query.clear();
758 }
759 OverlayKind::Shortcuts => self.overlays.shortcuts.visible = true,
760 }
761 }
762
763 #[must_use]
765 pub const fn current_tab_target(&self) -> super::traits::TabTarget {
766 super::traits::TabTarget::from_tab_kind(self.active_tab)
767 }
768
769 #[must_use]
771 pub fn current_shortcuts(&self) -> Vec<super::traits::Shortcut> {
772 use super::traits::Shortcut;
773
774 let mut shortcuts = vec![
775 Shortcut::primary("?", "Help"),
776 Shortcut::primary("q", "Quit"),
777 Shortcut::primary("Tab", "Next tab"),
778 Shortcut::primary("/", "Search"),
779 ];
780
781 match self.active_tab {
783 TabKind::Components => {
784 shortcuts.push(Shortcut::new("f", "Filter"));
785 shortcuts.push(Shortcut::new("s", "Sort"));
786 shortcuts.push(Shortcut::new("m", "Multi-select"));
787 }
788 TabKind::Dependencies => {
789 shortcuts.push(Shortcut::new("t", "Transitive"));
790 shortcuts.push(Shortcut::new("+/-", "Depth"));
791 }
792 TabKind::Vulnerabilities => {
793 shortcuts.push(Shortcut::new("f", "Filter"));
794 shortcuts.push(Shortcut::new("s", "Sort"));
795 }
796 TabKind::Quality => {
797 shortcuts.push(Shortcut::new("v", "View mode"));
798 }
799 _ => {}
800 }
801
802 shortcuts
803 }
804}
805
806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
808pub enum AppMode {
809 Diff,
811 View,
813 MultiDiff,
815 Timeline,
817 Matrix,
819}
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
823pub enum TabKind {
824 Summary,
825 Components,
826 Dependencies,
827 Licenses,
828 Vulnerabilities,
829 Quality,
830 Compliance,
831 SideBySide,
832 GraphChanges,
833 Source,
834}
835
836impl TabKind {
837 #[must_use]
838 pub const fn title(&self) -> &'static str {
839 match self {
840 Self::Summary => "Summary",
841 Self::Components => "Components",
842 Self::Dependencies => "Dependencies",
843 Self::Licenses => "Licenses",
844 Self::Vulnerabilities => "Vulnerabilities",
845 Self::Quality => "Quality",
846 Self::Compliance => "Compliance",
847 Self::SideBySide => "Side-by-Side",
848 Self::GraphChanges => "Graph",
849 Self::Source => "Source",
850 }
851 }
852
853 #[must_use]
855 pub const fn as_str(&self) -> &'static str {
856 match self {
857 Self::Summary => "summary",
858 Self::Components => "components",
859 Self::Dependencies => "dependencies",
860 Self::Licenses => "licenses",
861 Self::Vulnerabilities => "vulnerabilities",
862 Self::Quality => "quality",
863 Self::Compliance => "compliance",
864 Self::SideBySide => "side-by-side",
865 Self::GraphChanges => "graph",
866 Self::Source => "source",
867 }
868 }
869
870 #[must_use]
872 pub fn from_str_opt(s: &str) -> Option<Self> {
873 match s {
874 "summary" => Some(Self::Summary),
875 "components" => Some(Self::Components),
876 "dependencies" => Some(Self::Dependencies),
877 "licenses" => Some(Self::Licenses),
878 "vulnerabilities" => Some(Self::Vulnerabilities),
879 "quality" => Some(Self::Quality),
880 "compliance" => Some(Self::Compliance),
881 "side-by-side" => Some(Self::SideBySide),
882 "graph" => Some(Self::GraphChanges),
883 "source" => Some(Self::Source),
884 _ => None,
885 }
886 }
887}