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 query: Option<String>,
14    pub filtered_indices: Vec<usize>,
15    pub filtered_pattern_indices: Vec<usize>,
16    pub 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 scope_indices: Option<HashSet<usize>>,
20}
21
22impl App {
23    /// Compute the search scope from the current display list when group-filtered.
24    fn compute_search_scope(&self) -> Option<HashSet<usize>> {
25        self.hosts_state.group_filter.as_ref()?;
26        Some(
27            self.hosts_state
28                .display_list
29                .iter()
30                .filter_map(|item| {
31                    if let HostListItem::Host { index } = item {
32                        Some(*index)
33                    } else {
34                        None
35                    }
36                })
37                .collect(),
38        )
39    }
40
41    /// Enter search mode.
42    pub fn start_search(&mut self) {
43        self.search.pre_search_selection = self.ui.list_state.selected();
44        self.search.scope_indices = self.compute_search_scope();
45        self.search.query = Some(String::new());
46        self.apply_filter();
47    }
48
49    /// Start search with an initial query (for positional arg).
50    pub fn start_search_with(&mut self, query: &str) {
51        self.search.pre_search_selection = self.ui.list_state.selected();
52        self.search.scope_indices = self.compute_search_scope();
53        self.search.query = Some(query.to_string());
54        self.apply_filter();
55    }
56
57    /// Cancel search mode and restore normal view.
58    pub fn cancel_search(&mut self) {
59        self.ping.filter_down_only = false;
60        self.search.query = None;
61        self.search.filtered_indices.clear();
62        self.search.filtered_pattern_indices.clear();
63        self.search.scope_indices = None;
64        // Restore pre-search position (bounds-checked)
65        if let Some(pos) = self.search.pre_search_selection.take() {
66            if pos < self.hosts_state.display_list.len() {
67                self.ui.list_state.select(Some(pos));
68            } else if let Some(first) = self.hosts_state.display_list.iter().position(|item| {
69                matches!(
70                    item,
71                    HostListItem::Host { .. } | HostListItem::Pattern { .. }
72                )
73            }) {
74                self.ui.list_state.select(Some(first));
75            }
76        }
77    }
78
79    /// Apply the current search query to filter hosts.
80    pub fn apply_filter(&mut self) {
81        log::debug!(
82            "[purple] apply_filter: query={:?} down_only={} scope={}",
83            self.search.query.as_deref().unwrap_or(""),
84            self.ping.filter_down_only,
85            self.search.scope_indices.as_ref().map_or(0, |s| s.len())
86        );
87        // Filtered index lists drive the search-mode render path which also
88        // consumes the render cache; recompute fresh.
89        self.hosts_state.render_cache.invalidate();
90        let query = match &self.search.query {
91            Some(q) if !q.is_empty() => q.clone(),
92            Some(_) => {
93                self.search.filtered_indices = (0..self.hosts_state.list.len()).collect();
94                self.search.filtered_pattern_indices =
95                    (0..self.hosts_state.patterns.len()).collect();
96                // Scope to group if active
97                if let Some(ref scope) = self.search.scope_indices {
98                    self.search.filtered_indices.retain(|i| scope.contains(i));
99                }
100                if !self.ping.filter_down_only {
101                    let total = self.search.filtered_indices.len()
102                        + self.search.filtered_pattern_indices.len();
103                    if total == 0 {
104                        self.ui.list_state.select(None);
105                    } else {
106                        self.ui.list_state.select(Some(0));
107                    }
108                    return;
109                }
110                // Fall through to down-only filtering below
111                String::new()
112            }
113            None => {
114                if !self.ping.filter_down_only {
115                    return;
116                }
117                // No search query but down-only is active: start with all hosts
118                self.search.filtered_indices = (0..self.hosts_state.list.len()).collect();
119                self.search.filtered_pattern_indices = Vec::new();
120                // Scope to group if active
121                if let Some(ref scope) = self.search.scope_indices {
122                    self.search.filtered_indices.retain(|i| scope.contains(i));
123                }
124                // Fall through to down-only filtering below
125                String::new()
126            }
127        };
128
129        if let Some(tag_exact) = query.strip_prefix("tag=") {
130            // Exact tag match (from tag picker), includes provider name and virtual "stale"/"vault"
131            let provider_config = &self.providers.config;
132            self.search.filtered_indices = self
133                .hosts_state
134                .list
135                .iter()
136                .enumerate()
137                .filter(|(_, host)| {
138                    (super::eq_ci("stale", tag_exact) && host.stale.is_some())
139                        || (super::eq_ci("vault-ssh", tag_exact)
140                            && crate::vault_ssh::resolve_vault_role(
141                                host.vault_ssh.as_deref(),
142                                host.provider.as_deref(),
143                                host.provider_label.as_deref(),
144                                provider_config,
145                            )
146                            .is_some())
147                        || (super::eq_ci("vault-kv", tag_exact)
148                            && host
149                                .askpass
150                                .as_deref()
151                                .map(|s| s.starts_with("vault:"))
152                                .unwrap_or(false))
153                        || host
154                            .provider_tags
155                            .iter()
156                            .chain(host.tags.iter())
157                            .any(|t| super::eq_ci(t, tag_exact))
158                        || host
159                            .provider
160                            .as_ref()
161                            .is_some_and(|p| super::eq_ci(p, tag_exact))
162                })
163                .map(|(i, _)| i)
164                .collect();
165            self.search.filtered_pattern_indices = self
166                .hosts_state
167                .patterns
168                .iter()
169                .enumerate()
170                .filter(|(_, p)| p.tags.iter().any(|t| super::eq_ci(t, tag_exact)))
171                .map(|(i, _)| i)
172                .collect();
173        } else if let Some(tag_query) = query.strip_prefix("tag:") {
174            // Fuzzy tag match (manual search), 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::contains_ci("stale", tag_query) && host.stale.is_some())
183                        || (super::contains_ci("vault-ssh", tag_query)
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::contains_ci("vault-kv", tag_query)
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::contains_ci(t, tag_query))
202                        || host
203                            .provider
204                            .as_ref()
205                            .is_some_and(|p| super::contains_ci(p, tag_query))
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::contains_ci(t, tag_query)))
215                .map(|(i, _)| i)
216                .collect();
217        } else {
218            self.search.filtered_indices = self
219                .hosts_state
220                .list
221                .iter()
222                .enumerate()
223                .filter(|(_, host)| {
224                    super::contains_ci(&host.alias, &query)
225                        || super::contains_ci(&host.hostname, &query)
226                        || super::contains_ci(&host.user, &query)
227                        || host
228                            .provider_tags
229                            .iter()
230                            .chain(host.tags.iter())
231                            .any(|t| super::contains_ci(t, &query))
232                        || host
233                            .provider
234                            .as_ref()
235                            .is_some_and(|p| super::contains_ci(p, &query))
236                })
237                .map(|(i, _)| i)
238                .collect();
239            self.search.filtered_pattern_indices = self
240                .hosts_state
241                .patterns
242                .iter()
243                .enumerate()
244                .filter(|(_, p)| {
245                    super::contains_ci(&p.pattern, &query)
246                        || p.tags.iter().any(|t| super::contains_ci(t, &query))
247                })
248                .map(|(i, _)| i)
249                .collect();
250        }
251
252        // Scope results to the active group if set
253        if let Some(ref scope) = self.search.scope_indices {
254            self.search.filtered_indices.retain(|i| scope.contains(i));
255        }
256
257        // Post-filter: keep only unreachable hosts when down-only mode is active
258        if self.ping.filter_down_only {
259            self.search.filtered_indices.retain(|&idx| {
260                let alias = &self.hosts_state.list[idx].alias;
261                matches!(self.ping.status.get(alias), Some(PingStatus::Unreachable))
262            });
263            // Patterns can't be pinged, so hide them in down-only mode
264            self.search.filtered_pattern_indices.clear();
265        }
266
267        // Reset selection
268        let total_results =
269            self.search.filtered_indices.len() + self.search.filtered_pattern_indices.len();
270        if total_results == 0 {
271            self.ui.list_state.select(None);
272        } else {
273            self.ui.list_state.select(Some(0));
274        }
275    }
276    /// Return indices of snippets matching the search query.
277    pub fn filtered_snippet_indices(&self) -> Vec<usize> {
278        match &self.ui.snippet_search {
279            None => (0..self.snippets.store.snippets.len()).collect(),
280            Some(query) if query.is_empty() => (0..self.snippets.store.snippets.len()).collect(),
281            Some(query) => self
282                .snippets
283                .store
284                .snippets
285                .iter()
286                .enumerate()
287                .filter(|(_, s)| {
288                    super::contains_ci(&s.name, query)
289                        || super::contains_ci(&s.command, query)
290                        || super::contains_ci(&s.description, query)
291                })
292                .map(|(i, _)| i)
293                .collect(),
294        }
295    }
296}