Skip to main content

sbom_tools/tui/
app_impl_search.rs

1//! Search-related methods for App.
2
3use super::app::{App, TabKind};
4use super::app_states::{
5    ChangeType, ComponentFilter, DiffSearchResult, SearchMode, VulnChangeType, VulnFilter, VulnSort,
6};
7
8impl App {
9    /// Start searching
10    pub fn start_search(&mut self) {
11        self.overlays.search.active = true;
12        self.overlays.search.clear();
13        self.overlays.show_help = false;
14        self.overlays.show_export = false;
15        self.overlays.show_legend = false;
16    }
17
18    /// Stop searching
19    pub const fn stop_search(&mut self) {
20        self.overlays.search.active = false;
21    }
22
23    /// Add character to search query
24    pub fn search_push(&mut self, c: char) {
25        self.overlays.search.push_char(c);
26    }
27
28    /// Remove character from search query
29    pub fn search_pop(&mut self) {
30        self.overlays.search.pop_char();
31    }
32
33    /// Execute search with current query
34    pub fn execute_search(&mut self) {
35        if self.overlays.search.query.len() < 2 {
36            self.overlays.search.results.clear();
37            return;
38        }
39
40        let query = &self.overlays.search.query;
41        let query_lower = query.to_lowercase();
42        let search_mode = self.overlays.search.mode;
43
44        // Build a regex matcher when in regex mode; show error on invalid patterns.
45        let regex_matcher = if search_mode == SearchMode::Regex {
46            match regex::RegexBuilder::new(query)
47                .case_insensitive(true)
48                .build()
49            {
50                Ok(re) => {
51                    self.overlays.search.search_error = None;
52                    Some(re)
53                }
54                Err(e) => {
55                    self.overlays.search.search_error = Some(format!("Invalid regex: {e}"));
56                    self.overlays.search.results.clear();
57                    return;
58                }
59            }
60        } else {
61            self.overlays.search.search_error = None;
62            None
63        };
64
65        // Closure that performs the appropriate match depending on mode.
66        let matches_query = |text: &str| -> bool {
67            match search_mode {
68                SearchMode::Substring => text.to_lowercase().contains(&query_lower),
69                SearchMode::Regex => regex_matcher.as_ref().is_some_and(|re| re.is_match(text)),
70            }
71        };
72
73        let mut results = Vec::new();
74
75        // Search through diff results if available (Diff mode)
76        if let Some(ref diff) = self.data.diff_result {
77            // Search added components
78            for comp in &diff.components.added {
79                if matches_query(&comp.name) {
80                    results.push(DiffSearchResult::Component {
81                        name: comp.name.clone(),
82                        version: comp.new_version.clone(),
83                        change_type: ChangeType::Added,
84                    });
85                }
86            }
87
88            // Search removed components
89            for comp in &diff.components.removed {
90                if matches_query(&comp.name) {
91                    results.push(DiffSearchResult::Component {
92                        name: comp.name.clone(),
93                        version: comp.old_version.clone(),
94                        change_type: ChangeType::Removed,
95                    });
96                }
97            }
98
99            // Search modified components
100            for change in &diff.components.modified {
101                if matches_query(&change.name) {
102                    results.push(DiffSearchResult::Component {
103                        name: change.name.clone(),
104                        version: change.new_version.clone(),
105                        change_type: ChangeType::Modified,
106                    });
107                }
108            }
109
110            // Search introduced vulnerabilities
111            for vuln in &diff.vulnerabilities.introduced {
112                if matches_query(&vuln.id) {
113                    results.push(DiffSearchResult::Vulnerability {
114                        id: vuln.id.clone(),
115                        component_name: vuln.component_name.clone(),
116                        severity: Some(vuln.severity.clone()),
117                        change_type: VulnChangeType::Introduced,
118                    });
119                }
120            }
121
122            // Search resolved vulnerabilities
123            for vuln in &diff.vulnerabilities.resolved {
124                if matches_query(&vuln.id) {
125                    results.push(DiffSearchResult::Vulnerability {
126                        id: vuln.id.clone(),
127                        component_name: vuln.component_name.clone(),
128                        severity: Some(vuln.severity.clone()),
129                        change_type: VulnChangeType::Resolved,
130                    });
131                }
132            }
133
134            // Search license changes (new licenses)
135            for lic_change in &diff.licenses.new_licenses {
136                if matches_query(&lic_change.license) {
137                    let component_name = lic_change
138                        .components
139                        .first()
140                        .cloned()
141                        .unwrap_or_else(|| "multiple".to_string());
142                    results.push(DiffSearchResult::License {
143                        license: lic_change.license.clone(),
144                        component_name,
145                        change_type: ChangeType::Added,
146                    });
147                }
148            }
149
150            // Search license changes (removed licenses)
151            for lic_change in &diff.licenses.removed_licenses {
152                if matches_query(&lic_change.license) {
153                    let component_name = lic_change
154                        .components
155                        .first()
156                        .cloned()
157                        .unwrap_or_else(|| "multiple".to_string());
158                    results.push(DiffSearchResult::License {
159                        license: lic_change.license.clone(),
160                        component_name,
161                        change_type: ChangeType::Removed,
162                    });
163                }
164            }
165        }
166
167        // Search through single SBOM if available (View mode)
168        if self.data.diff_result.is_none()
169            && let Some(ref sbom) = self.data.sbom
170        {
171            // Search components by name
172            for comp in sbom.components.values() {
173                if matches_query(&comp.name) {
174                    results.push(DiffSearchResult::Component {
175                        name: comp.name.clone(),
176                        version: comp.version.clone(),
177                        change_type: ChangeType::Added, // reuse Added as "present"
178                    });
179                }
180            }
181
182            // Search vulnerabilities
183            for comp in sbom.components.values() {
184                for vuln in &comp.vulnerabilities {
185                    if matches_query(&vuln.id) {
186                        results.push(DiffSearchResult::Vulnerability {
187                            id: vuln.id.clone(),
188                            component_name: comp.name.clone(),
189                            severity: vuln.severity.as_ref().map(|s| format!("{s:?}")),
190                            change_type: VulnChangeType::Introduced, // reuse as "present"
191                        });
192                    }
193                }
194            }
195
196            // Search licenses
197            for comp in sbom.components.values() {
198                for lic in &comp.licenses.declared {
199                    if matches_query(&lic.expression) {
200                        results.push(DiffSearchResult::License {
201                            license: lic.expression.clone(),
202                            component_name: comp.name.clone(),
203                            change_type: ChangeType::Added, // reuse as "present"
204                        });
205                    }
206                }
207            }
208        }
209
210        // F2: Filter component results to match the current component filter.
211        // Vulnerability and license results are kept regardless.
212        let comp_filter = self.components_state().filter;
213        if comp_filter != ComponentFilter::All {
214            results.retain(|r| match r {
215                DiffSearchResult::Component { change_type, .. } => match comp_filter {
216                    ComponentFilter::Added => *change_type == ChangeType::Added,
217                    ComponentFilter::Removed => *change_type == ChangeType::Removed,
218                    ComponentFilter::Modified => *change_type == ChangeType::Modified,
219                    // EolOnly/EolRisk don't map to search change types — keep all
220                    _ => true,
221                },
222                // Keep vulnerability and license results regardless of filter
223                DiffSearchResult::Vulnerability { .. } | DiffSearchResult::License { .. } => true,
224            });
225        }
226
227        // Limit results
228        results.truncate(50);
229        self.overlays.search.results = results;
230        self.overlays.search.selected = 0;
231    }
232
233    /// Jump to the currently selected search result
234    pub fn jump_to_search_result(&mut self) {
235        if let Some(result) = self
236            .overlays
237            .search
238            .results
239            .get(self.overlays.search.selected)
240            .cloned()
241        {
242            match result {
243                DiffSearchResult::Component {
244                    name,
245                    version,
246                    change_type,
247                    ..
248                } => {
249                    // Prefer matching by change type + version when possible
250                    if let Some(index) =
251                        self.find_component_index_all(&name, Some(change_type), version.as_deref())
252                    {
253                        self.components_state_mut().filter = ComponentFilter::All;
254                        self.components_state_mut().selected = index;
255                        self.select_tab(TabKind::Components);
256                        self.stop_search();
257                        return;
258                    }
259
260                    // Fall back to name-only match across all components
261                    if let Some(index) = self.find_component_index_all(&name, None, None) {
262                        self.components_state_mut().filter = ComponentFilter::All;
263                        self.components_state_mut().selected = index;
264                        self.select_tab(TabKind::Components);
265                        self.stop_search();
266                        return;
267                    }
268
269                    self.components_state_mut().filter = ComponentFilter::All;
270                    self.select_tab(TabKind::Components);
271                }
272                DiffSearchResult::Vulnerability {
273                    id, change_type, ..
274                } => {
275                    // Align filter/sort so the selection is stable
276                    self.vulnerabilities_state_mut().sort_by = VulnSort::Id;
277                    self.vulnerabilities_state_mut().filter = match change_type {
278                        VulnChangeType::Introduced => VulnFilter::Introduced,
279                        VulnChangeType::Resolved => VulnFilter::Resolved,
280                    };
281
282                    if let Some(index) = self.find_vulnerability_index(&id) {
283                        self.vulnerabilities_state_mut().selected = index;
284                    }
285
286                    self.select_tab(TabKind::Vulnerabilities);
287                }
288                DiffSearchResult::License { license, .. } => {
289                    // Find the license index
290                    if let Some(ref diff) = self.data.diff_result {
291                        let mut index = 0;
292
293                        // Search new licenses first
294                        for lic in &diff.licenses.new_licenses {
295                            if lic.license == license {
296                                self.licenses_state_mut().selected = index;
297                                self.select_tab(TabKind::Licenses);
298                                self.stop_search();
299                                return;
300                            }
301                            index += 1;
302                        }
303
304                        // Then removed licenses
305                        for lic in &diff.licenses.removed_licenses {
306                            if lic.license == license {
307                                self.licenses_state_mut().selected = index;
308                                self.select_tab(TabKind::Licenses);
309                                self.stop_search();
310                                return;
311                            }
312                            index += 1;
313                        }
314                    }
315                    self.select_tab(TabKind::Licenses);
316                }
317            }
318            self.stop_search();
319        }
320    }
321}