Skip to main content

sbom_tools/tui/
app_impl_items.rs

1//! Item list building methods for App.
2
3use super::app::App;
4use super::app_states::{
5    ChangeType, ComponentFilter, DiffVulnItem, DiffVulnStatus, VulnFilter, sort_component_changes,
6    sort_components,
7};
8use crate::diff::SlaStatus;
9
10/// Check whether a component matches the active EOL filter.
11fn eol_filter_matches(comp: &crate::model::Component, filter: ComponentFilter) -> bool {
12    use crate::model::EolStatus;
13    match filter {
14        ComponentFilter::EolOnly => comp
15            .eol
16            .as_ref()
17            .is_some_and(|e| e.status == EolStatus::EndOfLife),
18        ComponentFilter::EolRisk => comp.eol.as_ref().is_some_and(|e| {
19            matches!(
20                e.status,
21                EolStatus::EndOfLife | EolStatus::ApproachingEol | EolStatus::SecurityOnly
22            )
23        }),
24        _ => true,
25    }
26}
27
28/// Check whether a vulnerability matches the active filter.
29fn matches_vuln_filter(vuln: &crate::diff::VulnerabilityDetail, filter: VulnFilter) -> bool {
30    match filter {
31        VulnFilter::Critical => vuln.severity == "Critical",
32        VulnFilter::High => vuln.severity == "High" || vuln.severity == "Critical",
33        VulnFilter::Kev => vuln.is_kev,
34        VulnFilter::Direct => vuln.component_depth == Some(1),
35        VulnFilter::Transitive => vuln.component_depth.is_some_and(|d| d > 1),
36        VulnFilter::VexActionable => vuln.is_vex_actionable(),
37        _ => true,
38    }
39}
40
41/// Determine which vulnerability categories (introduced, resolved, persistent)
42/// should be included for a given filter.
43const fn vuln_category_includes(filter: VulnFilter) -> (bool, bool, bool) {
44    let introduced = matches!(
45        filter,
46        VulnFilter::All
47            | VulnFilter::Introduced
48            | VulnFilter::Critical
49            | VulnFilter::High
50            | VulnFilter::Kev
51            | VulnFilter::Direct
52            | VulnFilter::Transitive
53            | VulnFilter::VexActionable
54    );
55    let resolved = matches!(
56        filter,
57        VulnFilter::All
58            | VulnFilter::Resolved
59            | VulnFilter::Critical
60            | VulnFilter::High
61            | VulnFilter::Kev
62            | VulnFilter::Direct
63            | VulnFilter::Transitive
64            | VulnFilter::VexActionable
65    );
66    let persistent = matches!(
67        filter,
68        VulnFilter::All
69            | VulnFilter::Critical
70            | VulnFilter::High
71            | VulnFilter::Kev
72            | VulnFilter::Direct
73            | VulnFilter::Transitive
74            | VulnFilter::VexActionable
75    );
76    (introduced, resolved, persistent)
77}
78
79impl App {
80    /// Find component index in diff mode using the same ordering as the components view
81    pub(super) fn find_component_index_all(
82        &self,
83        name: &str,
84        change_type: Option<ChangeType>,
85        version: Option<&str>,
86    ) -> Option<usize> {
87        let name_lower = name.to_lowercase();
88        let version_lower = version.map(str::to_lowercase);
89
90        self.diff_component_items(ComponentFilter::All)
91            .iter()
92            .position(|comp| {
93                let matches_type = change_type.is_none_or(|t| match t {
94                    ChangeType::Added => comp.change_type == crate::diff::ChangeType::Added,
95                    ChangeType::Removed => comp.change_type == crate::diff::ChangeType::Removed,
96                    ChangeType::Modified => comp.change_type == crate::diff::ChangeType::Modified,
97                });
98                let matches_name = comp.name.to_lowercase() == name_lower;
99                let matches_version = version_lower.as_ref().is_none_or(|v| {
100                    comp.new_version.as_deref().map(str::to_lowercase) == Some(v.clone())
101                        || comp.old_version.as_deref().map(str::to_lowercase) == Some(v.clone())
102                });
103
104                matches_type && matches_name && matches_version
105            })
106    }
107
108    /// Build diff-mode components list in the same order as the table.
109    #[must_use]
110    pub fn diff_component_items(
111        &self,
112        filter: ComponentFilter,
113    ) -> Vec<&crate::diff::ComponentChange> {
114        let Some(diff) = self.data.diff_result.as_ref() else {
115            return Vec::new();
116        };
117
118        let mut items = Vec::new();
119        // EOL filters are view-only; in diff mode they show all
120        let effective = if filter.is_view_filter() && filter != ComponentFilter::All {
121            ComponentFilter::All
122        } else {
123            filter
124        };
125        if effective == ComponentFilter::All || effective == ComponentFilter::Added {
126            items.extend(diff.components.added.iter());
127        }
128        if effective == ComponentFilter::All || effective == ComponentFilter::Removed {
129            items.extend(diff.components.removed.iter());
130        }
131        if effective == ComponentFilter::All || effective == ComponentFilter::Modified {
132            items.extend(diff.components.modified.iter());
133        }
134
135        sort_component_changes(&mut items, self.tabs.components.sort_by);
136        items
137    }
138
139    /// Count diff-mode components matching the filter (without building full list).
140    /// More efficient than `diff_component_items().len()` for just getting a count.
141    #[must_use]
142    pub fn diff_component_count(&self, filter: ComponentFilter) -> usize {
143        let Some(diff) = self.data.diff_result.as_ref() else {
144            return 0;
145        };
146
147        match filter {
148            ComponentFilter::All | ComponentFilter::EolOnly | ComponentFilter::EolRisk => {
149                diff.components.added.len()
150                    + diff.components.removed.len()
151                    + diff.components.modified.len()
152            }
153            ComponentFilter::Added => diff.components.added.len(),
154            ComponentFilter::Removed => diff.components.removed.len(),
155            ComponentFilter::Modified => diff.components.modified.len(),
156        }
157    }
158
159    /// Count view-mode components (without building full list).
160    pub fn view_component_count(&self) -> usize {
161        let Some(sbom) = self.data.sbom.as_ref() else {
162            return 0;
163        };
164        let filter = self.tabs.components.filter;
165        if filter == ComponentFilter::All || !filter.is_view_filter() {
166            sbom.component_count()
167        } else {
168            sbom.components
169                .values()
170                .filter(|c| eol_filter_matches(c, filter))
171                .count()
172        }
173    }
174
175    /// Build view-mode components list in the same order as the table.
176    #[must_use]
177    pub fn view_component_items(&self) -> Vec<&crate::model::Component> {
178        let Some(sbom) = self.data.sbom.as_ref() else {
179            return Vec::new();
180        };
181        let filter = self.tabs.components.filter;
182        let mut items: Vec<_> = if filter == ComponentFilter::All || !filter.is_view_filter() {
183            sbom.components.values().collect()
184        } else {
185            sbom.components
186                .values()
187                .filter(|c| eol_filter_matches(c, filter))
188                .collect()
189        };
190        sort_components(&mut items, self.tabs.components.sort_by);
191        items
192    }
193
194    /// Build diff-mode vulnerabilities list in the same order as the table.
195    #[must_use]
196    pub fn diff_vulnerability_items(&self) -> Vec<DiffVulnItem<'_>> {
197        let Some(diff) = self.data.diff_result.as_ref() else {
198            return Vec::new();
199        };
200        let filter = self.tabs.vulnerabilities.filter;
201        let sort = &self.tabs.vulnerabilities.sort_by;
202        let mut all_vulns: Vec<DiffVulnItem<'_>> = Vec::new();
203
204        let (include_introduced, include_resolved, include_persistent) =
205            vuln_category_includes(filter);
206
207        if include_introduced {
208            for vuln in &diff.vulnerabilities.introduced {
209                if !matches_vuln_filter(vuln, filter) {
210                    continue;
211                }
212                all_vulns.push(DiffVulnItem {
213                    status: DiffVulnStatus::Introduced,
214                    vuln,
215                });
216            }
217        }
218
219        if include_resolved {
220            for vuln in &diff.vulnerabilities.resolved {
221                if !matches_vuln_filter(vuln, filter) {
222                    continue;
223                }
224                all_vulns.push(DiffVulnItem {
225                    status: DiffVulnStatus::Resolved,
226                    vuln,
227                });
228            }
229        }
230
231        if include_persistent {
232            for vuln in &diff.vulnerabilities.persistent {
233                if !matches_vuln_filter(vuln, filter) {
234                    continue;
235                }
236                all_vulns.push(DiffVulnItem {
237                    status: DiffVulnStatus::Persistent,
238                    vuln,
239                });
240            }
241        }
242
243        // Get blast radius data for FixUrgency sorting
244        let reverse_graph = &self.tabs.dependencies.cached_reverse_graph;
245
246        match sort {
247            super::app_states::VulnSort::Severity => {
248                all_vulns.sort_by(|a, b| {
249                    let sev_order = |s: &str| match s {
250                        "Critical" => 0,
251                        "High" => 1,
252                        "Medium" => 2,
253                        "Low" => 3,
254                        _ => 4,
255                    };
256                    sev_order(&a.vuln.severity).cmp(&sev_order(&b.vuln.severity))
257                });
258            }
259            super::app_states::VulnSort::Id => {
260                all_vulns.sort_by(|a, b| a.vuln.id.cmp(&b.vuln.id));
261            }
262            super::app_states::VulnSort::Component => {
263                all_vulns.sort_by(|a, b| a.vuln.component_name.cmp(&b.vuln.component_name));
264            }
265            super::app_states::VulnSort::FixUrgency => {
266                // Sort by fix urgency (severity × blast radius)
267                all_vulns.sort_by(|a, b| {
268                    let urgency_a = calculate_vuln_urgency(a.vuln, reverse_graph);
269                    let urgency_b = calculate_vuln_urgency(b.vuln, reverse_graph);
270                    urgency_b.cmp(&urgency_a) // Higher urgency first
271                });
272            }
273            super::app_states::VulnSort::CvssScore => {
274                // Sort by CVSS score (highest first)
275                all_vulns.sort_by(|a, b| {
276                    let score_a = a.vuln.cvss_score.unwrap_or(0.0);
277                    let score_b = b.vuln.cvss_score.unwrap_or(0.0);
278                    score_b
279                        .partial_cmp(&score_a)
280                        .unwrap_or(std::cmp::Ordering::Equal)
281                });
282            }
283            super::app_states::VulnSort::SlaUrgency => {
284                // Sort by SLA urgency (most overdue first)
285                all_vulns.sort_by(|a, b| {
286                    let sla_a = sla_sort_key(a.vuln);
287                    let sla_b = sla_sort_key(b.vuln);
288                    sla_a.cmp(&sla_b)
289                });
290            }
291        }
292
293        all_vulns
294    }
295
296    /// Ensure the vulnerability cache is populated for the current filter+sort.
297    ///
298    /// Call this before `diff_vulnerability_items_from_cache()` to guarantee
299    /// the cache is warm.
300    pub fn ensure_vulnerability_cache(&mut self) {
301        let current_key = (
302            self.tabs.vulnerabilities.filter,
303            self.tabs.vulnerabilities.sort_by,
304        );
305
306        if self.tabs.vulnerabilities.cached_key == Some(current_key)
307            && !self.tabs.vulnerabilities.cached_indices.is_empty()
308        {
309            return; // Cache is warm
310        }
311
312        // Cache miss: compute full list, extract stable indices, then drop items
313        let items = self.diff_vulnerability_items();
314        let indices: Vec<(DiffVulnStatus, usize)> =
315            self.data
316                .diff_result
317                .as_ref()
318                .map_or_else(Vec::new, |diff| {
319                    items
320                        .iter()
321                        .filter_map(|item| {
322                            let list = match item.status {
323                                DiffVulnStatus::Introduced => &diff.vulnerabilities.introduced,
324                                DiffVulnStatus::Resolved => &diff.vulnerabilities.resolved,
325                                DiffVulnStatus::Persistent => &diff.vulnerabilities.persistent,
326                            };
327                            // Find the index by pointer identity
328                            let ptr = item.vuln as *const crate::diff::VulnerabilityDetail;
329                            list.iter()
330                                .position(|v| std::ptr::eq(v, ptr))
331                                .map(|idx| (item.status, idx))
332                        })
333                        .collect()
334                });
335        drop(items);
336
337        self.tabs.vulnerabilities.cached_key = Some(current_key);
338        self.tabs.vulnerabilities.cached_indices = indices;
339    }
340
341    /// Reconstruct vulnerability items from the cache (cheap pointer lookups).
342    ///
343    /// Panics if the cache has not been populated. Call `ensure_vulnerability_cache()`
344    /// first.
345    #[must_use]
346    pub fn diff_vulnerability_items_from_cache(&self) -> Vec<DiffVulnItem<'_>> {
347        let Some(diff) = self.data.diff_result.as_ref() else {
348            return Vec::new();
349        };
350        self.tabs
351            .vulnerabilities
352            .cached_indices
353            .iter()
354            .filter_map(|(status, idx)| {
355                let vuln = match status {
356                    DiffVulnStatus::Introduced => diff.vulnerabilities.introduced.get(*idx),
357                    DiffVulnStatus::Resolved => diff.vulnerabilities.resolved.get(*idx),
358                    DiffVulnStatus::Persistent => diff.vulnerabilities.persistent.get(*idx),
359                }?;
360                Some(DiffVulnItem {
361                    status: *status,
362                    vuln,
363                })
364            })
365            .collect()
366    }
367
368    /// Count diff-mode vulnerabilities matching the current filter (without building full list).
369    /// More efficient than `diff_vulnerability_items().len()` for just getting a count.
370    #[must_use]
371    pub fn diff_vulnerability_count(&self) -> usize {
372        let Some(diff) = self.data.diff_result.as_ref() else {
373            return 0;
374        };
375        let filter = self.tabs.vulnerabilities.filter;
376
377        let (include_introduced, include_resolved, include_persistent) =
378            vuln_category_includes(filter);
379
380        let mut count = 0;
381        if include_introduced {
382            count += diff
383                .vulnerabilities
384                .introduced
385                .iter()
386                .filter(|v| matches_vuln_filter(v, filter))
387                .count();
388        }
389        if include_resolved {
390            count += diff
391                .vulnerabilities
392                .resolved
393                .iter()
394                .filter(|v| matches_vuln_filter(v, filter))
395                .count();
396        }
397        if include_persistent {
398            count += diff
399                .vulnerabilities
400                .persistent
401                .iter()
402                .filter(|v| matches_vuln_filter(v, filter))
403                .count();
404        }
405        count
406    }
407
408    /// Find a vulnerability index based on the current filter/sort settings
409    pub(super) fn find_vulnerability_index(&self, id: &str) -> Option<usize> {
410        self.diff_vulnerability_items()
411            .iter()
412            .position(|item| item.vuln.id == id)
413    }
414
415    // ========================================================================
416    // Index access methods for O(1) lookups
417    // ========================================================================
418
419    /// Get the sort key for a component in the new SBOM (diff mode).
420    ///
421    /// Returns pre-computed lowercase strings to avoid repeated allocations during sorting.
422    #[must_use]
423    pub fn get_new_sbom_sort_key(
424        &self,
425        id: &crate::model::CanonicalId,
426    ) -> Option<&crate::model::ComponentSortKey> {
427        self.data
428            .new_sbom_index
429            .as_ref()
430            .and_then(|idx| idx.sort_key(id))
431    }
432
433    /// Get the sort key for a component in the old SBOM (diff mode).
434    #[must_use]
435    pub fn get_old_sbom_sort_key(
436        &self,
437        id: &crate::model::CanonicalId,
438    ) -> Option<&crate::model::ComponentSortKey> {
439        self.data
440            .old_sbom_index
441            .as_ref()
442            .and_then(|idx| idx.sort_key(id))
443    }
444
445    /// Get the sort key for a component in the single SBOM (view mode).
446    #[must_use]
447    pub fn get_sbom_sort_key(
448        &self,
449        id: &crate::model::CanonicalId,
450    ) -> Option<&crate::model::ComponentSortKey> {
451        self.data
452            .sbom_index
453            .as_ref()
454            .and_then(|idx| idx.sort_key(id))
455    }
456
457    /// Get dependencies of a component using the cached index (O(k) instead of O(edges)).
458    #[must_use]
459    pub fn get_dependencies_indexed(
460        &self,
461        id: &crate::model::CanonicalId,
462    ) -> Vec<&crate::model::DependencyEdge> {
463        if let (Some(sbom), Some(idx)) = (&self.data.new_sbom, &self.data.new_sbom_index) {
464            idx.dependencies_of(id, &sbom.edges)
465        } else if let (Some(sbom), Some(idx)) = (&self.data.sbom, &self.data.sbom_index) {
466            idx.dependencies_of(id, &sbom.edges)
467        } else {
468            Vec::new()
469        }
470    }
471
472    /// Get dependents of a component using the cached index (O(k) instead of O(edges)).
473    #[must_use]
474    pub fn get_dependents_indexed(
475        &self,
476        id: &crate::model::CanonicalId,
477    ) -> Vec<&crate::model::DependencyEdge> {
478        if let (Some(sbom), Some(idx)) = (&self.data.new_sbom, &self.data.new_sbom_index) {
479            idx.dependents_of(id, &sbom.edges)
480        } else if let (Some(sbom), Some(idx)) = (&self.data.sbom, &self.data.sbom_index) {
481            idx.dependents_of(id, &sbom.edges)
482        } else {
483            Vec::new()
484        }
485    }
486}
487
488/// Calculate fix urgency for a vulnerability based on severity and blast radius
489fn calculate_vuln_urgency(
490    vuln: &crate::diff::VulnerabilityDetail,
491    reverse_graph: &std::collections::HashMap<String, Vec<String>>,
492) -> u8 {
493    use crate::tui::security::{calculate_fix_urgency, severity_to_rank};
494
495    let severity_rank = severity_to_rank(&vuln.severity);
496    let cvss_score = vuln.cvss_score.unwrap_or(0.0);
497
498    // Calculate blast radius for affected component
499    let mut blast_radius = 0usize;
500    if let Some(direct_deps) = reverse_graph.get(&vuln.component_name) {
501        blast_radius = direct_deps.len();
502        // Add transitive count (simplified - just use direct for performance)
503        for dep in direct_deps {
504            if let Some(transitive) = reverse_graph.get(dep) {
505                blast_radius += transitive.len();
506            }
507        }
508    }
509
510    calculate_fix_urgency(severity_rank, blast_radius, cvss_score)
511}
512
513/// Calculate SLA sort key for a vulnerability (lower = more urgent)
514fn sla_sort_key(vuln: &crate::diff::VulnerabilityDetail) -> i64 {
515    match vuln.sla_status() {
516        SlaStatus::Overdue(days) => -(days + crate::tui::constants::SLA_OVERDUE_SORT_OFFSET), // Most urgent (very negative)
517        SlaStatus::DueSoon(days) | SlaStatus::OnTrack(days) => days,
518        SlaStatus::NoDueDate => i64::MAX,
519    }
520}