Skip to main content

sbom_tools/tui/
app.rs

1//! Application state for the TUI.
2
3use 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::state::ListNavigation;
9use crate::tui::views::ThresholdTuningState;
10
11// Re-export state types from app_states module for backwards compatibility
12#[allow(unused_imports)]
13pub use super::app_states::{
14    // Side-by-side states
15    AlignmentMode,
16    // Navigation states
17    Breadcrumb,
18    // Search states
19    ChangeType,
20    ChangeTypeFilter,
21    // Component deep dive states
22    ComponentDeepDiveData,
23    ComponentDeepDiveState,
24    // Component states
25    ComponentFilter,
26    ComponentSimilarityInfo,
27    ComponentSort,
28    ComponentTargetPresence,
29    ComponentVersionEntry,
30    ComponentVulnInfo,
31    ComponentsState,
32    // Dependencies state
33    DependenciesState,
34    DiffSearchResult,
35    DiffSearchState,
36    // Vulnerability states
37    DiffVulnItem,
38    DiffVulnStatus,
39    // Graph changes state
40    GraphChangesState,
41    // License states
42    LicenseGroupBy,
43    LicenseRiskFilter,
44    LicenseSort,
45    LicensesState,
46    // Matrix states
47    MatrixSortBy,
48    MatrixState,
49    // Multi-view states
50    MultiDiffState,
51    MultiViewFilterPreset,
52    MultiViewSearchState,
53    MultiViewSortBy,
54    // View switcher states
55    MultiViewType,
56    NavigationContext,
57    // Quality states
58    QualityState,
59    QualityViewMode,
60    ScrollSyncMode,
61    SearchMode,
62    // Shortcuts overlay states
63    ShortcutsContext,
64    ShortcutsOverlayState,
65    SideBySideState,
66    SimilarityThreshold,
67    SortDirection,
68    // Timeline states
69    TimelineComponentFilter,
70    TimelineSortBy,
71    TimelineState,
72    ViewSwitcherState,
73    VulnChangeType,
74    VulnFilter,
75    VulnSort,
76    VulnerabilitiesState,
77    sort_component_changes,
78};
79
80/// Mode-specific UI state for multi-comparison views.
81///
82/// Contains state for multi_diff, timeline, and matrix modes only.
83/// Per-tab state for standard tabs lives in their respective ViewState impls.
84pub struct ModeStates {
85    pub(crate) multi_diff: MultiDiffState,
86    pub(crate) timeline: TimelineState,
87    pub(crate) matrix: MatrixState,
88}
89
90/// Overlay UI state container.
91///
92/// Groups all overlay visibility flags and complex overlay states.
93pub struct AppOverlays {
94    pub(crate) show_help: bool,
95    pub(crate) show_export: bool,
96    pub(crate) show_legend: bool,
97    pub(crate) search: DiffSearchState,
98    pub(crate) threshold_tuning: ThresholdTuningState,
99    pub(crate) view_switcher: ViewSwitcherState,
100    pub(crate) shortcuts: ShortcutsOverlayState,
101    pub(crate) component_deep_dive: ComponentDeepDiveState,
102}
103
104impl AppOverlays {
105    pub fn new() -> Self {
106        Self {
107            show_help: false,
108            show_export: false,
109            show_legend: false,
110            search: DiffSearchState::new(),
111            threshold_tuning: ThresholdTuningState::default(),
112            view_switcher: ViewSwitcherState::new(),
113            shortcuts: ShortcutsOverlayState::new(),
114            component_deep_dive: ComponentDeepDiveState::new(),
115        }
116    }
117
118    pub const fn toggle_help(&mut self) {
119        self.show_help = !self.show_help;
120        if self.show_help {
121            self.show_export = false;
122            self.show_legend = false;
123        }
124    }
125
126    pub const fn toggle_export(&mut self) {
127        self.show_export = !self.show_export;
128        if self.show_export {
129            self.show_help = false;
130            self.show_legend = false;
131        }
132    }
133
134    pub const fn toggle_legend(&mut self) {
135        self.show_legend = !self.show_legend;
136        if self.show_legend {
137            self.show_help = false;
138            self.show_export = false;
139        }
140    }
141
142    pub const fn close_all(&mut self) {
143        self.show_help = false;
144        self.show_export = false;
145        self.show_legend = false;
146        self.search.active = false;
147        self.threshold_tuning.visible = false;
148    }
149
150    pub const fn has_active(&self) -> bool {
151        self.show_help
152            || self.show_export
153            || self.show_legend
154            || self.search.active
155            || self.threshold_tuning.visible
156    }
157}
158
159/// Data context: SBOM data, diff results, indexes, quality, and compliance.
160///
161/// Groups all immutable-after-construction data that tabs read from.
162pub struct DataContext {
163    pub(crate) diff_result: Option<DiffResult>,
164    pub(crate) old_sbom: Option<NormalizedSbom>,
165    pub(crate) new_sbom: Option<NormalizedSbom>,
166    pub(crate) sbom: Option<NormalizedSbom>,
167    pub(crate) multi_diff_result: Option<MultiDiffResult>,
168    pub(crate) timeline_result: Option<TimelineResult>,
169    pub(crate) matrix_result: Option<MatrixResult>,
170    pub(crate) old_sbom_index: Option<NormalizedSbomIndex>,
171    pub(crate) new_sbom_index: Option<NormalizedSbomIndex>,
172    pub(crate) sbom_index: Option<NormalizedSbomIndex>,
173    pub(crate) old_quality: Option<QualityReport>,
174    pub(crate) new_quality: Option<QualityReport>,
175    pub(crate) quality_report: Option<QualityReport>,
176    pub(crate) old_cra_compliance: Option<ComplianceResult>,
177    pub(crate) new_cra_compliance: Option<ComplianceResult>,
178    pub(crate) old_compliance_results: Option<Vec<ComplianceResult>>,
179    pub(crate) new_compliance_results: Option<Vec<ComplianceResult>>,
180    /// Optional CRA sidecar metadata. When set, `ensure_compliance_results`
181    /// passes it to `ComplianceChecker::with_sidecar()` so high-risk AI
182    /// escalation (EU AI Act), OSS-Steward, EUCC, and Article 14 checks render
183    /// the same COMPLIANT/NON-COMPLIANT verdict the CLI produces.
184    pub(crate) cra_sidecar: Option<crate::model::CraSidecarMetadata>,
185    pub(crate) matching_threshold: f64,
186    #[cfg(feature = "enrichment")]
187    pub(crate) enrichment_stats_old: Option<EnrichmentStats>,
188    #[cfg(feature = "enrichment")]
189    pub(crate) enrichment_stats_new: Option<EnrichmentStats>,
190}
191
192/// Main application state
193pub struct App {
194    /// Current mode (diff or view)
195    pub(crate) mode: AppMode,
196    /// Active tab
197    pub(crate) active_tab: TabKind,
198    /// SBOM data, diff results, indexes, quality, and compliance
199    pub(crate) data: DataContext,
200    /// Per-tab UI state
201    pub(crate) tabs: ModeStates,
202    /// Overlay UI state
203    pub(crate) overlays: AppOverlays,
204    /// Should quit
205    pub(crate) should_quit: bool,
206    /// Status message to display temporarily
207    pub(crate) status_message: Option<String>,
208    /// When true, the status message survives one extra keypress before clearing.
209    pub(crate) status_sticky: bool,
210    /// Animation tick counter
211    pub(crate) tick: u64,
212    /// Last exported file path
213    pub(crate) last_export_path: Option<String>,
214    /// Navigation context for cross-view navigation
215    pub(crate) navigation_ctx: NavigationContext,
216    /// Security analysis cache for blast radius, risk indicators, and flagged items
217    pub(crate) security_cache: crate::tui::security::SecurityAnalysisCache,
218    /// Compliance/policy checking state
219    pub(crate) compliance_state: crate::tui::app_states::PolicyComplianceState,
220    /// Optional export filename template (from `--export-template` CLI arg).
221    pub(crate) export_template: Option<String>,
222    // ========================================================================
223    // ViewState implementations
224    // ========================================================================
225    // Each view handles its own key events via the ViewState trait.
226    // State is synced back to `tabs.*` after each event for rendering.
227    pub(crate) components_view: crate::tui::view_states::ComponentsView,
228    pub(crate) dependencies_view: crate::tui::view_states::DependenciesView,
229    pub(crate) licenses_view: crate::tui::view_states::LicensesView,
230    pub(crate) vulnerabilities_view: crate::tui::view_states::VulnerabilitiesView,
231    pub(crate) quality_view: crate::tui::view_states::QualityView,
232    pub(crate) compliance_view: crate::tui::view_states::ComplianceView,
233    pub(crate) sidebyside_view: crate::tui::view_states::SideBySideView,
234    pub(crate) graph_changes_view: crate::tui::view_states::GraphChangesView,
235    pub(crate) source_view: crate::tui::view_states::SourceView,
236}
237
238impl App {
239    /// Lazily compute compliance results for all standards when first needed.
240    ///
241    /// The optional CRA sidecar is threaded into every checker so sidecar-driven
242    /// verdicts — most importantly EU AI Act high-risk escalation — match the
243    /// CLI. Without it a high-risk AI SBOM the CLI marks NON-COMPLIANT would
244    /// render COMPLIANT in the diff compliance tab.
245    pub fn ensure_compliance_results(&mut self) {
246        let sidecar = self.data.cra_sidecar.as_ref();
247        if self.data.old_compliance_results.is_none()
248            && let Some(old_sbom) = &self.data.old_sbom
249        {
250            self.data.old_compliance_results =
251                Some(Self::compliance_results_for(old_sbom, sidecar));
252        }
253        if self.data.new_compliance_results.is_none()
254            && let Some(new_sbom) = &self.data.new_sbom
255        {
256            self.data.new_compliance_results =
257                Some(Self::compliance_results_for(new_sbom, sidecar));
258        }
259    }
260
261    /// Run every compliance standard against `sbom`, threading the optional
262    /// CRA sidecar into each [`ComplianceChecker`].
263    fn compliance_results_for(
264        sbom: &crate::model::NormalizedSbom,
265        sidecar: Option<&crate::model::CraSidecarMetadata>,
266    ) -> Vec<crate::quality::ComplianceResult> {
267        crate::quality::ComplianceLevel::all()
268            .iter()
269            .map(|level| {
270                let mut checker = crate::quality::ComplianceChecker::new(*level);
271                if let Some(sc) = sidecar {
272                    checker = checker.with_sidecar(sc.clone());
273                }
274                checker.check(sbom)
275            })
276            .collect()
277    }
278
279    /// Toggle help overlay
280    pub const fn toggle_help(&mut self) {
281        self.overlays.toggle_help();
282    }
283
284    /// Toggle export dialog
285    pub const fn toggle_export(&mut self) {
286        self.overlays.toggle_export();
287    }
288
289    /// Toggle legend overlay
290    pub const fn toggle_legend(&mut self) {
291        self.overlays.toggle_legend();
292    }
293
294    /// Close all overlays
295    pub const fn close_overlays(&mut self) {
296        self.overlays.close_all();
297    }
298
299    /// Check if any overlay is open
300    #[must_use]
301    pub const fn has_overlay(&self) -> bool {
302        self.overlays.has_active()
303    }
304
305    /// Toggle threshold tuning overlay
306    pub fn toggle_threshold_tuning(&mut self) {
307        if self.overlays.threshold_tuning.visible {
308            self.overlays.threshold_tuning.visible = false;
309        } else {
310            self.show_threshold_tuning();
311        }
312    }
313
314    /// Show threshold tuning overlay and compute initial estimated matches
315    pub fn show_threshold_tuning(&mut self) {
316        // Close other overlays
317        self.overlays.close_all();
318
319        // Get total components count
320        let total = match self.mode {
321            AppMode::Diff => {
322                self.data
323                    .old_sbom
324                    .as_ref()
325                    .map_or(0, crate::model::NormalizedSbom::component_count)
326                    + self
327                        .data
328                        .new_sbom
329                        .as_ref()
330                        .map_or(0, crate::model::NormalizedSbom::component_count)
331            }
332            _ => 0,
333        };
334
335        // Initialize threshold tuning state
336        self.overlays.threshold_tuning =
337            ThresholdTuningState::new(self.data.matching_threshold, total);
338        self.update_threshold_preview();
339    }
340
341    /// Update the estimated matches preview based on current threshold
342    pub fn update_threshold_preview(&mut self) {
343        if !self.overlays.threshold_tuning.visible {
344            return;
345        }
346
347        // Estimate matches at current threshold
348        // For now, use a simple heuristic based on the diff result
349        let estimated = if let Some(ref result) = self.data.diff_result {
350            // Count modified components (matches) and estimate how threshold changes would affect
351            let current_matches = result.components.modified.len();
352            let threshold = self.overlays.threshold_tuning.threshold;
353            let base_threshold = self.data.matching_threshold;
354
355            // Simple estimation: lower threshold = more matches, higher = fewer
356            let ratio = if threshold < base_threshold {
357                (base_threshold - threshold).mul_add(2.0, 1.0)
358            } else {
359                (threshold - base_threshold).mul_add(-1.5, 1.0)
360            };
361            ((current_matches as f64 * ratio).max(0.0)) as usize
362        } else {
363            0
364        };
365
366        self.overlays
367            .threshold_tuning
368            .set_estimated_matches(estimated);
369    }
370
371    /// Apply the tuned threshold and potentially re-diff
372    pub fn apply_threshold(&mut self) {
373        self.data.matching_threshold = self.overlays.threshold_tuning.threshold;
374        self.overlays.threshold_tuning.visible = false;
375        self.set_status_message(format!(
376            "Threshold set to {:.0}% - Re-run diff to apply",
377            self.data.matching_threshold * 100.0
378        ));
379    }
380
381    /// Set a temporary status message
382    pub fn set_status_message(&mut self, msg: impl Into<String>) {
383        self.status_message = Some(msg.into());
384    }
385
386    /// Clear the status message.
387    ///
388    /// If `status_sticky` is set the message is kept for one extra keypress,
389    /// then cleared on the subsequent call.
390    pub fn clear_status_message(&mut self) {
391        if self.status_sticky {
392            self.status_sticky = false;
393        } else {
394            self.status_message = None;
395        }
396    }
397
398    /// Export the current diff to a file.
399    ///
400    /// The export is scoped to the active tab: e.g. if the user is on the
401    /// Vulnerabilities tab only vulnerability data is included.
402    pub fn export(&mut self, format: super::export::ExportFormat) {
403        use super::export::{
404            export_diff, export_view, tab_to_report_type, view_tab_to_report_type,
405        };
406        use crate::reports::ReportConfig;
407
408        let result = match self.mode {
409            AppMode::Diff => {
410                let report_type = tab_to_report_type(self.active_tab);
411                let config = ReportConfig::with_types(vec![report_type]);
412                if let (Some(diff_result), Some(old_sbom), Some(new_sbom)) = (
413                    &self.data.diff_result,
414                    &self.data.old_sbom,
415                    &self.data.new_sbom,
416                ) {
417                    export_diff(
418                        format,
419                        diff_result,
420                        old_sbom,
421                        new_sbom,
422                        None,
423                        &config,
424                        self.export_template.as_deref(),
425                    )
426                } else {
427                    self.set_status_message("No diff data to export");
428                    return;
429                }
430            }
431            AppMode::View => {
432                // Map diff TabKind to ViewTab for report type mapping
433                let report_type = match self.active_tab {
434                    super::TabKind::Tree => view_tab_to_report_type(crate::tui::ViewTab::Tree),
435                    super::TabKind::Vulnerabilities => {
436                        view_tab_to_report_type(crate::tui::ViewTab::Vulnerabilities)
437                    }
438                    super::TabKind::Licenses => {
439                        view_tab_to_report_type(crate::tui::ViewTab::Licenses)
440                    }
441                    super::TabKind::Dependencies => {
442                        view_tab_to_report_type(crate::tui::ViewTab::Dependencies)
443                    }
444                    _ => view_tab_to_report_type(crate::tui::ViewTab::Overview),
445                };
446                let config = ReportConfig::with_types(vec![report_type]);
447                if let Some(ref sbom) = self.data.sbom {
448                    export_view(format, sbom, None, &config, self.export_template.as_deref())
449                } else {
450                    self.set_status_message("No SBOM data available for export");
451                    return;
452                }
453            }
454            _ => {
455                self.set_status_message("Export not supported for this mode");
456                return;
457            }
458        };
459
460        if result.success {
461            self.last_export_path = Some(result.path.display().to_string());
462            self.set_status_message(result.message);
463            self.status_sticky = true;
464        } else {
465            self.set_status_message(format!("Export failed: {}", result.message));
466        }
467    }
468
469    /// Export compliance results from the active compliance tab
470    pub fn export_compliance(&mut self, format: super::export::ExportFormat) {
471        use super::export::export_compliance;
472
473        self.ensure_compliance_results();
474
475        // Determine which compliance results and selected standard to use
476        let selected_standard = self.diff_compliance_state().selected_standard;
477        let (results, selected) = if let Some(ref results) = self.data.new_compliance_results {
478            if !results.is_empty() {
479                (results, selected_standard)
480            } else if let Some(ref old_results) = self.data.old_compliance_results {
481                if old_results.is_empty() {
482                    self.set_status_message("No compliance results to export");
483                    return;
484                }
485                (old_results, selected_standard)
486            } else {
487                self.set_status_message("No compliance results to export");
488                return;
489            }
490        } else if let Some(ref old_results) = self.data.old_compliance_results {
491            if old_results.is_empty() {
492                self.set_status_message("No compliance results to export");
493                return;
494            }
495            (old_results, selected_standard)
496        } else {
497            self.set_status_message("No compliance results to export");
498            return;
499        };
500
501        let result = export_compliance(
502            format,
503            results,
504            selected,
505            None,
506            self.export_template.as_deref(),
507        );
508        if result.success {
509            self.last_export_path = Some(result.path.display().to_string());
510            self.set_status_message(result.message);
511            self.status_sticky = true;
512        } else {
513            self.set_status_message(format!("Export failed: {}", result.message));
514        }
515    }
516
517    /// Export matrix results to a file
518    pub fn export_matrix(&mut self, format: super::export::ExportFormat) {
519        use super::export::export_matrix;
520
521        let Some(ref matrix_result) = self.data.matrix_result else {
522            self.set_status_message("No matrix data to export");
523            return;
524        };
525
526        let result = export_matrix(format, matrix_result, self.export_template.as_deref());
527        if result.success {
528            self.last_export_path = Some(result.path.display().to_string());
529            self.set_status_message(result.message);
530            self.status_sticky = true;
531        } else {
532            self.set_status_message(format!("Export failed: {}", result.message));
533        }
534    }
535
536    // ========================================================================
537    // Compliance / Policy Checking
538    // ========================================================================
539
540    /// Run compliance check against the current policy
541    pub fn run_compliance_check(&mut self) {
542        use crate::tui::security::{SecurityPolicy, check_compliance};
543
544        let preset = self.compliance_state.policy_preset;
545
546        // Standards-based presets delegate to the quality::ComplianceChecker
547        if preset.is_standards_based() {
548            self.run_standards_compliance_check(preset);
549            return;
550        }
551
552        let policy = match preset {
553            super::app_states::PolicyPreset::Enterprise => SecurityPolicy::enterprise_default(),
554            super::app_states::PolicyPreset::Strict => SecurityPolicy::strict(),
555            super::app_states::PolicyPreset::Permissive => SecurityPolicy::permissive(),
556            // Standards-based presets handled above
557            _ => unreachable!(),
558        };
559
560        // Collect component data for compliance checking
561        let components = self.collect_compliance_data();
562
563        if components.is_empty() {
564            self.set_status_message("No components to check");
565            return;
566        }
567
568        let result = check_compliance(&policy, &components);
569        let passes = result.passes;
570        let score = result.score;
571        let violation_count = result.violations.len();
572
573        self.compliance_state.result = Some(result);
574        self.compliance_state.checked = true;
575        self.compliance_state.selected_violation = 0;
576
577        if passes {
578            self.set_status_message(format!("Policy: {} - PASS (score: {})", policy.name, score));
579        } else {
580            self.set_status_message(format!(
581                "Policy: {} - FAIL ({} violations, score: {})",
582                policy.name, violation_count, score
583            ));
584        }
585    }
586
587    /// Run a standards-based compliance check (CRA, NTIA, FDA) and convert
588    /// the result into a PolicyViolation-based `ComplianceResult` for unified display.
589    fn run_standards_compliance_check(&mut self, preset: super::app_states::PolicyPreset) {
590        use crate::quality::{ComplianceChecker, ViolationSeverity};
591        use crate::tui::security::{
592            ComplianceResult as PolicyResult, PolicySeverity, PolicyViolation,
593        };
594
595        let Some(level) = preset.compliance_level() else {
596            return;
597        };
598
599        // Find the SBOM to check (prefer new_sbom in diff mode, sbom in view mode)
600        let sbom = match self.mode {
601            AppMode::Diff => self.data.new_sbom.as_ref(),
602            _ => self.data.sbom.as_ref(),
603        };
604        let Some(sbom) = sbom else {
605            self.set_status_message("No SBOM loaded to check");
606            return;
607        };
608
609        let checker = ComplianceChecker::new(level);
610        let std_result = checker.check(sbom);
611
612        // Convert quality::Violation → PolicyViolation
613        let violations: Vec<PolicyViolation> = std_result
614            .violations
615            .iter()
616            .map(|v| {
617                let severity = match v.severity {
618                    ViolationSeverity::Error => PolicySeverity::High,
619                    ViolationSeverity::Warning => PolicySeverity::Medium,
620                    ViolationSeverity::Info => PolicySeverity::Low,
621                };
622                PolicyViolation {
623                    rule_name: v.requirement.clone(),
624                    severity,
625                    component: v.element.clone(),
626                    description: v.message.clone(),
627                    remediation: v.remediation_guidance().to_string(),
628                }
629            })
630            .collect();
631
632        // Calculate score: errors weigh 10pts, warnings 5pts, info 1pt
633        let penalty: u32 = violations
634            .iter()
635            .map(|v| match v.severity {
636                PolicySeverity::High | PolicySeverity::Critical => 10,
637                PolicySeverity::Medium => 5,
638                PolicySeverity::Low => 1,
639            })
640            .sum();
641        let score = 100u8.saturating_sub(penalty.min(100) as u8);
642
643        let passes = std_result.is_compliant;
644        let policy_name = format!("{} Compliance", preset.label());
645        let violation_count = violations.len();
646
647        let result = PolicyResult {
648            policy_name: policy_name.clone(),
649            components_checked: sbom.components.len(),
650            violations,
651            score,
652            passes,
653        };
654
655        self.compliance_state.result = Some(result);
656        self.compliance_state.checked = true;
657        self.compliance_state.selected_violation = 0;
658
659        if passes {
660            self.set_status_message(format!("{policy_name} - COMPLIANT (score: {score})"));
661        } else {
662            self.set_status_message(format!(
663                "{policy_name} - NON-COMPLIANT ({violation_count} violations, score: {score})"
664            ));
665        }
666    }
667
668    /// Collect component data for compliance checking
669    fn collect_compliance_data(&self) -> Vec<crate::tui::security::ComplianceComponentData> {
670        let mut components = Vec::new();
671
672        if self.mode == AppMode::Diff
673            && let Some(sbom) = &self.data.new_sbom
674        {
675            for comp in sbom.components.values() {
676                let licenses: Vec<String> = comp
677                    .licenses
678                    .declared
679                    .iter()
680                    .map(std::string::ToString::to_string)
681                    .collect();
682                let vulns: Vec<(String, String)> = comp
683                    .vulnerabilities
684                    .iter()
685                    .map(|v| {
686                        let severity = v.severity.as_ref().map_or_else(
687                            || "Unknown".to_string(),
688                            std::string::ToString::to_string,
689                        );
690                        (v.id.clone(), severity)
691                    })
692                    .collect();
693                components.push((comp.name.clone(), comp.version.clone(), licenses, vulns));
694            }
695        }
696
697        components
698    }
699
700    /// Toggle compliance view details
701    pub const fn toggle_compliance_details(&mut self) {
702        self.compliance_state.toggle_details();
703    }
704
705    /// Cycle to next policy preset
706    pub fn next_policy(&mut self) {
707        self.compliance_state.toggle_policy();
708        // Re-run check with new policy if already checked
709        if self.compliance_state.checked {
710            self.run_compliance_check();
711        }
712    }
713
714    // ========================================================================
715    // ViewState trait integration methods
716    // ========================================================================
717
718    /// Get the current view mode for `ViewContext`
719    #[must_use]
720    pub const fn view_mode(&self) -> super::traits::ViewMode {
721        super::traits::ViewMode::from_app_mode(self.mode)
722    }
723
724    /// Handle an `EventResult` from a view state
725    ///
726    /// This method processes the result of a view's event handling,
727    /// performing navigation, showing overlays, or setting status messages.
728    pub fn handle_event_result(&mut self, result: super::traits::EventResult) {
729        use super::traits::EventResult;
730
731        match result {
732            EventResult::Consumed | EventResult::Ignored => {
733                // Event was handled, or not handled -- nothing else to do
734            }
735            EventResult::NavigateTo(target) => {
736                self.navigate_to_target(target);
737            }
738            EventResult::Exit => {
739                self.should_quit = true;
740            }
741            EventResult::ShowOverlay(kind) => {
742                self.show_overlay_kind(&kind);
743            }
744            EventResult::StatusMessage(msg) => {
745                self.set_status_message(msg);
746            }
747        }
748    }
749
750    /// Show an overlay based on the kind
751    fn show_overlay_kind(&mut self, kind: &super::traits::OverlayKind) {
752        use super::traits::OverlayKind;
753
754        // Close any existing overlays first
755        self.overlays.close_all();
756
757        match kind {
758            OverlayKind::Help => self.overlays.show_help = true,
759            OverlayKind::Export => self.overlays.show_export = true,
760            OverlayKind::Legend => self.overlays.show_legend = true,
761            OverlayKind::Search => {
762                self.overlays.search.active = true;
763                self.overlays.search.query.clear();
764            }
765            OverlayKind::Shortcuts => self.overlays.shortcuts.visible = true,
766        }
767    }
768
769    /// Get the current tab as a `TabTarget`
770    #[must_use]
771    pub const fn current_tab_target(&self) -> super::traits::TabTarget {
772        super::traits::TabTarget::from_tab_kind(self.active_tab)
773    }
774
775    /// Get keyboard shortcuts for the current view
776    #[must_use]
777    pub fn current_shortcuts(&self) -> Vec<super::traits::Shortcut> {
778        use super::traits::Shortcut;
779
780        let mut shortcuts = vec![
781            Shortcut::primary("?", "Help"),
782            Shortcut::primary("q", "Quit"),
783            Shortcut::primary("Tab", "Next tab"),
784            Shortcut::primary("/", "Search"),
785        ];
786
787        // Add view-specific shortcuts
788        match self.active_tab {
789            TabKind::Components => {
790                shortcuts.push(Shortcut::new("f", "Filter"));
791                shortcuts.push(Shortcut::new("s", "Sort"));
792                shortcuts.push(Shortcut::new("m", "Multi-select"));
793            }
794            TabKind::Dependencies => {
795                shortcuts.push(Shortcut::new("t", "Transitive"));
796                shortcuts.push(Shortcut::new("+/-", "Depth"));
797            }
798            TabKind::Vulnerabilities => {
799                shortcuts.push(Shortcut::new("f", "Filter"));
800                shortcuts.push(Shortcut::new("s", "Sort"));
801            }
802            TabKind::Quality => {
803                shortcuts.push(Shortcut::new("v", "View mode"));
804            }
805            _ => {}
806        }
807
808        shortcuts
809    }
810
811    // ========================================================================
812    // ViewState inner state accessors
813    // ========================================================================
814
815    pub(crate) fn quality_state(&self) -> &super::app_states::QualityState {
816        self.quality_view.inner()
817    }
818    pub(crate) fn quality_state_mut(&mut self) -> &mut super::app_states::QualityState {
819        self.quality_view.inner_mut()
820    }
821
822    pub(crate) fn graph_changes_state(&self) -> &super::app_states::GraphChangesState {
823        self.graph_changes_view.inner()
824    }
825    pub(crate) fn graph_changes_state_mut(&mut self) -> &mut super::app_states::GraphChangesState {
826        self.graph_changes_view.inner_mut()
827    }
828
829    pub(crate) fn licenses_state(&self) -> &super::app_states::LicensesState {
830        self.licenses_view.inner()
831    }
832    pub(crate) fn licenses_state_mut(&mut self) -> &mut super::app_states::LicensesState {
833        self.licenses_view.inner_mut()
834    }
835
836    pub(crate) fn diff_compliance_state(&self) -> &super::app_states::DiffComplianceState {
837        self.compliance_view.inner()
838    }
839    pub(crate) fn components_state(&self) -> &ComponentsState {
840        self.components_view.inner()
841    }
842    pub(crate) fn components_state_mut(&mut self) -> &mut ComponentsState {
843        self.components_view.inner_mut()
844    }
845
846    pub(crate) fn vulnerabilities_state(&self) -> &super::app_states::VulnerabilitiesState {
847        self.vulnerabilities_view.inner()
848    }
849    pub(crate) fn vulnerabilities_state_mut(
850        &mut self,
851    ) -> &mut super::app_states::VulnerabilitiesState {
852        self.vulnerabilities_view.inner_mut()
853    }
854
855    pub(crate) fn side_by_side_state(&self) -> &super::app_states::SideBySideState {
856        self.sidebyside_view.inner()
857    }
858    pub(crate) fn side_by_side_state_mut(&mut self) -> &mut super::app_states::SideBySideState {
859        self.sidebyside_view.inner_mut()
860    }
861
862    pub(crate) fn dependencies_state(&self) -> &DependenciesState {
863        self.dependencies_view.inner()
864    }
865    pub(crate) fn dependencies_state_mut(&mut self) -> &mut DependenciesState {
866        self.dependencies_view.inner_mut()
867    }
868
869    pub(crate) fn source_state(&self) -> &crate::tui::app_states::SourceDiffState {
870        self.source_view.inner()
871    }
872    pub(crate) fn source_state_mut(&mut self) -> &mut crate::tui::app_states::SourceDiffState {
873        self.source_view.inner_mut()
874    }
875
876    // ========================================================================
877    // Pre-render preparation
878    // ========================================================================
879
880    /// Prepare mutable state that render functions previously computed inline.
881    ///
882    /// Call this once per frame, **before** creating a [`RenderContext`].
883    /// After this method returns, all render functions can operate on `&App`
884    /// (read-only) instead of `&mut App`.
885    ///
886    /// [`RenderContext`]: super::render_context::RenderContext
887    pub fn prepare_render(&mut self) {
888        // 1. Graph cache for dependencies (was inline in render_dependencies)
889        super::views::update_graph_cache(self.dependencies_view.inner_mut(), &self.data, self.mode);
890
891        // 2. Compliance results (was inline in render_diff_compliance)
892        self.ensure_compliance_results();
893
894        // 3. Vulnerability cache (was inline in render_vulnerabilities)
895        if matches!(self.mode, AppMode::Diff | AppMode::View) {
896            self.ensure_vulnerability_cache();
897        }
898
899        // 4. Component totals (was inline in render_components)
900        let comp_filter = self.components_state().filter;
901        let comp_total = match self.mode {
902            AppMode::Diff | AppMode::View => self.diff_component_count(comp_filter),
903            AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => 0,
904        };
905        self.components_state_mut().total = comp_total;
906        self.components_state_mut().clamp_selection();
907
908        // 5. Vulnerability totals (was inline in render_vulnerabilities)
909        self.prepare_vulnerability_totals();
910
911        // 6. Graph changes total (was inline in render_graph_changes)
912        let graph_total = self
913            .data
914            .diff_result
915            .as_ref()
916            .map_or(0, |r| r.graph_changes.len());
917        self.graph_changes_state_mut().set_total(graph_total);
918
919        // 7. Dependencies breadcrumbs (was inline in render_dependencies)
920        self.dependencies_state_mut().update_breadcrumbs();
921
922        // 8. License totals (was inline in render_licenses)
923        self.prepare_license_totals();
924
925        // 9. Side-by-side totals (was inline in render_sidebyside)
926        // Totals are set by set_totals in the render function, which is now
927        // hoisted here using cached aligned rows or diff data.
928        if matches!(self.mode, AppMode::Diff | AppMode::View)
929            && let Some(ref result) = self.data.diff_result
930        {
931            let left = result.components.removed.len() + result.components.modified.len();
932            let right = result.components.added.len() + result.components.modified.len();
933            self.side_by_side_state_mut().set_totals(left, right);
934        }
935
936        // 10. Quality recommendation totals
937        let rec_total = self
938            .data
939            .new_quality
940            .as_ref()
941            .or(self.data.old_quality.as_ref())
942            .map_or(0, |r| r.recommendations.len());
943        self.quality_state_mut().total_recommendations = rec_total;
944    }
945
946    /// Pre-compute license totals for rendering.
947    fn prepare_license_totals(&mut self) {
948        match self.mode {
949            AppMode::Diff | AppMode::View => {
950                if let Some(ref result) = self.data.diff_result {
951                    let focus_left = self.licenses_state().focus_left;
952                    let risk_filter = self.licenses_state().risk_filter;
953                    let count = if focus_left {
954                        Self::filtered_license_count(&result.licenses.new_licenses, risk_filter)
955                    } else {
956                        Self::filtered_license_count(&result.licenses.removed_licenses, risk_filter)
957                    };
958                    self.licenses_state_mut().total = count;
959                }
960            }
961            AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => {
962                self.licenses_state_mut().total = 0;
963            }
964        }
965        self.licenses_state_mut().clamp_selection();
966    }
967
968    /// Count licenses after applying risk filter.
969    fn filtered_license_count(
970        licenses: &[crate::diff::LicenseChange],
971        risk_filter: Option<crate::tui::app_states::LicenseRiskFilter>,
972    ) -> usize {
973        use crate::tui::license_utils::{LicenseInfo, RiskLevel};
974        if let Some(min_risk) = risk_filter {
975            let min_level = match min_risk {
976                crate::tui::app_states::LicenseRiskFilter::Low => RiskLevel::Low,
977                crate::tui::app_states::LicenseRiskFilter::Medium => RiskLevel::Medium,
978                crate::tui::app_states::LicenseRiskFilter::High => RiskLevel::High,
979                crate::tui::app_states::LicenseRiskFilter::Critical => RiskLevel::Critical,
980            };
981            licenses
982                .iter()
983                .filter(|l| LicenseInfo::from_spdx(&l.license).risk_level >= min_level)
984                .count()
985        } else {
986            licenses.len()
987        }
988    }
989
990    /// Pre-compute vulnerability totals for rendering.
991    fn prepare_vulnerability_totals(&mut self) {
992        let vuln_total = match self.mode {
993            AppMode::Diff | AppMode::View => self.diff_vulnerability_count(),
994            AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => 0,
995        };
996        self.vulnerabilities_state_mut().total = vuln_total;
997        self.vulnerabilities_state_mut().clamp_selection();
998
999        // Grouped mode adjusts total to match visible render items
1000        if self.vulnerabilities_state().group_by_component {
1001            let grouped_count = super::views::count_grouped_items(self);
1002            self.vulnerabilities_state_mut().total = grouped_count;
1003            self.vulnerabilities_state_mut().clamp_selection();
1004        }
1005    }
1006}
1007
1008/// Application mode
1009#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010pub enum AppMode {
1011    /// Comparing two SBOMs
1012    Diff,
1013    /// Exploring a single SBOM
1014    View,
1015    /// 1:N multi-diff comparison
1016    MultiDiff,
1017    /// Timeline analysis
1018    Timeline,
1019    /// N×N matrix comparison
1020    Matrix,
1021}
1022
1023/// Tab kinds
1024#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1025pub enum TabKind {
1026    Summary,
1027    /// Single SBOM overview (View mode)
1028    Overview,
1029    /// Hierarchical component browser (View mode)
1030    Tree,
1031    Components,
1032    Dependencies,
1033    Licenses,
1034    Vulnerabilities,
1035    Quality,
1036    Compliance,
1037    SideBySide,
1038    GraphChanges,
1039    Source,
1040}
1041
1042impl TabKind {
1043    #[must_use]
1044    pub const fn title(&self) -> &'static str {
1045        match self {
1046            Self::Summary => "Summary",
1047            Self::Overview => "Overview",
1048            Self::Tree => "Tree",
1049            Self::Components => "Components",
1050            Self::Dependencies => "Dependencies",
1051            Self::Licenses => "Licenses",
1052            Self::Vulnerabilities => "Vulnerabilities",
1053            Self::Quality => "Quality",
1054            Self::Compliance => "Compliance",
1055            Self::SideBySide => "Side-by-Side",
1056            Self::GraphChanges => "Graph",
1057            Self::Source => "Source",
1058        }
1059    }
1060
1061    /// Stable string identifier for persistence.
1062    #[must_use]
1063    pub const fn as_str(&self) -> &'static str {
1064        match self {
1065            Self::Summary => "summary",
1066            Self::Overview => "overview",
1067            Self::Tree => "tree",
1068            Self::Components => "components",
1069            Self::Dependencies => "dependencies",
1070            Self::Licenses => "licenses",
1071            Self::Vulnerabilities => "vulnerabilities",
1072            Self::Quality => "quality",
1073            Self::Compliance => "compliance",
1074            Self::SideBySide => "side-by-side",
1075            Self::GraphChanges => "graph",
1076            Self::Source => "source",
1077        }
1078    }
1079
1080    /// Parse from a persisted string identifier.
1081    #[must_use]
1082    pub fn from_str_opt(s: &str) -> Option<Self> {
1083        match s {
1084            "summary" => Some(Self::Summary),
1085            "overview" => Some(Self::Overview),
1086            "tree" => Some(Self::Tree),
1087            "components" => Some(Self::Components),
1088            "dependencies" => Some(Self::Dependencies),
1089            "licenses" => Some(Self::Licenses),
1090            "vulnerabilities" => Some(Self::Vulnerabilities),
1091            "quality" => Some(Self::Quality),
1092            "compliance" => Some(Self::Compliance),
1093            "side-by-side" => Some(Self::SideBySide),
1094            "graph" => Some(Self::GraphChanges),
1095            "source" => Some(Self::Source),
1096            _ => None,
1097        }
1098    }
1099
1100    /// Returns the tabs visible in a given mode.
1101    #[must_use]
1102    pub const fn tabs_for_mode(mode: AppMode) -> &'static [TabKind] {
1103        match mode {
1104            AppMode::View => &[
1105                TabKind::Overview,
1106                TabKind::Tree,
1107                TabKind::Vulnerabilities,
1108                TabKind::Licenses,
1109                TabKind::Dependencies,
1110                TabKind::Quality,
1111                TabKind::Compliance,
1112                TabKind::Source,
1113            ],
1114            AppMode::Diff => &[
1115                TabKind::Summary,
1116                TabKind::Components,
1117                TabKind::Dependencies,
1118                TabKind::Licenses,
1119                TabKind::Vulnerabilities,
1120                TabKind::Quality,
1121                TabKind::Compliance,
1122                TabKind::SideBySide,
1123                TabKind::GraphChanges,
1124                TabKind::Source,
1125            ],
1126            // MultiDiff/Timeline/Matrix use full-screen renders, not tabs
1127            AppMode::MultiDiff | AppMode::Timeline | AppMode::Matrix => &[],
1128        }
1129    }
1130}