Skip to main content

purple_ssh/app/
search.rs

1//! Search and filter operations. Implements `impl App` continuation with
2//! query mode entry/exit, fuzzy filter, scope computation, and the snippet
3//! search helper.
4
5use std::collections::HashSet;
6
7use super::{HostListItem, PingStatus};
8use crate::app::App;
9
10/// Search mode state.
11#[derive(Default)]
12pub struct SearchState {
13    pub(in crate::app) query: Option<String>,
14    pub(in crate::app) filtered_indices: Vec<usize>,
15    pub(in crate::app) filtered_pattern_indices: Vec<usize>,
16    pub(in crate::app) pre_search_selection: Option<usize>,
17    /// When a group tab is active, holds the host indices visible in that group.
18    /// Search results are intersected with this set to scope the search.
19    pub(in crate::app) scope_indices: Option<HashSet<usize>>,
20}
21
22impl SearchState {
23    pub fn query(&self) -> Option<&str> {
24        self.query.as_deref()
25    }
26
27    pub fn filtered_indices(&self) -> &[usize] {
28        &self.filtered_indices
29    }
30
31    pub fn filtered_pattern_indices(&self) -> &[usize] {
32        &self.filtered_pattern_indices
33    }
34
35    pub fn scope_indices(&self) -> Option<&HashSet<usize>> {
36        self.scope_indices.as_ref()
37    }
38
39    pub fn set_query(&mut self, value: Option<String>) {
40        self.query = value;
41    }
42
43    pub fn clear_filtered_indices(&mut self) {
44        self.filtered_indices.clear();
45    }
46
47    pub fn clear_filtered_pattern_indices(&mut self) {
48        self.filtered_pattern_indices.clear();
49    }
50
51    /// Append a char to the query string. No-op when the query is inactive.
52    pub fn push_query_char(&mut self, c: char) {
53        if let Some(q) = self.query.as_mut() {
54            q.push(c);
55        }
56    }
57
58    /// Pop the trailing char from the query string. No-op when the query is inactive.
59    pub fn pop_query_char(&mut self) {
60        if let Some(q) = self.query.as_mut() {
61            q.pop();
62        }
63    }
64}
65
66impl App {
67    /// Compute the search scope from the current display list when group-filtered.
68    fn compute_search_scope(&self) -> Option<HashSet<usize>> {
69        self.hosts_state.group_filter.as_ref()?;
70        Some(
71            self.hosts_state
72                .display_list
73                .iter()
74                .filter_map(|item| {
75                    if let HostListItem::Host { index } = item {
76                        Some(*index)
77                    } else {
78                        None
79                    }
80                })
81                .collect(),
82        )
83    }
84
85    /// Enter search mode.
86    pub fn start_search(&mut self) {
87        self.search.pre_search_selection = self.ui.list_state.selected();
88        self.search.scope_indices = self.compute_search_scope();
89        self.search.query = Some(String::new());
90        self.apply_filter();
91    }
92
93    /// Start search with an initial query (for positional arg).
94    pub fn start_search_with(&mut self, query: &str) {
95        self.search.pre_search_selection = self.ui.list_state.selected();
96        self.search.scope_indices = self.compute_search_scope();
97        self.search.query = Some(query.to_string());
98        self.apply_filter();
99    }
100
101    /// Cancel search mode and restore normal view.
102    pub fn cancel_search(&mut self) {
103        self.ping.filter_down_only = false;
104        self.search.query = None;
105        self.search.filtered_indices.clear();
106        self.search.filtered_pattern_indices.clear();
107        self.search.scope_indices = None;
108        // Restore pre-search position (bounds-checked)
109        if let Some(pos) = self.search.pre_search_selection.take() {
110            if pos < self.hosts_state.display_list.len() {
111                self.ui.list_state.select(Some(pos));
112            } else if let Some(first) = self.hosts_state.display_list.iter().position(|item| {
113                matches!(
114                    item,
115                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
116                )
117            }) {
118                self.ui.list_state.select(Some(first));
119            }
120        }
121    }
122
123    /// Apply the current search query to filter hosts.
124    pub fn apply_filter(&mut self) {
125        log::debug!(
126            "[purple] apply_filter: query={:?} down_only={} scope={}",
127            self.search.query.as_deref().unwrap_or(""),
128            self.ping.filter_down_only,
129            self.search.scope_indices.as_ref().map_or(0, |s| s.len())
130        );
131        // Filtered index lists drive the search-mode render path which also
132        // consumes the render cache; recompute fresh.
133        self.hosts_state.render_cache.invalidate();
134        let query = match &self.search.query {
135            Some(q) if !q.is_empty() => q.clone(),
136            Some(_) => {
137                self.search.filtered_indices = (0..self.hosts_state.list.len()).collect();
138                self.search.filtered_pattern_indices =
139                    (0..self.hosts_state.patterns.len()).collect();
140                // Scope to group if active
141                if let Some(ref scope) = self.search.scope_indices {
142                    self.search.filtered_indices.retain(|i| scope.contains(i));
143                }
144                if !self.ping.filter_down_only {
145                    let total = self.search.filtered_indices.len()
146                        + self.search.filtered_pattern_indices.len();
147                    if total == 0 {
148                        self.ui.list_state.select(None);
149                    } else {
150                        self.ui.list_state.select(Some(0));
151                    }
152                    return;
153                }
154                // Fall through to down-only filtering below
155                String::new()
156            }
157            None => {
158                if !self.ping.filter_down_only {
159                    return;
160                }
161                // No search query but down-only is active: start with all hosts
162                self.search.filtered_indices = (0..self.hosts_state.list.len()).collect();
163                self.search.filtered_pattern_indices = Vec::new();
164                // Scope to group if active
165                if let Some(ref scope) = self.search.scope_indices {
166                    self.search.filtered_indices.retain(|i| scope.contains(i));
167                }
168                // Fall through to down-only filtering below
169                String::new()
170            }
171        };
172
173        if let Some(tag_exact) = query.strip_prefix("tag=") {
174            // Exact tag match (from tag picker), includes provider name and virtual "stale"/"vault"
175            let provider_config = &self.providers.config;
176            self.search.filtered_indices = self
177                .hosts_state
178                .list
179                .iter()
180                .enumerate()
181                .filter(|(_, host)| {
182                    (super::eq_ci("stale", tag_exact) && host.stale.is_some())
183                        || (super::eq_ci("vault-ssh", tag_exact)
184                            && crate::vault_ssh::resolve_vault_role(
185                                host.vault_ssh.as_deref(),
186                                host.provider.as_deref(),
187                                host.provider_label.as_deref(),
188                                provider_config,
189                            )
190                            .is_some())
191                        || (super::eq_ci("vault-kv", tag_exact)
192                            && host
193                                .askpass
194                                .as_deref()
195                                .map(|s| s.starts_with("vault:"))
196                                .unwrap_or(false))
197                        || host
198                            .provider_tags
199                            .iter()
200                            .chain(host.tags.iter())
201                            .any(|t| super::eq_ci(t, tag_exact))
202                        || host
203                            .provider
204                            .as_ref()
205                            .is_some_and(|p| super::eq_ci(p, tag_exact))
206                })
207                .map(|(i, _)| i)
208                .collect();
209            self.search.filtered_pattern_indices = self
210                .hosts_state
211                .patterns
212                .iter()
213                .enumerate()
214                .filter(|(_, p)| p.tags.iter().any(|t| super::eq_ci(t, tag_exact)))
215                .map(|(i, _)| i)
216                .collect();
217        } else if let Some(tag_query) = query.strip_prefix("tag:") {
218            // Fuzzy tag match (manual search), includes provider name and virtual "stale"/"vault"
219            let provider_config = &self.providers.config;
220            self.search.filtered_indices = self
221                .hosts_state
222                .list
223                .iter()
224                .enumerate()
225                .filter(|(_, host)| {
226                    (super::contains_ci("stale", tag_query) && host.stale.is_some())
227                        || (super::contains_ci("vault-ssh", tag_query)
228                            && crate::vault_ssh::resolve_vault_role(
229                                host.vault_ssh.as_deref(),
230                                host.provider.as_deref(),
231                                host.provider_label.as_deref(),
232                                provider_config,
233                            )
234                            .is_some())
235                        || (super::contains_ci("vault-kv", tag_query)
236                            && host
237                                .askpass
238                                .as_deref()
239                                .map(|s| s.starts_with("vault:"))
240                                .unwrap_or(false))
241                        || host
242                            .provider_tags
243                            .iter()
244                            .chain(host.tags.iter())
245                            .any(|t| super::contains_ci(t, tag_query))
246                        || host
247                            .provider
248                            .as_ref()
249                            .is_some_and(|p| super::contains_ci(p, tag_query))
250                })
251                .map(|(i, _)| i)
252                .collect();
253            self.search.filtered_pattern_indices = self
254                .hosts_state
255                .patterns
256                .iter()
257                .enumerate()
258                .filter(|(_, p)| p.tags.iter().any(|t| super::contains_ci(t, tag_query)))
259                .map(|(i, _)| i)
260                .collect();
261        } else {
262            self.search.filtered_indices = self
263                .hosts_state
264                .list
265                .iter()
266                .enumerate()
267                .filter(|(_, host)| {
268                    super::contains_ci(&host.alias, &query)
269                        || super::contains_ci(&host.hostname, &query)
270                        || super::contains_ci(&host.user, &query)
271                        || host
272                            .provider_tags
273                            .iter()
274                            .chain(host.tags.iter())
275                            .any(|t| super::contains_ci(t, &query))
276                        || host
277                            .provider
278                            .as_ref()
279                            .is_some_and(|p| super::contains_ci(p, &query))
280                })
281                .map(|(i, _)| i)
282                .collect();
283            self.search.filtered_pattern_indices = self
284                .hosts_state
285                .patterns
286                .iter()
287                .enumerate()
288                .filter(|(_, p)| {
289                    super::contains_ci(&p.pattern, &query)
290                        || p.tags.iter().any(|t| super::contains_ci(t, &query))
291                })
292                .map(|(i, _)| i)
293                .collect();
294        }
295
296        // Scope results to the active group if set
297        if let Some(ref scope) = self.search.scope_indices {
298            self.search.filtered_indices.retain(|i| scope.contains(i));
299        }
300
301        // Post-filter: keep only unreachable hosts when down-only mode is active
302        if self.ping.filter_down_only {
303            self.search.filtered_indices.retain(|&idx| {
304                let alias = &self.hosts_state.list[idx].alias;
305                matches!(self.ping.status.get(alias), Some(PingStatus::Unreachable))
306            });
307            // Patterns can't be pinged, so hide them in down-only mode
308            self.search.filtered_pattern_indices.clear();
309        }
310
311        // Reset selection
312        let total_results =
313            self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
314        if total_results == 0 {
315            self.ui.list_state.select(None);
316        } else {
317            self.ui.list_state.select(Some(0));
318        }
319    }
320    /// Return indices of snippets matching the search query.
321    pub fn filtered_snippet_indices(&self) -> Vec<usize> {
322        match &self.ui.snippet_search {
323            None => (0..self.snippets.store.snippets.len()).collect(),
324            Some(query) if query.is_empty() => (0..self.snippets.store.snippets.len()).collect(),
325            Some(query) => self
326                .snippets
327                .store
328                .snippets
329                .iter()
330                .enumerate()
331                .filter(|(_, s)| {
332                    super::contains_ci(&s.name, query)
333                        || super::contains_ci(&s.command, query)
334                        || super::contains_ci(&s.description, query)
335                })
336                .map(|(i, _)| i)
337                .collect(),
338        }
339    }
340}