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            // Space-separated terms are ANDed: every term must hit a tag/provider field.
220            let provider_config = &self.providers.config;
221            let terms: Vec<&str> = tag_query.split_whitespace().collect();
222            self.search.filtered_indices = self
223                .hosts_state
224                .list
225                .iter()
226                .enumerate()
227                .filter(|(_, host)| {
228                    terms.iter().all(|term| {
229                        (super::contains_ci("stale", term) && host.stale.is_some())
230                            || (super::contains_ci("vault-ssh", term)
231                                && crate::vault_ssh::resolve_vault_role(
232                                    host.vault_ssh.as_deref(),
233                                    host.provider.as_deref(),
234                                    host.provider_label.as_deref(),
235                                    provider_config,
236                                )
237                                .is_some())
238                            || (super::contains_ci("vault-kv", term)
239                                && host
240                                    .askpass
241                                    .as_deref()
242                                    .map(|s| s.starts_with("vault:"))
243                                    .unwrap_or(false))
244                            || host
245                                .provider_tags
246                                .iter()
247                                .chain(host.tags.iter())
248                                .any(|t| super::contains_ci(t, term))
249                            || host
250                                .provider
251                                .as_ref()
252                                .is_some_and(|p| super::contains_ci(p, term))
253                    })
254                })
255                .map(|(i, _)| i)
256                .collect();
257            self.search.filtered_pattern_indices = self
258                .hosts_state
259                .patterns
260                .iter()
261                .enumerate()
262                .filter(|(_, p)| {
263                    terms
264                        .iter()
265                        .all(|term| p.tags.iter().any(|t| super::contains_ci(t, term)))
266                })
267                .map(|(i, _)| i)
268                .collect();
269        } else {
270            // Space-separated terms are ANDed: every term must match at least
271            // one field. Split once, not per host. No terms (whitespace-only
272            // query) matches everything, so a trailing space while typing does
273            // not blank the list.
274            let terms: Vec<&str> = query.split_whitespace().collect();
275            self.search.filtered_indices = self
276                .hosts_state
277                .list
278                .iter()
279                .enumerate()
280                .filter(|(_, host)| {
281                    terms.iter().all(|term| {
282                        super::contains_ci(&host.alias, term)
283                            || super::contains_ci(&host.hostname, term)
284                            || super::contains_ci(&host.user, term)
285                            || host
286                                .provider_tags
287                                .iter()
288                                .chain(host.tags.iter())
289                                .any(|t| super::contains_ci(t, term))
290                            || host
291                                .provider
292                                .as_ref()
293                                .is_some_and(|p| super::contains_ci(p, term))
294                    })
295                })
296                .map(|(i, _)| i)
297                .collect();
298            self.search.filtered_pattern_indices = self
299                .hosts_state
300                .patterns
301                .iter()
302                .enumerate()
303                .filter(|(_, p)| {
304                    terms.iter().all(|term| {
305                        super::contains_ci(&p.pattern, term)
306                            || p.tags.iter().any(|t| super::contains_ci(t, term))
307                    })
308                })
309                .map(|(i, _)| i)
310                .collect();
311        }
312
313        // Scope results to the active group if set
314        if let Some(ref scope) = self.search.scope_indices {
315            self.search.filtered_indices.retain(|i| scope.contains(i));
316        }
317
318        // Post-filter: keep only unreachable hosts when down-only mode is active
319        if self.ping.filter_down_only {
320            self.search.filtered_indices.retain(|&idx| {
321                let alias = &self.hosts_state.list[idx].alias;
322                matches!(self.ping.status.get(alias), Some(PingStatus::Unreachable))
323            });
324            // Patterns can't be pinged, so hide them in down-only mode
325            self.search.filtered_pattern_indices.clear();
326        }
327
328        // Reset selection
329        let total_results =
330            self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
331        log::debug!(
332            "[purple] apply_filter matched: hosts={} patterns={}",
333            self.search.filtered_indices.len(),
334            self.search.filtered_pattern_indices.len()
335        );
336        if total_results == 0 {
337            self.ui.list_state.select(None);
338        } else {
339            self.ui.list_state.select(Some(0));
340        }
341    }
342    /// Return indices of snippets matching the search query.
343    pub fn filtered_snippet_indices(&self) -> Vec<usize> {
344        match &self.ui.snippet_search {
345            None => (0..self.snippets.store.snippets.len()).collect(),
346            Some(query) if query.is_empty() => (0..self.snippets.store.snippets.len()).collect(),
347            Some(query) => self
348                .snippets
349                .store
350                .snippets
351                .iter()
352                .enumerate()
353                .filter(|(_, s)| {
354                    super::contains_ci(&s.name, query)
355                        || super::contains_ci(&s.command, query)
356                        || super::contains_ci(&s.description, query)
357                })
358                .map(|(i, _)| i)
359                .collect(),
360        }
361    }
362}