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;
9use ratatui::widgets::ScrollbarState;
10
11#[allow(unused_imports)]
13pub use super::app_states::{
14 ComponentFilter, ComponentSort, ComponentsState, sort_component_changes, sort_components,
16 DependenciesState,
18 LicenseGroupBy, LicenseRiskFilter, LicenseSort, LicensesState,
20 DiffVulnItem, DiffVulnStatus, VulnFilter, VulnSort, VulnerabilitiesState,
22 QualityState, QualityViewMode,
24 GraphChangesState,
26 AlignmentMode, ChangeTypeFilter, ScrollSyncMode, SideBySideState,
28 MultiDiffState, MultiViewFilterPreset, MultiViewSearchState, MultiViewSortBy, SortDirection,
30 TimelineComponentFilter, TimelineSortBy, TimelineState,
32 MatrixSortBy, MatrixState, SimilarityThreshold,
34 ChangeType, DiffSearchResult, DiffSearchState, VulnChangeType,
36 Breadcrumb, NavigationContext,
38 MultiViewType, ViewSwitcherState,
40 ComponentDeepDiveData, ComponentDeepDiveState, ComponentSimilarityInfo,
42 ComponentTargetPresence, ComponentVersionEntry, ComponentVulnInfo,
43 ShortcutsContext, ShortcutsOverlayState,
45};
46
47pub struct TabStates {
52 pub components: ComponentsState,
53 pub dependencies: DependenciesState,
54 pub licenses: LicensesState,
55 pub vulnerabilities: VulnerabilitiesState,
56 pub quality: QualityState,
57 pub graph_changes: GraphChangesState,
58 pub side_by_side: SideBySideState,
59 pub diff_compliance: crate::tui::app_states::DiffComplianceState,
60 pub multi_diff: MultiDiffState,
61 pub timeline: TimelineState,
62 pub matrix: MatrixState,
63 pub source: crate::tui::app_states::SourceDiffState,
64}
65
66pub struct AppOverlays {
70 pub show_help: bool,
71 pub show_export: bool,
72 pub show_legend: bool,
73 pub search: DiffSearchState,
74 pub threshold_tuning: ThresholdTuningState,
75 pub view_switcher: ViewSwitcherState,
76 pub shortcuts: ShortcutsOverlayState,
77 pub component_deep_dive: ComponentDeepDiveState,
78}
79
80impl AppOverlays {
81 pub fn new() -> Self {
82 Self {
83 show_help: false,
84 show_export: false,
85 show_legend: false,
86 search: DiffSearchState::new(),
87 threshold_tuning: ThresholdTuningState::default(),
88 view_switcher: ViewSwitcherState::new(),
89 shortcuts: ShortcutsOverlayState::new(),
90 component_deep_dive: ComponentDeepDiveState::new(),
91 }
92 }
93
94 pub fn toggle_help(&mut self) {
95 self.show_help = !self.show_help;
96 if self.show_help {
97 self.show_export = false;
98 self.show_legend = false;
99 }
100 }
101
102 pub fn toggle_export(&mut self) {
103 self.show_export = !self.show_export;
104 if self.show_export {
105 self.show_help = false;
106 self.show_legend = false;
107 }
108 }
109
110 pub fn toggle_legend(&mut self) {
111 self.show_legend = !self.show_legend;
112 if self.show_legend {
113 self.show_help = false;
114 self.show_export = false;
115 }
116 }
117
118 pub fn close_all(&mut self) {
119 self.show_help = false;
120 self.show_export = false;
121 self.show_legend = false;
122 self.search.active = false;
123 self.threshold_tuning.visible = false;
124 }
125
126 pub fn has_active(&self) -> bool {
127 self.show_help
128 || self.show_export
129 || self.show_legend
130 || self.search.active
131 || self.threshold_tuning.visible
132 }
133}
134
135pub struct DataContext {
139 pub diff_result: Option<DiffResult>,
140 pub old_sbom: Option<NormalizedSbom>,
141 pub new_sbom: Option<NormalizedSbom>,
142 pub sbom: Option<NormalizedSbom>,
143 pub multi_diff_result: Option<MultiDiffResult>,
144 pub timeline_result: Option<TimelineResult>,
145 pub matrix_result: Option<MatrixResult>,
146 pub old_sbom_index: Option<NormalizedSbomIndex>,
147 pub new_sbom_index: Option<NormalizedSbomIndex>,
148 pub sbom_index: Option<NormalizedSbomIndex>,
149 pub old_quality: Option<QualityReport>,
150 pub new_quality: Option<QualityReport>,
151 pub quality_report: Option<QualityReport>,
152 pub old_cra_compliance: Option<ComplianceResult>,
153 pub new_cra_compliance: Option<ComplianceResult>,
154 pub old_compliance_results: Option<Vec<ComplianceResult>>,
155 pub new_compliance_results: Option<Vec<ComplianceResult>>,
156 pub matching_threshold: f64,
157 #[cfg(feature = "enrichment")]
158 pub enrichment_stats_old: Option<EnrichmentStats>,
159 #[cfg(feature = "enrichment")]
160 pub enrichment_stats_new: Option<EnrichmentStats>,
161}
162
163pub struct App {
165 pub mode: AppMode,
167 pub active_tab: TabKind,
169 pub data: DataContext,
171 pub tabs: TabStates,
173 pub overlays: AppOverlays,
175 pub scroll_state: ScrollbarState,
177 pub should_quit: bool,
179 pub status_message: Option<String>,
181 pub tick: u64,
183 pub last_export_path: Option<String>,
185 pub navigation_ctx: NavigationContext,
187 pub security_cache: crate::tui::security::SecurityAnalysisCache,
189 pub compliance_state: crate::tui::app_states::PolicyComplianceState,
191 pub quality_view: Option<crate::tui::view_states::QualityView>,
197}
198
199impl App {
200 pub fn ensure_compliance_results(&mut self) {
202 if self.data.old_compliance_results.is_none() {
203 if let Some(old_sbom) = &self.data.old_sbom {
204 self.data.old_compliance_results = Some(
205 crate::quality::ComplianceLevel::all()
206 .iter()
207 .map(|level| crate::quality::ComplianceChecker::new(*level).check(old_sbom))
208 .collect(),
209 );
210 }
211 }
212 if self.data.new_compliance_results.is_none() {
213 if let Some(new_sbom) = &self.data.new_sbom {
214 self.data.new_compliance_results = Some(
215 crate::quality::ComplianceLevel::all()
216 .iter()
217 .map(|level| crate::quality::ComplianceChecker::new(*level).check(new_sbom))
218 .collect(),
219 );
220 }
221 }
222 }
223
224 pub fn toggle_help(&mut self) {
226 self.overlays.toggle_help();
227 }
228
229 pub fn toggle_export(&mut self) {
231 self.overlays.toggle_export();
232 }
233
234 pub fn toggle_legend(&mut self) {
236 self.overlays.toggle_legend();
237 }
238
239 pub fn close_overlays(&mut self) {
241 self.overlays.close_all();
242 }
243
244 pub fn has_overlay(&self) -> bool {
246 self.overlays.has_active()
247 }
248
249 pub fn toggle_threshold_tuning(&mut self) {
251 if self.overlays.threshold_tuning.visible {
252 self.overlays.threshold_tuning.visible = false;
253 } else {
254 self.show_threshold_tuning();
255 }
256 }
257
258 pub fn show_threshold_tuning(&mut self) {
260 self.overlays.close_all();
262
263 let total = match self.mode {
265 AppMode::Diff => {
266 self.data.old_sbom
267 .as_ref()
268 .map(|s| s.component_count())
269 .unwrap_or(0)
270 + self.data
271 .new_sbom
272 .as_ref()
273 .map(|s| s.component_count())
274 .unwrap_or(0)
275 }
276 AppMode::View => self.data.sbom.as_ref().map(|s| s.component_count()).unwrap_or(0),
277 _ => 0,
278 };
279
280 self.overlays.threshold_tuning = ThresholdTuningState::new(self.data.matching_threshold, total);
282 self.update_threshold_preview();
283 }
284
285 pub fn update_threshold_preview(&mut self) {
287 if !self.overlays.threshold_tuning.visible {
288 return;
289 }
290
291 let estimated = if let Some(ref result) = self.data.diff_result {
294 let current_matches = result.components.modified.len();
296 let threshold = self.overlays.threshold_tuning.threshold;
297 let base_threshold = self.data.matching_threshold;
298
299 let ratio = if threshold < base_threshold {
301 1.0 + (base_threshold - threshold) * 2.0
302 } else {
303 1.0 - (threshold - base_threshold) * 1.5
304 };
305 ((current_matches as f64 * ratio).max(0.0)) as usize
306 } else {
307 0
308 };
309
310 self.overlays.threshold_tuning.set_estimated_matches(estimated);
311 }
312
313 pub fn apply_threshold(&mut self) {
315 self.data.matching_threshold = self.overlays.threshold_tuning.threshold;
316 self.overlays.threshold_tuning.visible = false;
317 self.set_status_message(format!(
318 "Threshold set to {:.0}% - Re-run diff to apply",
319 self.data.matching_threshold * 100.0
320 ));
321 }
322
323 pub fn set_status_message(&mut self, msg: impl Into<String>) {
325 self.status_message = Some(msg.into());
326 }
327
328 pub fn clear_status_message(&mut self) {
330 self.status_message = None;
331 }
332
333 pub fn export(&mut self, format: super::export::ExportFormat) {
335 use super::export::{export_diff, export_view};
336
337 let result = match self.mode {
338 AppMode::Diff => {
339 if let (Some(ref diff_result), Some(ref old_sbom), Some(ref new_sbom)) =
340 (&self.data.diff_result, &self.data.old_sbom, &self.data.new_sbom)
341 {
342 export_diff(format, diff_result, old_sbom, new_sbom, None)
343 } else {
344 self.set_status_message("No diff data to export");
345 return;
346 }
347 }
348 AppMode::View => {
349 if let Some(ref sbom) = self.data.sbom {
350 export_view(format, sbom, None)
351 } else {
352 self.set_status_message("No SBOM data to export");
353 return;
354 }
355 }
356 _ => {
357 self.set_status_message("Export not supported for this mode");
358 return;
359 }
360 };
361
362 if result.success {
363 self.last_export_path = Some(result.path.display().to_string());
364 self.set_status_message(result.message);
365 } else {
366 self.set_status_message(format!("Export failed: {}", result.message));
367 }
368 }
369
370 pub fn export_compliance(&mut self, format: super::export::ExportFormat) {
372 use super::export::export_compliance;
373
374 self.ensure_compliance_results();
375
376 let (results, selected) = if let Some(ref results) = self.data.new_compliance_results {
378 if !results.is_empty() {
379 (results, self.tabs.diff_compliance.selected_standard)
380 } else if let Some(ref old_results) = self.data.old_compliance_results {
381 if !old_results.is_empty() {
382 (old_results, self.tabs.diff_compliance.selected_standard)
383 } else {
384 self.set_status_message("No compliance results to export");
385 return;
386 }
387 } else {
388 self.set_status_message("No compliance results to export");
389 return;
390 }
391 } else if let Some(ref old_results) = self.data.old_compliance_results {
392 if !old_results.is_empty() {
393 (old_results, self.tabs.diff_compliance.selected_standard)
394 } else {
395 self.set_status_message("No compliance results to export");
396 return;
397 }
398 } else {
399 self.set_status_message("No compliance results to export");
400 return;
401 };
402
403 let result = export_compliance(format, results, selected, None);
404 if result.success {
405 self.last_export_path = Some(result.path.display().to_string());
406 self.set_status_message(result.message);
407 } else {
408 self.set_status_message(format!("Export failed: {}", result.message));
409 }
410 }
411
412 pub fn run_compliance_check(&mut self) {
418 let preset = self.compliance_state.policy_preset;
419
420 if preset.is_standards_based() {
422 self.run_standards_compliance_check(preset);
423 return;
424 }
425
426 use crate::tui::security::{check_compliance, SecurityPolicy};
427
428 let policy = match preset {
429 super::app_states::PolicyPreset::Enterprise => SecurityPolicy::enterprise_default(),
430 super::app_states::PolicyPreset::Strict => SecurityPolicy::strict(),
431 super::app_states::PolicyPreset::Permissive => SecurityPolicy::permissive(),
432 _ => unreachable!(),
434 };
435
436 let components = self.collect_compliance_data();
438
439 if components.is_empty() {
440 self.set_status_message("No components to check");
441 return;
442 }
443
444 let result = check_compliance(&policy, &components);
445 let passes = result.passes;
446 let score = result.score;
447 let violation_count = result.violations.len();
448
449 self.compliance_state.result = Some(result);
450 self.compliance_state.checked = true;
451 self.compliance_state.selected_violation = 0;
452
453 if passes {
454 self.set_status_message(format!(
455 "Policy: {} - PASS (score: {})",
456 policy.name, score
457 ));
458 } else {
459 self.set_status_message(format!(
460 "Policy: {} - FAIL ({} violations, score: {})",
461 policy.name, violation_count, score
462 ));
463 }
464 }
465
466 fn run_standards_compliance_check(&mut self, preset: super::app_states::PolicyPreset) {
469 use crate::quality::{ComplianceChecker, ViolationSeverity};
470 use crate::tui::security::{ComplianceResult as PolicyResult, PolicySeverity, PolicyViolation};
471
472 let level = match preset.compliance_level() {
473 Some(l) => l,
474 None => return,
475 };
476
477 let sbom = match self.mode {
479 AppMode::Diff => self.data.new_sbom.as_ref(),
480 _ => self.data.sbom.as_ref(),
481 };
482 let sbom = match sbom {
483 Some(s) => s,
484 None => {
485 self.set_status_message("No SBOM loaded to check");
486 return;
487 }
488 };
489
490 let checker = ComplianceChecker::new(level);
491 let std_result = checker.check(sbom);
492
493 let violations: Vec<PolicyViolation> = std_result
495 .violations
496 .iter()
497 .map(|v| {
498 let severity = match v.severity {
499 ViolationSeverity::Error => PolicySeverity::High,
500 ViolationSeverity::Warning => PolicySeverity::Medium,
501 ViolationSeverity::Info => PolicySeverity::Low,
502 };
503 PolicyViolation {
504 rule_name: v.requirement.clone(),
505 severity,
506 component: v.element.clone(),
507 description: v.message.clone(),
508 remediation: v.remediation_guidance().to_string(),
509 }
510 })
511 .collect();
512
513 let penalty: u32 = violations.iter().map(|v| match v.severity {
515 PolicySeverity::High | PolicySeverity::Critical => 10,
516 PolicySeverity::Medium => 5,
517 PolicySeverity::Low => 1,
518 }).sum();
519 let score = 100u8.saturating_sub(penalty.min(100) as u8);
520
521 let passes = std_result.is_compliant;
522 let policy_name = format!("{} Compliance", preset.label());
523 let violation_count = violations.len();
524
525 let result = PolicyResult {
526 policy_name: policy_name.clone(),
527 components_checked: sbom.components.len(),
528 violations,
529 score,
530 passes,
531 };
532
533 self.compliance_state.result = Some(result);
534 self.compliance_state.checked = true;
535 self.compliance_state.selected_violation = 0;
536
537 if passes {
538 self.set_status_message(format!(
539 "{} - COMPLIANT (score: {})",
540 policy_name, score
541 ));
542 } else {
543 self.set_status_message(format!(
544 "{} - NON-COMPLIANT ({} violations, score: {})",
545 policy_name, violation_count, score
546 ));
547 }
548 }
549
550 fn collect_compliance_data(
552 &self,
553 ) -> Vec<crate::tui::security::ComplianceComponentData> {
554 let mut components = Vec::new();
555
556 match self.mode {
557 AppMode::Diff => {
558 if let Some(sbom) = &self.data.new_sbom {
559 for comp in sbom.components.values() {
560 let licenses: Vec<String> = comp
561 .licenses
562 .declared
563 .iter()
564 .map(|l| l.to_string())
565 .collect();
566 let vulns: Vec<(String, String)> = comp
567 .vulnerabilities
568 .iter()
569 .map(|v| {
570 let severity = v
571 .severity
572 .as_ref()
573 .map(|s| s.to_string())
574 .unwrap_or_else(|| "Unknown".to_string());
575 (v.id.clone(), severity)
576 })
577 .collect();
578 components.push((
579 comp.name.clone(),
580 comp.version.clone(),
581 licenses,
582 vulns,
583 ));
584 }
585 }
586 }
587 AppMode::View => {
588 if let Some(sbom) = &self.data.sbom {
589 for comp in sbom.components.values() {
590 let licenses: Vec<String> = comp
591 .licenses
592 .declared
593 .iter()
594 .map(|l| l.to_string())
595 .collect();
596 let vulns: Vec<(String, String)> = comp
597 .vulnerabilities
598 .iter()
599 .map(|v| {
600 let severity = v
601 .severity
602 .as_ref()
603 .map(|s| s.to_string())
604 .unwrap_or_else(|| "Unknown".to_string());
605 (v.id.clone(), severity)
606 })
607 .collect();
608 components.push((
609 comp.name.clone(),
610 comp.version.clone(),
611 licenses,
612 vulns,
613 ));
614 }
615 }
616 }
617 _ => {}
618 }
619
620 components
621 }
622
623 pub fn toggle_compliance_details(&mut self) {
625 self.compliance_state.toggle_details();
626 }
627
628 pub fn next_policy(&mut self) {
630 self.compliance_state.toggle_policy();
631 if self.compliance_state.checked {
633 self.run_compliance_check();
634 }
635 }
636
637 pub fn view_mode(&self) -> super::traits::ViewMode {
643 super::traits::ViewMode::from_app_mode(self.mode)
644 }
645
646 pub fn handle_event_result(&mut self, result: super::traits::EventResult) {
651 use super::traits::EventResult;
652
653 match result {
654 EventResult::Consumed => {
655 }
657 EventResult::Ignored => {
658 }
660 EventResult::NavigateTo(target) => {
661 self.navigate_to_target(target);
662 }
663 EventResult::Exit => {
664 self.should_quit = true;
665 }
666 EventResult::ShowOverlay(kind) => {
667 self.show_overlay_kind(kind);
668 }
669 EventResult::StatusMessage(msg) => {
670 self.set_status_message(msg);
671 }
672 }
673 }
674
675 fn show_overlay_kind(&mut self, kind: super::traits::OverlayKind) {
677 use super::traits::OverlayKind;
678
679 self.overlays.close_all();
681
682 match kind {
683 OverlayKind::Help => self.overlays.show_help = true,
684 OverlayKind::Export => self.overlays.show_export = true,
685 OverlayKind::Legend => self.overlays.show_legend = true,
686 OverlayKind::Search => {
687 self.overlays.search.active = true;
688 self.overlays.search.query.clear();
689 }
690 OverlayKind::Shortcuts => self.overlays.shortcuts.visible = true,
691 }
692 }
693
694 pub fn current_tab_target(&self) -> super::traits::TabTarget {
696 super::traits::TabTarget::from_tab_kind(self.active_tab)
697 }
698
699 pub fn current_shortcuts(&self) -> Vec<super::traits::Shortcut> {
701 use super::traits::Shortcut;
702
703 let mut shortcuts = vec![
704 Shortcut::primary("?", "Help"),
705 Shortcut::primary("q", "Quit"),
706 Shortcut::primary("Tab", "Next tab"),
707 Shortcut::primary("/", "Search"),
708 ];
709
710 match self.active_tab {
712 TabKind::Components => {
713 shortcuts.push(Shortcut::new("f", "Filter"));
714 shortcuts.push(Shortcut::new("s", "Sort"));
715 shortcuts.push(Shortcut::new("m", "Multi-select"));
716 }
717 TabKind::Dependencies => {
718 shortcuts.push(Shortcut::new("t", "Transitive"));
719 shortcuts.push(Shortcut::new("+/-", "Depth"));
720 }
721 TabKind::Vulnerabilities => {
722 shortcuts.push(Shortcut::new("f", "Filter"));
723 shortcuts.push(Shortcut::new("s", "Sort"));
724 }
725 TabKind::Quality => {
726 shortcuts.push(Shortcut::new("v", "View mode"));
727 }
728 _ => {}
729 }
730
731 shortcuts
732 }
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum AppMode {
738 Diff,
740 View,
742 MultiDiff,
744 Timeline,
746 Matrix,
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum TabKind {
753 Summary,
754 Components,
755 Dependencies,
756 Licenses,
757 Vulnerabilities,
758 Quality,
759 Compliance,
760 SideBySide,
761 GraphChanges,
762 Source,
763}
764
765impl TabKind {
766 pub fn title(&self) -> &'static str {
767 match self {
768 TabKind::Summary => "Summary",
769 TabKind::Components => "Components",
770 TabKind::Dependencies => "Dependencies",
771 TabKind::Licenses => "Licenses",
772 TabKind::Vulnerabilities => "Vulnerabilities",
773 TabKind::Quality => "Quality",
774 TabKind::Compliance => "Compliance",
775 TabKind::SideBySide => "Side-by-Side",
776 TabKind::GraphChanges => "Graph",
777 TabKind::Source => "Source",
778 }
779 }
780}