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