Skip to main content

omnyssh/app/
snippets.rs

1//! Snippets screen state: forms, popups, list view, and the `App` methods that
2//! execute snippets, quick-execute commands, and quick views.
3
4use super::*;
5use omnyssh_core::config::snippets::SnippetScope;
6use omnyssh_core::ssh::session::SshSession;
7
8// ---------------------------------------------------------------------------
9// Snippets structs
10// ---------------------------------------------------------------------------
11
12/// One host's result entry for the snippet execution results popup.
13#[derive(Debug, Clone)]
14pub struct SnippetResultEntry {
15    pub host_name: String,
16    pub snippet_name: String,
17    /// `Ok(stdout)` when done, `Err(message)` on error, empty `Ok("")` while pending.
18    pub output: Result<String, String>,
19    /// True while we're still waiting for the SSH task to complete.
20    pub pending: bool,
21}
22
23/// Which popup is currently shown over the Snippets screen (or as a full-screen
24/// overlay from any screen).
25#[derive(Debug)]
26pub enum SnippetPopup {
27    /// Adding a new snippet.
28    Add(SnippetForm),
29    /// Editing an existing snippet.
30    Edit {
31        snippet_idx: usize,
32        form: SnippetForm,
33    },
34    /// Asking for confirmation before deleting.
35    DeleteConfirm(usize),
36    /// Collecting values for `{{placeholder}}` params before executing.
37    ParamInput {
38        snippet_idx: usize,
39        host_names: Vec<String>,
40        param_names: Vec<String>,
41        param_fields: Vec<FormField>,
42        focused_field: usize,
43    },
44    /// Picking which hosts to broadcast to.
45    BroadcastPicker {
46        snippet_idx: usize,
47        /// Indices into `AppState.hosts` that are checked.
48        selected_host_indices: Vec<usize>,
49        /// Highlighted row in the list.
50        cursor: usize,
51    },
52    /// Single-line command input for quick-execute.
53    QuickExecuteInput {
54        host_name: String,
55        command_field: FormField,
56    },
57    /// Scrollable results from one or more hosts.
58    Results {
59        entries: Vec<SnippetResultEntry>,
60        scroll: usize,
61    },
62}
63
64/// Labels for each field in the snippet add/edit form.
65pub const SNIPPET_FORM_FIELD_LABELS: &[&str] = &[
66    "Name",
67    "Command",
68    "Scope (global / host)",
69    "Host (if scope=host)",
70    "Tags (comma-sep)",
71    "Params (comma-sep)",
72];
73
74/// The snippet add/edit form.
75#[derive(Debug, Clone)]
76pub struct SnippetForm {
77    /// Parallel to `SNIPPET_FORM_FIELD_LABELS`.
78    pub fields: Vec<FormField>,
79    pub focused_field: usize,
80}
81
82impl SnippetForm {
83    /// Creates an empty form for adding a new snippet.
84    pub fn empty() -> Self {
85        Self {
86            fields: SNIPPET_FORM_FIELD_LABELS
87                .iter()
88                .map(|_| FormField::default())
89                .collect(),
90            focused_field: 0,
91        }
92    }
93
94    /// Creates a form pre-filled from an existing snippet (for editing).
95    pub fn from_snippet(s: &Snippet) -> Self {
96        let mut form = Self::empty();
97        form.fields[0] = FormField::with_value(&s.name);
98        form.fields[1] = FormField::with_value(&s.command);
99        form.fields[2] = FormField::with_value(match s.scope {
100            SnippetScope::Global => "global",
101            SnippetScope::Host => "host",
102        });
103        form.fields[3] = FormField::with_value(s.host.as_deref().unwrap_or(""));
104        form.fields[4] = FormField::with_value(s.tags.as_deref().unwrap_or(&[]).join(", "));
105        form.fields[5] = FormField::with_value(s.params.as_deref().unwrap_or(&[]).join(", "));
106        form
107    }
108
109    /// Validates the form and converts it into a [`Snippet`].
110    ///
111    /// # Errors
112    /// Returns a human-readable error string if validation fails.
113    pub fn to_snippet(&self) -> Result<Snippet, String> {
114        let name = self.fields[0].value.trim().to_string();
115        if name.is_empty() {
116            return Err("Name cannot be empty".to_string());
117        }
118
119        let command = self.fields[1].value.trim().to_string();
120        if command.is_empty() {
121            return Err("Command cannot be empty".to_string());
122        }
123
124        let scope_str = self.fields[2].value.trim().to_lowercase();
125        let scope = match scope_str.as_str() {
126            "host" => SnippetScope::Host,
127            _ => SnippetScope::Global, // default to global
128        };
129
130        let host = {
131            let v = self.fields[3].value.trim();
132            if scope == SnippetScope::Host && v.is_empty() {
133                return Err("Host cannot be empty when scope is 'host'".to_string());
134            }
135            if v.is_empty() {
136                None
137            } else {
138                Some(v.to_string())
139            }
140        };
141
142        let tags: Vec<String> = self.fields[4]
143            .value
144            .split(',')
145            .map(|t| t.trim().to_string())
146            .filter(|t| !t.is_empty())
147            .collect();
148
149        let params: Vec<String> = self.fields[5]
150            .value
151            .split(',')
152            .map(|p| p.trim().to_string())
153            .filter(|p| !p.is_empty())
154            .collect();
155
156        Ok(Snippet {
157            name,
158            command,
159            scope,
160            host,
161            tags: if tags.is_empty() { None } else { Some(tags) },
162            params: if params.is_empty() {
163                None
164            } else {
165                Some(params)
166            },
167        })
168    }
169
170    /// Move focus to the next field (wraps around).
171    pub fn focus_next(&mut self) {
172        self.focused_field = (self.focused_field + 1) % self.fields.len();
173    }
174
175    /// Move focus to the previous field (wraps around).
176    pub fn focus_prev(&mut self) {
177        if self.focused_field == 0 {
178            self.focused_field = self.fields.len() - 1;
179        } else {
180            self.focused_field -= 1;
181        }
182    }
183}
184
185/// UI state specific to the Snippets screen.
186#[derive(Debug, Default)]
187pub struct SnippetsView {
188    /// Selected row index within `filtered_indices`.
189    pub selected: usize,
190    /// True when the user is actively typing a search query.
191    pub search_mode: bool,
192    /// Current search query.
193    pub search_query: String,
194    /// Indices into `AppState.snippets` matching the current query.
195    pub filtered_indices: Vec<usize>,
196    /// Currently visible popup (if any).
197    pub popup: Option<SnippetPopup>,
198}
199
200impl SnippetsView {
201    /// Returns the index into `AppState.snippets` for the selected row.
202    pub fn selected_snippet_idx(&self) -> Option<usize> {
203        self.filtered_indices.get(self.selected).copied()
204    }
205
206    /// Rebuilds `filtered_indices` with case-insensitive substring matching.
207    pub fn rebuild_filter(&mut self, snippets: &[Snippet], query: &str) {
208        self.filtered_indices = filter_snippets(snippets, query);
209        if self.filtered_indices.is_empty() {
210            self.selected = 0;
211        } else if self.selected >= self.filtered_indices.len() {
212            self.selected = self.filtered_indices.len() - 1;
213        }
214    }
215
216    pub fn select_next(&mut self) {
217        if self.filtered_indices.is_empty() {
218            return;
219        }
220        self.selected = (self.selected + 1).min(self.filtered_indices.len() - 1);
221    }
222
223    pub fn select_prev(&mut self) {
224        self.selected = self.selected.saturating_sub(1);
225    }
226}
227
228/// Case-insensitive substring filter over snippet name / command / tags.
229pub fn filter_snippets(snippets: &[Snippet], query: &str) -> Vec<usize> {
230    if query.is_empty() {
231        return (0..snippets.len()).collect();
232    }
233    let q = query.to_lowercase();
234    snippets
235        .iter()
236        .enumerate()
237        .filter(|(_, s)| {
238            s.name.to_lowercase().contains(&q)
239                || s.command.to_lowercase().contains(&q)
240                || s.tags
241                    .as_deref()
242                    .unwrap_or(&[])
243                    .iter()
244                    .any(|t| t.to_lowercase().contains(&q))
245        })
246        .map(|(i, _)| i)
247        .collect()
248}
249
250impl App {
251    // -----------------------------------------------------------------------
252    // Snippet execution private methods
253    // -----------------------------------------------------------------------
254
255    /// Checks whether the snippet requires param input; if yes, opens the
256    /// `ParamInput` popup, otherwise fires the execution tasks immediately.
257    pub(crate) async fn execute_snippet(&mut self, snippet_idx: usize, host_names: Vec<String>) {
258        let snippet = {
259            let state = self.state.read().await;
260            state.snippets.get(snippet_idx).cloned()
261        };
262        let Some(snippet) = snippet else { return };
263
264        let param_names: Vec<String> = snippet.params.as_deref().unwrap_or(&[]).to_vec();
265
266        if !param_names.is_empty() {
267            // Need values for placeholders — open the param input popup.
268            let param_fields = param_names.iter().map(|_| FormField::default()).collect();
269            self.view.snippets_view.popup = Some(SnippetPopup::ParamInput {
270                snippet_idx,
271                host_names,
272                param_names,
273                param_fields,
274                focused_field: 0,
275            });
276        } else {
277            self.spawn_snippet_tasks(&snippet, &host_names, &[]).await;
278        }
279    }
280
281    /// Called when the user confirms the `ParamInput` popup.  Collects the
282    /// filled values and fires the execution tasks.
283    pub(crate) async fn handle_confirm_param_input(&mut self) {
284        let popup = self.view.snippets_view.popup.take();
285        match popup {
286            Some(SnippetPopup::ParamInput {
287                snippet_idx,
288                host_names,
289                param_names,
290                param_fields,
291                ..
292            }) => {
293                let param_values: Vec<String> = param_fields
294                    .iter()
295                    .map(|f| f.value.trim().to_string())
296                    .collect();
297
298                let snippet = {
299                    let state = self.state.read().await;
300                    state.snippets.get(snippet_idx).cloned()
301                };
302                if let Some(snippet) = snippet {
303                    self.spawn_snippet_tasks(&snippet, &host_names, &param_values)
304                        .await;
305                }
306                // `self.view.snippets_view.popup` is already `None` from `.take()`.
307                // spawn_snippet_tasks will replace it with Results.
308                let _ = (param_names,); // suppress unused warning
309            }
310            other => {
311                // Wrong popup type — restore.
312                self.view.snippets_view.popup = other;
313            }
314        }
315    }
316
317    /// Substitutes `{{placeholder}}` values in the command, opens a `Results`
318    /// popup with pending entries, and spawns one tokio task per host.
319    async fn spawn_snippet_tasks(
320        &mut self,
321        snippet: &Snippet,
322        host_names: &[String],
323        param_values: &[String],
324    ) {
325        let command = substitute_params(&snippet.command, snippet.params.as_deref(), param_values);
326
327        // Pre-populate the Results popup with pending entries.
328        let entries: Vec<SnippetResultEntry> = host_names
329            .iter()
330            .map(|h| SnippetResultEntry {
331                host_name: h.clone(),
332                snippet_name: snippet.name.clone(),
333                output: Ok(String::new()),
334                pending: true,
335            })
336            .collect();
337        self.view.snippets_view.popup = Some(SnippetPopup::Results { entries, scroll: 0 });
338
339        // Collect Host structs for the requested names.
340        let hosts: Vec<Host> = {
341            let state = self.state.read().await;
342            host_names
343                .iter()
344                .filter_map(|name| state.hosts.iter().find(|h| &h.name == name).cloned())
345                .collect()
346        };
347
348        // Spawn one task per host.
349        for host in hosts {
350            let tx = self.core_tx.clone();
351            let cmd = command.clone();
352            let sname = snippet.name.clone();
353            tokio::spawn(async move {
354                let result = run_command_on_host(&host, &cmd).await;
355                let _ = tx
356                    .send(CoreEvent::SnippetResult {
357                        host_name: host.name.clone(),
358                        snippet_name: sname,
359                        output: result,
360                    })
361                    .await;
362            });
363        }
364    }
365
366    /// Confirms the broadcast-picker and runs the snippet on all checked hosts.
367    pub(crate) async fn handle_confirm_broadcast(&mut self) {
368        let popup = self.view.snippets_view.popup.take();
369        match popup {
370            Some(SnippetPopup::BroadcastPicker {
371                snippet_idx,
372                selected_host_indices,
373                ..
374            }) => {
375                if selected_host_indices.is_empty() {
376                    self.view.status_message = Some("No hosts selected.".to_string());
377                    return;
378                }
379                let host_names: Vec<String> = {
380                    let state = self.state.read().await;
381                    selected_host_indices
382                        .iter()
383                        .filter_map(|&i| state.hosts.get(i))
384                        .map(|h| h.name.clone())
385                        .collect()
386                };
387                self.execute_snippet(snippet_idx, host_names).await;
388            }
389            other => {
390                self.view.snippets_view.popup = other;
391            }
392        }
393    }
394
395    /// Runs an ad-hoc quick-execute command.
396    pub(crate) async fn run_quick_execute(&mut self, host_name: String, command: String) {
397        let host = {
398            let state = self.state.read().await;
399            state.hosts.iter().find(|h| h.name == host_name).cloned()
400        };
401
402        let Some(host) = host else {
403            self.view.snippets_view.popup = Some(SnippetPopup::Results {
404                entries: vec![SnippetResultEntry {
405                    host_name: host_name.clone(),
406                    snippet_name: "(quick-execute)".to_string(),
407                    output: Err(format!("Host '{}' not found.", host_name)),
408                    pending: false,
409                }],
410                scroll: 0,
411            });
412            return;
413        };
414
415        // Open Results popup with a single pending entry.
416        self.view.snippets_view.popup = Some(SnippetPopup::Results {
417            entries: vec![SnippetResultEntry {
418                host_name: host_name.clone(),
419                snippet_name: "(quick-execute)".to_string(),
420                output: Ok(String::new()),
421                pending: true,
422            }],
423            scroll: 0,
424        });
425
426        let tx = self.core_tx.clone();
427        let cmd = command.clone();
428        tokio::spawn(async move {
429            let result = run_command_on_host(&host, &cmd).await;
430            let _ = tx
431                .send(CoreEvent::SnippetResult {
432                    host_name: host.name.clone(),
433                    snippet_name: "(quick-execute)".to_string(),
434                    output: result,
435                })
436                .await;
437        });
438    }
439
440    /// Executes a quick view command for a specific service.
441    /// This is similar to quick-execute but uses predefined commands per service type.
442    pub(crate) async fn execute_quick_view(&mut self, service_kind: ServiceKind) {
443        // Get the currently selected host from the dashboard/detail view
444        let (host, host_name) = {
445            let state = self.state.read().await;
446            match self.view.host_list.selected_host_idx() {
447                Some(idx) => match state.hosts.get(idx) {
448                    Some(h) => (Some(h.clone()), h.name.clone()),
449                    None => (None, "(unknown)".to_string()),
450                },
451                None => (None, "(no selection)".to_string()),
452            }
453        };
454
455        let Some(host) = host else {
456            self.view.snippets_view.popup = Some(SnippetPopup::Results {
457                entries: vec![SnippetResultEntry {
458                    host_name: host_name.clone(),
459                    snippet_name: format!("Quick View: {:?}", service_kind),
460                    output: Err("No host selected.".to_string()),
461                    pending: false,
462                }],
463                scroll: 0,
464            });
465            return;
466        };
467
468        // Determine the command based on service kind
469        let (command, service_name) = match service_kind {
470            ServiceKind::Docker => (
471                "docker compose ps -a 2>/dev/null || docker ps -a",
472                "Docker Containers",
473            ),
474            ServiceKind::Nginx => (
475                "echo '=== Nginx Status ===' && systemctl status nginx --no-pager || service nginx status",
476                "Nginx Status",
477            ),
478            ServiceKind::PostgreSQL => (
479                "echo '=== PostgreSQL Connections ===' && sudo -u postgres psql -c 'SELECT count(*) as connections, state FROM pg_stat_activity GROUP BY state;' 2>/dev/null || echo 'No access to PostgreSQL'",
480                "PostgreSQL Connections",
481            ),
482            ServiceKind::Redis => (
483                "echo '=== Redis Info ===' && redis-cli info server 2>/dev/null | head -20 || echo 'Redis not accessible'",
484                "Redis Info",
485            ),
486            ServiceKind::NodeJS => (
487                "echo '=== PM2 Status ===' && pm2 status 2>/dev/null || (echo '=== Node Processes ===' && ps aux | grep -E '[n]ode ' | head -10)",
488                "Node.js Processes",
489            ),
490        };
491
492        // Open Results popup with a single pending entry
493        self.view.snippets_view.popup = Some(SnippetPopup::Results {
494            entries: vec![SnippetResultEntry {
495                host_name: host_name.clone(),
496                snippet_name: format!("Quick View: {}", service_name),
497                output: Ok(String::new()),
498                pending: true,
499            }],
500            scroll: 0,
501        });
502
503        let tx = self.core_tx.clone();
504        let cmd = command.to_string();
505        let sname = format!("Quick View: {}", service_name);
506        tokio::spawn(async move {
507            let result = run_command_on_host(&host, &cmd).await;
508            let _ = tx
509                .send(CoreEvent::SnippetResult {
510                    host_name: host.name.clone(),
511                    snippet_name: sname,
512                    output: result,
513                })
514                .await;
515        });
516    }
517
518    /// Confirms the snippet add/edit form and saves.
519    pub(crate) async fn handle_confirm_snippet_form(&mut self) {
520        match self.view.snippets_view.popup.take() {
521            Some(SnippetPopup::Add(form)) => match form.to_snippet() {
522                Ok(snippet) => {
523                    {
524                        let mut state = self.state.write().await;
525                        state.snippets.push(snippet);
526                    }
527                    self.save_snippets().await;
528                    let state = self.state.read().await;
529                    let q = self.view.snippets_view.search_query.clone();
530                    self.view.snippets_view.rebuild_filter(&state.snippets, &q);
531                    self.view.status_message = Some("Snippet added.".to_string());
532                }
533                Err(e) => {
534                    self.view.snippets_view.popup = Some(SnippetPopup::Add(form));
535                    self.view.status_message = Some(format!("Error: {e}"));
536                }
537            },
538
539            Some(SnippetPopup::Edit { snippet_idx, form }) => match form.to_snippet() {
540                Ok(snippet) => {
541                    {
542                        let mut state = self.state.write().await;
543                        if let Some(slot) = state.snippets.get_mut(snippet_idx) {
544                            *slot = snippet;
545                        }
546                    }
547                    self.save_snippets().await;
548                    let state = self.state.read().await;
549                    let q = self.view.snippets_view.search_query.clone();
550                    self.view.snippets_view.rebuild_filter(&state.snippets, &q);
551                    self.view.status_message = Some("Snippet updated.".to_string());
552                }
553                Err(e) => {
554                    self.view.snippets_view.popup = Some(SnippetPopup::Edit { snippet_idx, form });
555                    self.view.status_message = Some(format!("Error: {e}"));
556                }
557            },
558
559            other => {
560                self.view.snippets_view.popup = other;
561            }
562        }
563    }
564
565    /// Confirms snippet deletion.
566    pub(crate) async fn handle_confirm_snippet_delete(&mut self) {
567        if let Some(SnippetPopup::DeleteConfirm(idx)) = self.view.snippets_view.popup.take() {
568            {
569                let mut state = self.state.write().await;
570                if idx < state.snippets.len() {
571                    let removed = state.snippets.remove(idx);
572                    self.view.status_message = Some(format!("Deleted snippet '{}'.", removed.name));
573                }
574            }
575            self.save_snippets().await;
576            let state = self.state.read().await;
577            let q = self.view.snippets_view.search_query.clone();
578            self.view.snippets_view.rebuild_filter(&state.snippets, &q);
579        }
580    }
581
582    /// Persists `AppState.snippets` to `snippets.toml`.
583    async fn save_snippets(&mut self) {
584        let snippets = self.state.read().await.snippets.clone();
585        if let Err(e) = config::snippets::save_snippets(&snippets) {
586            self.view.status_message = Some(format!("Save failed: {e}"));
587        }
588    }
589}
590
591// ---------------------------------------------------------------------------
592// Free helper functions for snippet execution
593// ---------------------------------------------------------------------------
594
595/// Opens a fresh SSH connection, runs `command`, closes the connection, and
596/// returns `Ok(stdout)` or `Err(error_message)`.
597///
598/// A new connection is opened for each invocation.  Connection pooling with
599/// the metrics poller is a future optimisation.
600async fn run_command_on_host(host: &Host, command: &str) -> Result<String, String> {
601    let session = SshSession::connect(host)
602        .await
603        .map_err(|e| format!("Connect failed: {e}"))?;
604    let output = session
605        .run_command(command)
606        .await
607        .map_err(|e| format!("Command failed: {e}"))?;
608    let _ = session.disconnect().await;
609    Ok(output)
610}
611
612/// Replaces `{{param_name}}` placeholders in `command` with the
613/// corresponding values from `param_values` (parallel to `param_names`).
614pub(crate) fn substitute_params(
615    command: &str,
616    param_names: Option<&[String]>,
617    param_values: &[String],
618) -> String {
619    let mut result = command.to_string();
620    if let Some(names) = param_names {
621        for (name, value) in names.iter().zip(param_values.iter()) {
622            let placeholder = format!("{{{{{}}}}}", name);
623            result = result.replace(&placeholder, value);
624        }
625    }
626    result
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    fn s(v: &str) -> String {
634        v.to_string()
635    }
636
637    // --- substitute_params (P0.1) -----------------------------------------
638
639    #[test]
640    fn subst_single_placeholder() {
641        let out = substitute_params("echo {{name}}", Some(&[s("name")]), &[s("world")]);
642        assert_eq!(out, "echo world");
643    }
644
645    #[test]
646    fn subst_multiple_placeholders() {
647        let out = substitute_params("{{a}}-{{b}}", Some(&[s("a"), s("b")]), &[s("x"), s("y")]);
648        assert_eq!(out, "x-y");
649    }
650
651    #[test]
652    fn subst_repeated_placeholder_replaces_all() {
653        let out = substitute_params("{{x}} {{x}}", Some(&[s("x")]), &[s("v")]);
654        assert_eq!(out, "v v");
655    }
656
657    #[test]
658    fn subst_no_params_returns_command_unchanged() {
659        let out = substitute_params("echo hi", None, &[]);
660        assert_eq!(out, "echo hi");
661    }
662
663    #[test]
664    fn subst_empty_value_removes_placeholder() {
665        let out = substitute_params("a{{p}}b", Some(&[s("p")]), &[s("")]);
666        assert_eq!(out, "ab");
667    }
668
669    #[test]
670    fn subst_missing_value_leaves_placeholder_literal() {
671        // More names than values: zip stops short, {{b}} stays untouched.
672        let out = substitute_params("{{a}} {{b}}", Some(&[s("a"), s("b")]), &[s("x")]);
673        assert_eq!(out, "x {{b}}");
674    }
675
676    #[test]
677    fn subst_extra_values_ignored() {
678        let out = substitute_params("{{a}}", Some(&[s("a")]), &[s("x"), s("y")]);
679        assert_eq!(out, "x");
680    }
681
682    #[test]
683    fn subst_unused_name_is_noop() {
684        let out = substitute_params("echo hi", Some(&[s("unused")]), &[s("v")]);
685        assert_eq!(out, "echo hi");
686    }
687
688    #[test]
689    fn subst_value_inserted_verbatim_no_shell_escaping() {
690        // Pins current behaviour: substitution does NOT escape shell metachars.
691        let out = substitute_params("sh -c {{cmd}}", Some(&[s("cmd")]), &[s("; rm -rf /")]);
692        assert_eq!(out, "sh -c ; rm -rf /");
693    }
694
695    #[test]
696    fn subst_unknown_braces_in_value_not_reexpanded() {
697        let out = substitute_params("{{a}}", Some(&[s("a")]), &[s("{{evil}}")]);
698        assert_eq!(out, "{{evil}}");
699    }
700
701    #[test]
702    fn subst_single_brace_untouched() {
703        let out = substitute_params("{name}", Some(&[s("name")]), &[s("v")]);
704        assert_eq!(out, "{name}");
705    }
706
707    #[test]
708    fn subst_name_with_spaces() {
709        let out = substitute_params("{{db name}}", Some(&[s("db name")]), &[s("v")]);
710        assert_eq!(out, "v");
711    }
712
713    /// Builds a snippet form from the 6 field values, in `SNIPPET_FORM_FIELD_LABELS` order.
714    fn snippet_form(values: [&str; 6]) -> SnippetForm {
715        let mut form = SnippetForm::empty();
716        for (i, v) in values.iter().enumerate() {
717            form.fields[i] = FormField::with_value(*v);
718        }
719        form
720    }
721
722    fn snippet(name: &str, command: &str, tags: &[&str]) -> Snippet {
723        Snippet {
724            name: name.to_string(),
725            command: command.to_string(),
726            scope: SnippetScope::Global,
727            host: None,
728            tags: if tags.is_empty() {
729                None
730            } else {
731                Some(tags.iter().map(|t| t.to_string()).collect())
732            },
733            params: None,
734        }
735    }
736
737    // --- SnippetForm::to_snippet (P1.2) -----------------------------------
738
739    #[test]
740    fn to_snippet_empty_name_errs() {
741        assert!(snippet_form(["", "cmd", "global", "", "", ""])
742            .to_snippet()
743            .is_err());
744    }
745
746    #[test]
747    fn to_snippet_empty_command_errs() {
748        assert!(snippet_form(["n", "", "global", "", "", ""])
749            .to_snippet()
750            .is_err());
751    }
752
753    #[test]
754    fn to_snippet_scope_defaults_global() {
755        let snip = snippet_form(["n", "cmd", "", "", "", ""])
756            .to_snippet()
757            .unwrap();
758        assert_eq!(snip.scope, SnippetScope::Global);
759    }
760
761    #[test]
762    fn to_snippet_scope_host_explicit() {
763        let snip = snippet_form(["n", "cmd", "host", "web", "", ""])
764            .to_snippet()
765            .unwrap();
766        assert_eq!(snip.scope, SnippetScope::Host);
767    }
768
769    #[test]
770    fn to_snippet_scope_case_insensitive() {
771        let snip = snippet_form(["n", "cmd", "HOST", "web", "", ""])
772            .to_snippet()
773            .unwrap();
774        assert_eq!(snip.scope, SnippetScope::Host);
775    }
776
777    #[test]
778    fn to_snippet_host_required_when_scope_host() {
779        assert!(snippet_form(["n", "cmd", "host", "", "", ""])
780            .to_snippet()
781            .is_err());
782    }
783
784    #[test]
785    fn to_snippet_host_kept_when_scope_global() {
786        let snip = snippet_form(["n", "cmd", "global", "web", "", ""])
787            .to_snippet()
788            .unwrap();
789        assert_eq!(snip.host.as_deref(), Some("web"));
790    }
791
792    #[test]
793    fn to_snippet_tags_params_split_and_trimmed() {
794        let snip = snippet_form(["n", "cmd", "global", "", "a, b ,,", "p1, p2"])
795            .to_snippet()
796            .unwrap();
797        assert_eq!(snip.tags, Some(vec!["a".to_string(), "b".to_string()]));
798        assert_eq!(snip.params, Some(vec!["p1".to_string(), "p2".to_string()]));
799    }
800
801    #[test]
802    fn to_snippet_tags_params_none_when_empty() {
803        let snip = snippet_form(["n", "cmd", "global", "", "", ""])
804            .to_snippet()
805            .unwrap();
806        assert!(snip.tags.is_none());
807        assert!(snip.params.is_none());
808    }
809
810    // --- filter_snippets (P1.3) -------------------------------------------
811
812    #[test]
813    fn filter_snippets_empty_query_returns_all() {
814        let snips = [snippet("a", "ls", &[]), snippet("b", "pwd", &[])];
815        assert_eq!(filter_snippets(&snips, ""), vec![0, 1]);
816    }
817
818    #[test]
819    fn filter_snippets_matches_name() {
820        let snips = [snippet("deploy", "ls", &[]), snippet("backup", "pwd", &[])];
821        assert_eq!(filter_snippets(&snips, "depl"), vec![0]);
822    }
823
824    #[test]
825    fn filter_snippets_matches_command() {
826        let snips = [
827            snippet("a", "systemctl restart", &[]),
828            snippet("b", "ls", &[]),
829        ];
830        assert_eq!(filter_snippets(&snips, "systemctl"), vec![0]);
831    }
832
833    #[test]
834    fn filter_snippets_matches_tag() {
835        let snips = [snippet("a", "ls", &["ops"]), snippet("b", "pwd", &["dev"])];
836        assert_eq!(filter_snippets(&snips, "ops"), vec![0]);
837    }
838
839    #[test]
840    fn filter_snippets_case_insensitive() {
841        let snips = [snippet("Deploy", "ls", &[])];
842        assert_eq!(filter_snippets(&snips, "DEPLOY"), vec![0]);
843    }
844
845    #[test]
846    fn filter_snippets_no_match_returns_empty() {
847        let snips = [snippet("a", "ls", &[])];
848        assert!(filter_snippets(&snips, "zzz").is_empty());
849    }
850
851    // --- SnippetsView navigation (P1.5) -----------------------------------
852
853    #[test]
854    fn snippetsview_rebuild_filter_sets_indices() {
855        let snips = [snippet("deploy", "ls", &[]), snippet("backup", "pwd", &[])];
856        let mut view = SnippetsView::default();
857        view.rebuild_filter(&snips, "depl");
858        assert_eq!(view.filtered_indices, vec![0]);
859    }
860
861    #[test]
862    fn snippetsview_rebuild_clamps_selection() {
863        let snips = [snippet("a", "ls", &[]), snippet("b", "pwd", &[])];
864        let mut view = SnippetsView {
865            selected: 5,
866            ..Default::default()
867        };
868        view.rebuild_filter(&snips, "");
869        assert_eq!(view.selected, 1);
870    }
871
872    #[test]
873    fn snippetsview_rebuild_empty_resets_selection() {
874        let snips = [snippet("a", "ls", &[])];
875        let mut view = SnippetsView {
876            selected: 3,
877            ..Default::default()
878        };
879        view.rebuild_filter(&snips, "zzz");
880        assert_eq!(view.selected, 0);
881    }
882
883    #[test]
884    fn snippetsview_select_next_clamps_at_last() {
885        let mut view = SnippetsView {
886            filtered_indices: vec![0, 1],
887            ..Default::default()
888        };
889        view.select_next();
890        view.select_next();
891        view.select_next();
892        assert_eq!(view.selected, 1);
893    }
894
895    #[test]
896    fn snippetsview_select_prev_saturates_at_zero() {
897        let mut view = SnippetsView {
898            filtered_indices: vec![0, 1],
899            ..Default::default()
900        };
901        view.select_prev();
902        assert_eq!(view.selected, 0);
903    }
904
905    #[test]
906    fn snippetsview_selected_idx_maps_through_filter() {
907        let view = SnippetsView {
908            filtered_indices: vec![3, 7],
909            selected: 1,
910            ..Default::default()
911        };
912        assert_eq!(view.selected_snippet_idx(), Some(7));
913    }
914}