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