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