Skip to main content

purple_ssh/app/
forms.rs

1//! Form state machines for host/provider/tunnel/snippet editing screens.
2//!
3//! Each form type owns its own field enum (for focus tracking), validation
4//! rules, and cursor management. Forms are passive state — the handler module
5//! drives them via key events and the ui module renders them.
6
7use std::collections::HashMap;
8
9use crate::providers::ProviderKind;
10use crate::ssh_config::model::{HostEntry, PatternEntry};
11use crate::tunnel::{TunnelRule, TunnelType};
12
13#[derive(Debug, Clone, Copy, PartialEq)]
14pub enum FormField {
15    Alias,
16    Hostname,
17    User,
18    Port,
19    IdentityFile,
20    ProxyJump,
21    AskPass,
22    VaultSsh,
23    VaultAddr,
24    Tags,
25}
26
27impl FormField {
28    pub const ALL: [FormField; 10] = [
29        FormField::Alias,
30        FormField::Hostname,
31        FormField::User,
32        FormField::Port,
33        FormField::IdentityFile,
34        FormField::VaultSsh,
35        FormField::VaultAddr,
36        FormField::ProxyJump,
37        FormField::AskPass,
38        FormField::Tags,
39    ];
40
41    /// Walk the raw `ALL` array forward, ignoring visibility. Used only by
42    /// tests that assert the schema order of the enum. Production code
43    /// navigates via `HostForm::focus_next_visible` which respects the
44    /// progressive-disclosure filter for `VaultAddr`.
45    #[cfg(test)]
46    pub fn next(self) -> Self {
47        let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
48        FormField::ALL[(idx + 1) % FormField::ALL.len()]
49    }
50
51    /// Walk the raw `ALL` array backward, test-only counterpart of `next()`.
52    #[cfg(test)]
53    pub fn prev(self) -> Self {
54        let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
55        FormField::ALL[(idx + FormField::ALL.len() - 1) % FormField::ALL.len()]
56    }
57
58    pub fn label(self) -> &'static str {
59        match self {
60            FormField::Alias => "Name",
61            FormField::Hostname => "Host / IP",
62            FormField::User => "User",
63            FormField::Port => "Port",
64            FormField::IdentityFile => "Identity File",
65            FormField::ProxyJump => "ProxyJump",
66            FormField::AskPass => "Password Source",
67            FormField::VaultSsh => "Vault SSH Role",
68            FormField::VaultAddr => "Vault SSH Address",
69            FormField::Tags => "Tags",
70        }
71    }
72
73    /// Whether this field opens a picker overlay when activated with Space.
74    ///
75    /// Picker fields: IdentityFile, ProxyJump, AskPass, VaultSsh. All other
76    /// fields are plain text inputs. The handler dispatches Space to a
77    /// picker for these fields and falls through to literal-character
78    /// insertion for the rest.
79    ///
80    /// Note: `VaultSsh` is a picker only when the host's role candidates
81    /// list is non-empty. The handler must consult `App::vault_role_candidates`
82    /// before opening the picker; with no candidates, Space inserts a
83    /// literal space (the user types the role manually).
84    pub fn is_picker(self) -> bool {
85        matches!(
86            self,
87            FormField::IdentityFile
88                | FormField::ProxyJump
89                | FormField::AskPass
90                | FormField::VaultSsh
91        )
92    }
93
94    /// Field kind for [`crate::ui::design::FieldKind`]. Drives dynamic
95    /// footer hints in the host form (`Space pick` vs no hint).
96    pub fn kind(self) -> crate::ui::design::FieldKind {
97        if self.is_picker() {
98            crate::ui::design::FieldKind::Picker
99        } else {
100            crate::ui::design::FieldKind::Text
101        }
102    }
103}
104
105/// Form state for adding/editing a host.
106#[derive(Debug, Clone)]
107pub struct HostForm {
108    pub alias: String,
109    pub hostname: String,
110    pub user: String,
111    pub port: String,
112    pub identity_file: String,
113    pub proxy_jump: String,
114    pub askpass: String,
115    pub vault_ssh: String,
116    /// Optional VAULT_ADDR override. Progressive disclosure: the form field
117    /// only renders and is only navigable when `vault_ssh` is non-empty. The
118    /// stored value is preserved while hidden so re-enabling the role shows
119    /// the previous address again.
120    pub vault_addr: String,
121    pub tags: String,
122    pub focused_field: FormField,
123    pub cursor_pos: usize,
124    /// Real-time validation hint shown in footer.
125    pub form_hint: Option<String>,
126    /// When true, alias is a Host pattern (wildcards allowed, hostname optional).
127    pub is_pattern: bool,
128    /// Progressive disclosure: false = only required fields visible, true = all.
129    /// Excluded from dirty detection (UI-only state).
130    pub expanded: bool,
131    /// Inherited values from matching patterns (value, source pattern).
132    /// Shown as dimmed placeholders when the field is empty.
133    pub inherited: crate::ssh_config::model::InheritedHints,
134}
135
136impl HostForm {
137    pub(crate) fn new() -> Self {
138        Self {
139            alias: String::new(),
140            hostname: String::new(),
141            user: String::new(),
142            port: "22".to_string(),
143            identity_file: String::new(),
144            proxy_jump: String::new(),
145            askpass: String::new(),
146            vault_ssh: String::new(),
147            vault_addr: String::new(),
148            tags: String::new(),
149            focused_field: FormField::Alias,
150            cursor_pos: 0,
151            form_hint: None,
152            is_pattern: false,
153            expanded: false,
154            inherited: Default::default(),
155        }
156    }
157
158    pub fn new_pattern() -> Self {
159        Self {
160            is_pattern: true,
161            expanded: true,
162            ..Self::new()
163        }
164    }
165
166    /// Create form from a raw HostEntry (without pattern inheritance applied).
167    /// The `inherited` hints are computed separately and passed in.
168    pub fn from_entry(
169        entry: &HostEntry,
170        inherited: crate::ssh_config::model::InheritedHints,
171    ) -> Self {
172        let alias = entry.alias.clone();
173        let cursor_pos = alias.chars().count();
174        Self {
175            alias,
176            hostname: entry.hostname.clone(),
177            user: entry.user.clone(),
178            port: entry.port.to_string(),
179            identity_file: entry.identity_file.clone(),
180            proxy_jump: entry.proxy_jump.clone(),
181            askpass: entry.askpass.clone().unwrap_or_default(),
182            vault_ssh: entry.vault_ssh.clone().unwrap_or_default(),
183            vault_addr: entry.vault_addr.clone().unwrap_or_default(),
184            tags: entry.tags.join(", "),
185            focused_field: FormField::Alias,
186            cursor_pos,
187            form_hint: None,
188            is_pattern: false,
189            expanded: true,
190            inherited,
191        }
192    }
193
194    /// Create a HostForm from an existing host for the clone/duplicate flow.
195    /// Clears fields that must not carry over to the copy: `vault_ssh` (the
196    /// per-host Vault SSH override, which belongs to the original alias's
197    /// certificate) and implicitly `certificate_file` (not stored on the form
198    /// since it is derived from the alias). The caller is still responsible
199    /// for setting a unique alias on the returned form.
200    /// Returns the cloned form and a flag indicating whether a per-host
201    /// Vault SSH override was cleared. Callers display a status when the
202    /// flag is true so the user understands why the clone does not inherit
203    /// the source host's Vault SSH role.
204    pub fn from_entry_duplicate(
205        entry: &HostEntry,
206        inherited: crate::ssh_config::model::InheritedHints,
207    ) -> (Self, bool) {
208        let mut form = Self::from_entry(entry, inherited);
209        let had_vault_ssh = !form.vault_ssh.is_empty();
210        form.vault_ssh.clear();
211        // The Vault address is conceptually part of the Vault SSH setup: if
212        // we clear the role for the clone (because it belongs to the original
213        // alias's certificate), the address must be cleared too so the user
214        // explicitly re-enters it when enabling Vault SSH on the copy.
215        form.vault_addr.clear();
216        (form, had_vault_ssh)
217    }
218
219    pub fn from_pattern_entry(entry: &PatternEntry) -> Self {
220        let alias = entry.pattern.clone();
221        let cursor_pos = alias.chars().count();
222        Self {
223            alias,
224            hostname: entry.hostname.clone(),
225            user: entry.user.clone(),
226            port: entry.port.to_string(),
227            identity_file: entry.identity_file.clone(),
228            proxy_jump: entry.proxy_jump.clone(),
229            askpass: entry.askpass.clone().unwrap_or_default(),
230            vault_ssh: String::new(),
231            vault_addr: String::new(),
232            tags: entry.tags.join(", "),
233            focused_field: FormField::Alias,
234            cursor_pos,
235            form_hint: None,
236            is_pattern: true,
237            expanded: true,
238            inherited: Default::default(),
239        }
240    }
241
242    pub fn focused_value(&self) -> &str {
243        match self.focused_field {
244            FormField::Alias => &self.alias,
245            FormField::Hostname => &self.hostname,
246            FormField::User => &self.user,
247            FormField::Port => &self.port,
248            FormField::IdentityFile => &self.identity_file,
249            FormField::ProxyJump => &self.proxy_jump,
250            FormField::AskPass => &self.askpass,
251            FormField::VaultSsh => &self.vault_ssh,
252            FormField::VaultAddr => &self.vault_addr,
253            FormField::Tags => &self.tags,
254        }
255    }
256
257    /// Get a mutable reference to the currently focused field's value.
258    pub fn focused_value_mut(&mut self) -> &mut String {
259        match self.focused_field {
260            FormField::Alias => &mut self.alias,
261            FormField::Hostname => &mut self.hostname,
262            FormField::User => &mut self.user,
263            FormField::Port => &mut self.port,
264            FormField::IdentityFile => &mut self.identity_file,
265            FormField::ProxyJump => &mut self.proxy_jump,
266            FormField::AskPass => &mut self.askpass,
267            FormField::VaultSsh => &mut self.vault_ssh,
268            FormField::VaultAddr => &mut self.vault_addr,
269            FormField::Tags => &mut self.tags,
270        }
271    }
272
273    /// Returns the fields that are currently visible in the rendered form.
274    ///
275    /// `FormField::VaultAddr` is hidden (absent) unless the Vault SSH role
276    /// field has a non-empty value on the same form. Navigation helpers must
277    /// consult this list so Tab/Shift-Tab skip over hidden fields. The stored
278    /// `vault_addr` value survives hiding, so toggling the role back on
279    /// restores the previous input.
280    pub fn visible_fields(&self) -> Vec<FormField> {
281        let role_set = !self.vault_ssh.trim().is_empty();
282        FormField::ALL
283            .iter()
284            .copied()
285            .filter(|f| *f != FormField::VaultAddr || role_set)
286            .collect()
287    }
288
289    /// Advance `focused_field` to the next visible field (wrapping).
290    ///
291    /// When the currently focused field is NOT in the visible set (e.g. the
292    /// user toggled the role off while focused on VaultAddr, which the UI
293    /// does not currently allow but defensive code must handle), fall back
294    /// to the first visible field instead of silently skipping to index 1.
295    pub fn focus_next_visible(&mut self) {
296        let visible = self.visible_fields();
297        if visible.is_empty() {
298            return;
299        }
300        self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
301            Some(idx) => visible[(idx + 1) % visible.len()],
302            None => visible[0],
303        };
304    }
305
306    /// Advance `focused_field` to the previous visible field (wrapping).
307    ///
308    /// Same fallback policy as `focus_next_visible`: an out-of-set focus
309    /// state snaps to the last visible field, not the second-to-last.
310    pub fn focus_prev_visible(&mut self) {
311        let visible = self.visible_fields();
312        if visible.is_empty() {
313            return;
314        }
315        self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
316            Some(idx) => visible[(idx + visible.len() - 1) % visible.len()],
317            None => visible[visible.len() - 1],
318        };
319    }
320
321    pub fn insert_char(&mut self, c: char) {
322        let pos = self.cursor_pos;
323        let val = self.focused_value_mut();
324        let byte_pos = char_to_byte_pos(val, pos);
325        val.insert(byte_pos, c);
326        self.cursor_pos = pos + 1;
327    }
328
329    pub fn delete_char_before_cursor(&mut self) {
330        if self.cursor_pos == 0 {
331            return;
332        }
333        let pos = self.cursor_pos;
334        let val = self.focused_value_mut();
335        let byte_pos = char_to_byte_pos(val, pos);
336        let prev = char_to_byte_pos(val, pos - 1);
337        val.drain(prev..byte_pos);
338        self.cursor_pos = pos - 1;
339    }
340
341    pub fn sync_cursor_to_end(&mut self) {
342        self.cursor_pos = self.focused_value().chars().count();
343    }
344
345    /// Absorb a password-source selection from the password picker. Sets
346    /// `askpass` to `value`, optionally moves focus to the AskPass field
347    /// when the user must type extra characters (custom command or prefix
348    /// completion), and parks the cursor at the end of the askpass value
349    /// regardless of which field currently has focus.
350    pub fn apply_password_source(&mut self, value: String, refocus_askpass: bool) {
351        self.askpass = value;
352        if refocus_askpass {
353            self.focused_field = FormField::AskPass;
354        }
355        self.cursor_pos = self.askpass.chars().count();
356    }
357
358    /// Fill parsed `user@host:port` fields into the form, skipping any
359    /// field already at non-default state. Alias is always overwritten
360    /// with the caller-supplied short form.
361    pub fn apply_smart_paste(
362        &mut self,
363        parsed: crate::quick_add::ParsedTarget,
364        clean_alias: String,
365    ) {
366        if self.hostname.is_empty() {
367            self.hostname = parsed.hostname;
368        }
369        if self.user.is_empty() && !parsed.user.is_empty() {
370            self.user = parsed.user;
371        }
372        if self.port == "22" && parsed.port != 22 {
373            self.port = parsed.port.to_string();
374        }
375        self.alias = clean_alias;
376    }
377
378    /// Run lightweight validation on the focused field and update `form_hint`.
379    pub fn update_hint(&mut self) {
380        self.form_hint = match self.focused_field {
381            FormField::Alias => {
382                let v = self.alias.trim();
383                if v.is_empty() {
384                    None // Don't nag while empty (user may not have typed yet)
385                } else if self.is_pattern {
386                    if !crate::ssh_config::model::is_host_pattern(v) {
387                        Some("Pattern needs a wildcard (*, ?, [) or multiple hosts".into())
388                    } else {
389                        None
390                    }
391                } else if v.contains(char::is_whitespace) {
392                    Some("Alias can't contain whitespace".into())
393                } else if v.contains('#') {
394                    Some("Alias can't contain '#'".into())
395                } else if crate::ssh_config::model::is_host_pattern(v) {
396                    Some("Alias can't contain pattern characters".into())
397                } else {
398                    None
399                }
400            }
401            FormField::Hostname => {
402                let v = self.hostname.trim();
403                if !v.is_empty() && v.contains(char::is_whitespace) {
404                    Some("Hostname can't contain whitespace".into())
405                } else {
406                    None
407                }
408            }
409            FormField::User => {
410                let v = self.user.trim();
411                if !v.is_empty() && v.contains(char::is_whitespace) {
412                    Some("User can't contain whitespace".into())
413                } else {
414                    None
415                }
416            }
417            FormField::Port => {
418                let v = &self.port;
419                if v.is_empty() || v == "22" {
420                    None
421                } else {
422                    match v.parse::<u16>() {
423                        Ok(0) => Some("Port must be 1-65535".into()),
424                        Err(_) => Some("Not a valid port number".into()),
425                        _ => None,
426                    }
427                }
428            }
429            _ => None,
430        };
431    }
432
433    /// Validate the form. Returns an error message if invalid.
434    pub fn validate(&self) -> Result<(), String> {
435        let alias = self.alias.trim();
436        if alias.is_empty() {
437            return Err(if self.is_pattern {
438                crate::messages::HOST_PATTERN_EMPTY.to_string()
439            } else {
440                crate::messages::HOST_ALIAS_EMPTY.to_string()
441            });
442        }
443        if self.is_pattern && !crate::ssh_config::model::is_host_pattern(alias) {
444            return Err(crate::messages::HOST_PATTERN_NEEDS_WILDCARD.to_string());
445        } else if !self.is_pattern {
446            if alias.contains(char::is_whitespace) {
447                return Err(crate::messages::HOST_ALIAS_WHITESPACE.to_string());
448            }
449            if alias.contains('#') {
450                return Err(crate::messages::HOST_ALIAS_HASH.to_string());
451            }
452            // Catches *, ?, [, ! — whitespace overlap with the check above is intentional
453            // (user gets the more specific whitespace message first)
454            if crate::ssh_config::model::is_host_pattern(alias) {
455                return Err(crate::messages::HOST_ALIAS_PATTERN_CHARS.to_string());
456            }
457        }
458        // Reject control characters in all fields
459        let fields = [
460            (
461                &self.alias,
462                if self.is_pattern { "Pattern" } else { "Alias" },
463            ),
464            (&self.hostname, "Hostname"),
465            (&self.user, "User"),
466            (&self.port, "Port"),
467            (&self.identity_file, "Identity File"),
468            (&self.proxy_jump, "ProxyJump"),
469            (&self.askpass, "Password Source"),
470            (&self.vault_ssh, "Vault SSH Role"),
471            (&self.vault_addr, "Vault SSH Address"),
472            (&self.tags, "Tags"),
473        ];
474        for (value, name) in &fields {
475            if value.chars().any(|c| c.is_control()) {
476                return Err(crate::messages::field_control_chars(name));
477            }
478        }
479        if !self.is_pattern && self.hostname.trim().is_empty() {
480            return Err(crate::messages::HOST_HOSTNAME_EMPTY.to_string());
481        }
482        if self.hostname.trim().contains(char::is_whitespace) {
483            return Err(crate::messages::HOST_HOSTNAME_WHITESPACE.to_string());
484        }
485        if self.user.trim().contains(char::is_whitespace) {
486            return Err(crate::messages::USER_NO_WHITESPACE.to_string());
487        }
488        let port: u16 = self
489            .port
490            .parse()
491            .map_err(|_| crate::messages::HOST_PORT_INVALID.to_string())?;
492        if port == 0 {
493            return Err(crate::messages::HOST_PORT_ZERO.to_string());
494        }
495        let vault_role = self.vault_ssh.trim();
496        if !vault_role.is_empty() && !crate::vault_ssh::is_valid_role(vault_role) {
497            return Err(crate::messages::HOST_VAULT_ROLE_INVALID.to_string());
498        }
499        // vault_addr is only meaningful when a vault role is set. If the
500        // user typed an address but then cleared the role we treat it as
501        // leftover state and do not validate it (the submit path will not
502        // persist it either, since visible_fields filters it out).
503        if !vault_role.is_empty() {
504            let addr = self.vault_addr.trim();
505            if !addr.is_empty() && !crate::vault_ssh::is_valid_vault_addr(addr) {
506                return Err(crate::messages::HOST_VAULT_ADDR_INVALID.to_string());
507            }
508        }
509        Ok(())
510    }
511
512    /// Convert to a HostEntry.
513    pub fn to_entry(&self) -> HostEntry {
514        let askpass_trimmed = self.askpass.trim().to_string();
515        let vault_ssh_trimmed = self.vault_ssh.trim().to_string();
516        // Drop vault_addr when the role is empty: without a role the address
517        // has no effect, and persisting leftover state would be confusing.
518        let vault_addr_trimmed = if vault_ssh_trimmed.is_empty() {
519            String::new()
520        } else {
521            self.vault_addr.trim().to_string()
522        };
523        HostEntry {
524            alias: self.alias.trim().to_string(),
525            hostname: self.hostname.trim().to_string(),
526            user: self.user.trim().to_string(),
527            port: self.port.parse().unwrap_or(22),
528            identity_file: self.identity_file.trim().to_string(),
529            proxy_jump: self.proxy_jump.trim().to_string(),
530            tags: self
531                .tags
532                .split(',')
533                .map(|t| t.trim().to_string())
534                .filter(|t| !t.is_empty())
535                .collect(),
536            askpass: if askpass_trimmed.is_empty() {
537                None
538            } else {
539                Some(askpass_trimmed)
540            },
541            vault_ssh: if vault_ssh_trimmed.is_empty() {
542                None
543            } else {
544                Some(vault_ssh_trimmed)
545            },
546            vault_addr: if vault_addr_trimmed.is_empty() {
547                None
548            } else {
549                Some(vault_addr_trimmed)
550            },
551            ..Default::default()
552        }
553    }
554}
555
556/// Which provider form field is focused.
557///
558/// `Label` is dynamic. The static per-provider arrays (`PROXMOX_FIELDS` etc.)
559/// never list it; `ProviderFormFields::visible_fields()` prepends it when the
560/// form was opened to collect a label for a new labeled config. This keeps
561/// `fields_for(provider)` provider-pure and prevents Label from leaking into
562/// bare-add or edit flows where the label is fixed.
563#[derive(Debug, Clone, Copy, PartialEq)]
564pub enum ProviderFormField {
565    Label,
566    Url,
567    Token,
568    Profile,
569    Project,
570    Compartment,
571    Regions,
572    Filter,
573    Ssm,
574    AliasPrefix,
575    User,
576    IdentityFile,
577    VerifyTls,
578    VaultRole,
579    VaultAddr,
580    AutoSync,
581}
582
583impl ProviderFormField {
584    const CLOUD_FIELDS: &[ProviderFormField] = &[
585        ProviderFormField::Token,
586        ProviderFormField::AliasPrefix,
587        ProviderFormField::User,
588        ProviderFormField::IdentityFile,
589        ProviderFormField::VaultRole,
590        ProviderFormField::VaultAddr,
591        ProviderFormField::AutoSync,
592    ];
593
594    const PROXMOX_FIELDS: &[ProviderFormField] = &[
595        ProviderFormField::Url,
596        ProviderFormField::Token,
597        ProviderFormField::AliasPrefix,
598        ProviderFormField::User,
599        ProviderFormField::IdentityFile,
600        ProviderFormField::VerifyTls,
601        ProviderFormField::VaultRole,
602        ProviderFormField::VaultAddr,
603        ProviderFormField::AutoSync,
604    ];
605
606    const NETBOX_FIELDS: &[ProviderFormField] = &[
607        ProviderFormField::Url,
608        ProviderFormField::Token,
609        ProviderFormField::Filter,
610        ProviderFormField::AliasPrefix,
611        ProviderFormField::User,
612        ProviderFormField::IdentityFile,
613        ProviderFormField::VerifyTls,
614        ProviderFormField::VaultRole,
615        ProviderFormField::VaultAddr,
616        ProviderFormField::AutoSync,
617    ];
618
619    const AWS_FIELDS: &[ProviderFormField] = &[
620        ProviderFormField::Token,
621        ProviderFormField::Profile,
622        ProviderFormField::Regions,
623        ProviderFormField::Ssm,
624        ProviderFormField::AliasPrefix,
625        ProviderFormField::User,
626        ProviderFormField::IdentityFile,
627        ProviderFormField::VaultRole,
628        ProviderFormField::VaultAddr,
629        ProviderFormField::AutoSync,
630    ];
631
632    const SCALEWAY_FIELDS: &[ProviderFormField] = &[
633        ProviderFormField::Token,
634        ProviderFormField::Regions,
635        ProviderFormField::AliasPrefix,
636        ProviderFormField::User,
637        ProviderFormField::IdentityFile,
638        ProviderFormField::VaultRole,
639        ProviderFormField::VaultAddr,
640        ProviderFormField::AutoSync,
641    ];
642
643    const GCP_FIELDS: &[ProviderFormField] = &[
644        ProviderFormField::Token,
645        ProviderFormField::Project,
646        ProviderFormField::Regions,
647        ProviderFormField::AliasPrefix,
648        ProviderFormField::User,
649        ProviderFormField::IdentityFile,
650        ProviderFormField::VaultRole,
651        ProviderFormField::VaultAddr,
652        ProviderFormField::AutoSync,
653    ];
654
655    const AZURE_FIELDS: &[ProviderFormField] = &[
656        ProviderFormField::Token,
657        ProviderFormField::Regions,
658        ProviderFormField::AliasPrefix,
659        ProviderFormField::User,
660        ProviderFormField::IdentityFile,
661        ProviderFormField::VaultRole,
662        ProviderFormField::VaultAddr,
663        ProviderFormField::AutoSync,
664    ];
665
666    const ORACLE_FIELDS: &[ProviderFormField] = &[
667        ProviderFormField::Token,
668        ProviderFormField::Compartment,
669        ProviderFormField::Regions,
670        ProviderFormField::AliasPrefix,
671        ProviderFormField::User,
672        ProviderFormField::IdentityFile,
673        ProviderFormField::VaultRole,
674        ProviderFormField::VaultAddr,
675        ProviderFormField::AutoSync,
676    ];
677
678    /// Teleport has no token and issues its own certificates, so neither the
679    /// Token nor the Vault fields apply. User (the Teleport login) leads so the
680    /// collapsed form has a field to show.
681    const TELEPORT_FIELDS: &[ProviderFormField] = &[
682        ProviderFormField::User,
683        ProviderFormField::AliasPrefix,
684        ProviderFormField::IdentityFile,
685        ProviderFormField::AutoSync,
686    ];
687
688    const OVH_FIELDS: &[ProviderFormField] = &[
689        ProviderFormField::Token,
690        ProviderFormField::Project,
691        ProviderFormField::Regions,
692        ProviderFormField::AliasPrefix,
693        ProviderFormField::User,
694        ProviderFormField::IdentityFile,
695        ProviderFormField::VaultRole,
696        ProviderFormField::VaultAddr,
697        ProviderFormField::AutoSync,
698    ];
699
700    pub fn fields_for(provider: &str) -> &'static [ProviderFormField] {
701        let Ok(kind) = provider.parse::<ProviderKind>() else {
702            return Self::CLOUD_FIELDS;
703        };
704        match kind {
705            ProviderKind::Proxmox => Self::PROXMOX_FIELDS,
706            ProviderKind::NetBox => Self::NETBOX_FIELDS,
707            ProviderKind::Aws => Self::AWS_FIELDS,
708            ProviderKind::Scaleway => Self::SCALEWAY_FIELDS,
709            ProviderKind::Gcp => Self::GCP_FIELDS,
710            ProviderKind::Azure => Self::AZURE_FIELDS,
711            ProviderKind::Oracle => Self::ORACLE_FIELDS,
712            ProviderKind::Ovh => Self::OVH_FIELDS,
713            ProviderKind::Teleport => Self::TELEPORT_FIELDS,
714            ProviderKind::DigitalOcean
715            | ProviderKind::Hetzner
716            | ProviderKind::I3d
717            | ProviderKind::Leaseweb
718            | ProviderKind::Linode
719            | ProviderKind::Tailscale
720            | ProviderKind::Transip
721            | ProviderKind::UpCloud
722            | ProviderKind::Vultr => Self::CLOUD_FIELDS,
723        }
724    }
725
726    /// Required fields for this provider (always visible in collapsed mode).
727    /// Used in tests only; runtime code slices `fields_for()` directly.
728    #[cfg(test)]
729    pub fn required_fields_for(provider: &str) -> Vec<ProviderFormField> {
730        let all = Self::fields_for(provider);
731        all.iter()
732            .filter(|f| Self::is_required_field(**f, provider))
733            .copied()
734            .collect()
735    }
736
737    /// Optional fields for this provider (shown after expansion).
738    /// Used in tests only; runtime code slices `fields_for()` directly.
739    #[cfg(test)]
740    pub fn optional_fields_for(provider: &str) -> Vec<ProviderFormField> {
741        let all = Self::fields_for(provider);
742        all.iter()
743            .filter(|f| !Self::is_required_field(**f, provider))
744            .copied()
745            .collect()
746    }
747
748    /// Whether a field is mandatory for form submission (asterisk in renderer).
749    /// Distinct from `is_required_field` which controls progressive disclosure.
750    ///
751    /// Token: asterisked unless the provider can resolve credentials elsewhere
752    /// (`ProviderKind::token_is_optional`).
753    /// Profile: never asterisked. Only AWS shows it. AWS also takes a token or
754    /// environment credentials, so no single one of those fields is mandatory.
755    /// OVH: Regions (= Endpoint) is mandatory (unlike GCP/Oracle where it has
756    /// a meaningful default).
757    /// Label: only rendered when the form is in label-entry mode, and always
758    /// mandatory there. `validate_label("")` rejects empties at save time.
759    pub fn is_mandatory_field(field: ProviderFormField, provider: &str) -> bool {
760        let kind = provider.parse::<ProviderKind>().ok();
761        match field {
762            ProviderFormField::Label => true,
763            ProviderFormField::Url => true,
764            ProviderFormField::Token => !kind.is_some_and(ProviderKind::token_is_optional),
765            ProviderFormField::Profile => false,
766            ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
767            ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
768            ProviderFormField::Regions => {
769                kind.is_some_and(ProviderKind::regions_field_is_mandatory)
770            }
771            _ => false,
772        }
773    }
774
775    /// Whether a field is shown in collapsed mode (progressive disclosure).
776    /// Label is always required when present so the user is forced to fill it
777    /// before reaching the optional tail.
778    pub fn is_required_field(field: ProviderFormField, provider: &str) -> bool {
779        let kind = provider.parse::<ProviderKind>().ok();
780        match field {
781            ProviderFormField::Label => true,
782            ProviderFormField::Token => true,
783            ProviderFormField::Url => kind.is_some_and(ProviderKind::requires_url),
784            ProviderFormField::Profile => kind == Some(ProviderKind::Aws),
785            ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
786            ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
787            ProviderFormField::Regions => kind.is_some_and(ProviderKind::has_regions_field),
788            // Teleport shows the login user up front; every other provider
789            // keeps User behind the expand step.
790            ProviderFormField::User => kind == Some(ProviderKind::Teleport),
791            _ => false,
792        }
793    }
794
795    pub fn next(self, fields: &[Self]) -> Self {
796        debug_assert!(
797            fields.contains(&self),
798            "focused field {:?} not in fields slice",
799            self
800        );
801        let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
802        fields[(idx + 1) % fields.len()]
803    }
804
805    pub fn prev(self, fields: &[Self]) -> Self {
806        debug_assert!(
807            fields.contains(&self),
808            "focused field {:?} not in fields slice",
809            self
810        );
811        let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
812        fields[(idx + fields.len() - 1) % fields.len()]
813    }
814
815    pub fn label(self) -> &'static str {
816        match self {
817            ProviderFormField::Label => "Name",
818            ProviderFormField::Url => "URL",
819            ProviderFormField::Token => "Token",
820            ProviderFormField::Profile => "Profile",
821            ProviderFormField::Project => "Project ID",
822            ProviderFormField::Compartment => "Compartment",
823            ProviderFormField::Regions => "Regions",
824            ProviderFormField::Filter => "Filter",
825            ProviderFormField::Ssm => "Session Manager",
826            ProviderFormField::AliasPrefix => "Alias Prefix",
827            ProviderFormField::User => "User",
828            ProviderFormField::IdentityFile => "Identity File",
829            ProviderFormField::VerifyTls => "Verify TLS",
830            ProviderFormField::VaultRole => "Vault SSH Role",
831            ProviderFormField::VaultAddr => "Vault SSH Address",
832            ProviderFormField::AutoSync => "Auto Sync",
833        }
834    }
835
836    /// Whether Space activates this field in place instead of typing into it.
837    /// Most such fields hold a yes/no and flip; `Ssm` steps through three
838    /// values. Same element, same key, same footer.
839    pub fn is_toggle(self) -> bool {
840        matches!(
841            self,
842            ProviderFormField::VerifyTls | ProviderFormField::AutoSync | ProviderFormField::Ssm
843        )
844    }
845
846    /// Whether this field opens a picker overlay when activated with Space.
847    ///
848    /// `IdentityFile` always opens the SSH key picker. `Regions` opens a
849    /// region picker only for providers with structured region lists
850    /// (aws/scaleway/gcp/oracle/ovh). Other providers (azure, proxmox, ...)
851    /// take Regions as free-form text input, so Space inserts a literal space.
852    /// `Profile` lists the profiles found in `~/.aws`, so the name never has
853    /// to be typed from memory.
854    pub fn is_picker(self, provider: &str) -> bool {
855        match self {
856            ProviderFormField::IdentityFile => true,
857            ProviderFormField::Profile => provider.parse::<ProviderKind>() == Ok(ProviderKind::Aws),
858            ProviderFormField::Regions => provider
859                .parse::<ProviderKind>()
860                .ok()
861                .is_some_and(ProviderKind::regions_field_is_picker),
862            _ => false,
863        }
864    }
865
866    /// Field kind for [`crate::ui::design::FieldKind`]. Toggles take precedence
867    /// over pickers (no field is both).
868    pub fn kind(self, provider: &str) -> crate::ui::design::FieldKind {
869        if self.is_toggle() {
870            crate::ui::design::FieldKind::Toggle
871        } else if self.is_picker(provider) {
872            crate::ui::design::FieldKind::Picker
873        } else {
874            crate::ui::design::FieldKind::Text
875        }
876    }
877}
878
879/// Form state for configuring a provider.
880#[derive(Debug, Clone)]
881pub struct ProviderFormFields {
882    /// Label being entered for a new labeled config. Only visible in the form
883    /// when `label_entry` is true (the `_ =>` branch of `open_add_config_flow`).
884    /// Migration, bare add, and edit flows keep this empty and `label_entry`
885    /// false so the field stays hidden and the label is sourced from `form_id`.
886    pub label: String,
887    /// Whether the form was opened to collect a label from the user. Drives
888    /// `visible_fields` prepending `Label`, the initial focus, and the
889    /// submit-time write of `label` back into `form_id.label`.
890    pub label_entry: bool,
891    pub url: String,
892    pub token: String,
893    pub profile: String,
894    pub project: String,
895    pub compartment: String,
896    pub regions: String,
897    /// Raw NetBox query parameters scoping which objects sync.
898    pub filter: String,
899    pub alias_prefix: String,
900    pub user: String,
901    pub identity_file: String,
902    pub verify_tls: bool,
903    pub auto_sync: bool,
904    pub vault_role: String,
905    /// Whether synced AWS hosts route through Session Manager.
906    pub ssm: crate::providers::aws_ssm::SsmMode,
907    /// Optional `VAULT_ADDR` override. Empty = inherit parent env. The
908    /// rendered input is progressively disclosed: the field is only visible
909    /// in the provider form when `vault_role` is non-empty.
910    pub vault_addr: String,
911    pub focused_field: ProviderFormField,
912    pub cursor_pos: usize,
913    /// Progressive disclosure: false = required fields only, true = all fields.
914    /// Excluded from dirty detection (UI-only state).
915    pub expanded: bool,
916}
917
918impl ProviderFormFields {
919    pub(crate) fn new() -> Self {
920        Self {
921            label: String::new(),
922            label_entry: false,
923            url: String::new(),
924            token: String::new(),
925            profile: String::new(),
926            project: String::new(),
927            compartment: String::new(),
928            regions: String::new(),
929            filter: String::new(),
930            alias_prefix: String::new(),
931            user: "root".to_string(),
932            identity_file: String::new(),
933            verify_tls: true,
934            auto_sync: true,
935            vault_role: String::new(),
936            ssm: crate::providers::aws_ssm::SsmMode::default(),
937            vault_addr: String::new(),
938            focused_field: ProviderFormField::Token,
939            cursor_pos: 0,
940            expanded: false,
941        }
942    }
943
944    pub fn focused_value(&self) -> &str {
945        match self.focused_field {
946            ProviderFormField::Label => &self.label,
947            ProviderFormField::Url => &self.url,
948            ProviderFormField::Token => &self.token,
949            ProviderFormField::Profile => &self.profile,
950            ProviderFormField::Project => &self.project,
951            ProviderFormField::Compartment => &self.compartment,
952            ProviderFormField::Regions => &self.regions,
953            ProviderFormField::Filter => &self.filter,
954            ProviderFormField::AliasPrefix => &self.alias_prefix,
955            ProviderFormField::User => &self.user,
956            ProviderFormField::IdentityFile => &self.identity_file,
957            ProviderFormField::VaultRole => &self.vault_role,
958            ProviderFormField::VaultAddr => &self.vault_addr,
959            ProviderFormField::Ssm => self.ssm.as_str(),
960            ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
961        }
962    }
963
964    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
965        match self.focused_field {
966            ProviderFormField::Label => Some(&mut self.label),
967            ProviderFormField::Url => Some(&mut self.url),
968            ProviderFormField::Token => Some(&mut self.token),
969            ProviderFormField::Profile => Some(&mut self.profile),
970            ProviderFormField::Project => Some(&mut self.project),
971            ProviderFormField::Compartment => Some(&mut self.compartment),
972            ProviderFormField::Regions => Some(&mut self.regions),
973            ProviderFormField::Filter => Some(&mut self.filter),
974            ProviderFormField::AliasPrefix => Some(&mut self.alias_prefix),
975            ProviderFormField::User => Some(&mut self.user),
976            ProviderFormField::IdentityFile => Some(&mut self.identity_file),
977            ProviderFormField::VaultRole => Some(&mut self.vault_role),
978            ProviderFormField::VaultAddr => Some(&mut self.vault_addr),
979            // Ssm steps through a fixed list rather than being typed into.
980            ProviderFormField::Ssm | ProviderFormField::VerifyTls | ProviderFormField::AutoSync => {
981                None
982            }
983        }
984    }
985
986    /// Filter `fields_for(provider)` to the fields that should actually be
987    /// rendered and receive focus. Two dynamic adjustments:
988    /// 1. `Label` is prepended when `label_entry` is true so the user is asked
989    ///    for a new config name on the third-and-onward add (issue #51).
990    /// 2. `VaultAddr` is only visible when `vault_role` is non-empty,
991    ///    mirroring the host form's progressive disclosure.
992    pub fn visible_fields(&self, provider: &str) -> Vec<ProviderFormField> {
993        let role_set = !self.vault_role.trim().is_empty();
994        let base = ProviderFormField::fields_for(provider)
995            .iter()
996            .copied()
997            .filter(|f| *f != ProviderFormField::VaultAddr || role_set);
998        if self.label_entry {
999            std::iter::once(ProviderFormField::Label)
1000                .chain(base)
1001                .collect()
1002        } else {
1003            base.collect()
1004        }
1005    }
1006
1007    pub fn insert_char(&mut self, c: char) {
1008        let pos = self.cursor_pos;
1009        if let Some(val) = self.focused_value_mut() {
1010            let byte_pos = char_to_byte_pos(val, pos);
1011            val.insert(byte_pos, c);
1012            self.cursor_pos = pos + 1;
1013        }
1014    }
1015
1016    pub fn delete_char_before_cursor(&mut self) {
1017        if self.cursor_pos == 0 {
1018            return;
1019        }
1020        let pos = self.cursor_pos;
1021        if let Some(val) = self.focused_value_mut() {
1022            let byte_pos = char_to_byte_pos(val, pos);
1023            let prev = char_to_byte_pos(val, pos - 1);
1024            val.drain(prev..byte_pos);
1025            self.cursor_pos = pos - 1;
1026        }
1027    }
1028
1029    pub fn sync_cursor_to_end(&mut self) {
1030        self.cursor_pos = self.focused_value().chars().count();
1031    }
1032}
1033
1034pub(crate) fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
1035    s.char_indices()
1036        .nth(char_pos)
1037        .map(|(i, _)| i)
1038        .unwrap_or(s.len())
1039}
1040
1041/// Which tunnel form field is focused.
1042#[derive(Debug, Clone, Copy, PartialEq)]
1043pub enum TunnelFormField {
1044    Type,
1045    BindPort,
1046    RemoteHost,
1047    RemotePort,
1048}
1049
1050impl TunnelFormField {
1051    /// Next field, skipping remote fields when Dynamic.
1052    pub fn next(self, tunnel_type: TunnelType) -> Self {
1053        match (self, tunnel_type) {
1054            (TunnelFormField::Type, _) => TunnelFormField::BindPort,
1055            (TunnelFormField::BindPort, TunnelType::Dynamic) => TunnelFormField::Type,
1056            (TunnelFormField::BindPort, _) => TunnelFormField::RemoteHost,
1057            (TunnelFormField::RemoteHost, _) => TunnelFormField::RemotePort,
1058            (TunnelFormField::RemotePort, _) => TunnelFormField::Type,
1059        }
1060    }
1061
1062    /// Previous field, skipping remote fields when Dynamic.
1063    pub fn prev(self, tunnel_type: TunnelType) -> Self {
1064        match (self, tunnel_type) {
1065            (TunnelFormField::Type, TunnelType::Dynamic) => TunnelFormField::BindPort,
1066            (TunnelFormField::Type, _) => TunnelFormField::RemotePort,
1067            (TunnelFormField::BindPort, _) => TunnelFormField::Type,
1068            (TunnelFormField::RemoteHost, _) => TunnelFormField::BindPort,
1069            (TunnelFormField::RemotePort, _) => TunnelFormField::RemoteHost,
1070        }
1071    }
1072
1073    pub fn label(self) -> &'static str {
1074        match self {
1075            TunnelFormField::Type => "Type",
1076            TunnelFormField::BindPort => "Bind Port",
1077            TunnelFormField::RemoteHost => "Remote Host",
1078            TunnelFormField::RemotePort => "Remote Port",
1079        }
1080    }
1081}
1082
1083/// Form state for adding/editing a tunnel.
1084#[derive(Debug, Clone)]
1085pub struct TunnelForm {
1086    pub tunnel_type: TunnelType,
1087    pub bind_port: String,
1088    pub remote_host: String,
1089    pub remote_port: String,
1090    /// Hidden field: preserved during edit, not exposed in the form UI.
1091    pub bind_address: String,
1092    pub focused_field: TunnelFormField,
1093    pub cursor_pos: usize,
1094}
1095
1096impl TunnelForm {
1097    pub(crate) fn new() -> Self {
1098        Self {
1099            tunnel_type: TunnelType::Local,
1100            bind_port: String::new(),
1101            remote_host: "localhost".to_string(),
1102            remote_port: String::new(),
1103            bind_address: String::new(),
1104            focused_field: TunnelFormField::Type,
1105            cursor_pos: 0,
1106        }
1107    }
1108
1109    pub fn from_rule(rule: &TunnelRule) -> Self {
1110        Self {
1111            tunnel_type: rule.tunnel_type,
1112            bind_port: rule.bind_port.to_string(),
1113            remote_host: rule.remote_host.clone(),
1114            remote_port: if rule.remote_port > 0 {
1115                rule.remote_port.to_string()
1116            } else {
1117                String::new()
1118            },
1119            bind_address: rule.bind_address.clone(),
1120            focused_field: TunnelFormField::Type,
1121            cursor_pos: 0,
1122        }
1123    }
1124
1125    /// Advance the focused field to the next one (skipping remote fields
1126    /// when tunnel_type is Dynamic) and sync the cursor to the end of the
1127    /// new focused value.
1128    pub fn focus_next(&mut self) {
1129        self.focused_field = self.focused_field.next(self.tunnel_type);
1130        self.sync_cursor_to_end();
1131    }
1132
1133    /// Retreat the focused field to the previous one (skipping remote
1134    /// fields when tunnel_type is Dynamic) and sync the cursor to the end
1135    /// of the new focused value.
1136    pub fn focus_prev(&mut self) {
1137        self.focused_field = self.focused_field.prev(self.tunnel_type);
1138        self.sync_cursor_to_end();
1139    }
1140
1141    /// Validate the form. Returns error message if invalid.
1142    pub fn validate(&self) -> Result<(), String> {
1143        // Reject control characters in all fields
1144        let fields = [
1145            (&self.bind_port, "Bind Port"),
1146            (&self.remote_host, "Remote Host"),
1147            (&self.remote_port, "Remote Port"),
1148        ];
1149        for (value, name) in &fields {
1150            if value.chars().any(|c| c.is_control()) {
1151                return Err(crate::messages::field_control_chars_short(name));
1152            }
1153        }
1154        let port: u16 = self
1155            .bind_port
1156            .parse()
1157            .map_err(|_| crate::messages::TUNNEL_BIND_PORT_INVALID.to_string())?;
1158        if port == 0 {
1159            return Err(crate::messages::TUNNEL_BIND_PORT_ZERO.to_string());
1160        }
1161        if self.tunnel_type != TunnelType::Dynamic {
1162            let host = self.remote_host.trim();
1163            if host.is_empty() {
1164                return Err(crate::messages::TUNNEL_REMOTE_HOST_EMPTY.to_string());
1165            }
1166            if host.contains(char::is_whitespace) {
1167                return Err(crate::messages::TUNNEL_REMOTE_HOST_SPACES.to_string());
1168            }
1169            let rport: u16 = self
1170                .remote_port
1171                .parse()
1172                .map_err(|_| crate::messages::TUNNEL_REMOTE_PORT_INVALID.to_string())?;
1173            if rport == 0 {
1174                return Err(crate::messages::TUNNEL_REMOTE_PORT_ZERO.to_string());
1175            }
1176        }
1177        Ok(())
1178    }
1179
1180    /// Convert to directive key and value for writing to SSH config.
1181    /// Uses TunnelRule for correct IPv6 bracketing and bind_address preservation.
1182    pub fn to_directive(&self) -> (&'static str, String) {
1183        let key = self.tunnel_type.directive_key();
1184        let bind_port: u16 = self.bind_port.parse().unwrap_or(0);
1185        let remote_port: u16 = self.remote_port.parse().unwrap_or(0);
1186        let rule = TunnelRule {
1187            tunnel_type: self.tunnel_type,
1188            bind_address: self.bind_address.clone(),
1189            bind_port,
1190            remote_host: self.remote_host.clone(),
1191            remote_port,
1192        };
1193        (key, rule.to_directive_value())
1194    }
1195
1196    pub fn focused_value(&self) -> Option<&str> {
1197        match self.focused_field {
1198            TunnelFormField::Type => None,
1199            TunnelFormField::BindPort => Some(&self.bind_port),
1200            TunnelFormField::RemoteHost => Some(&self.remote_host),
1201            TunnelFormField::RemotePort => Some(&self.remote_port),
1202        }
1203    }
1204
1205    /// Get mutable reference to the focused text field's value.
1206    /// Returns None for Type field (uses Left/Right, not text input).
1207    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1208        match self.focused_field {
1209            TunnelFormField::Type => None,
1210            TunnelFormField::BindPort => Some(&mut self.bind_port),
1211            TunnelFormField::RemoteHost => Some(&mut self.remote_host),
1212            TunnelFormField::RemotePort => Some(&mut self.remote_port),
1213        }
1214    }
1215
1216    pub fn insert_char(&mut self, c: char) {
1217        let pos = self.cursor_pos;
1218        if let Some(val) = self.focused_value_mut() {
1219            let byte_pos = char_to_byte_pos(val, pos);
1220            val.insert(byte_pos, c);
1221            self.cursor_pos = pos + 1;
1222        }
1223    }
1224
1225    pub fn delete_char_before_cursor(&mut self) {
1226        if self.cursor_pos == 0 {
1227            return;
1228        }
1229        let pos = self.cursor_pos;
1230        if let Some(val) = self.focused_value_mut() {
1231            let byte_pos = char_to_byte_pos(val, pos);
1232            let prev = char_to_byte_pos(val, pos - 1);
1233            val.drain(prev..byte_pos);
1234            self.cursor_pos = pos - 1;
1235        }
1236    }
1237
1238    pub fn sync_cursor_to_end(&mut self) {
1239        self.cursor_pos = self.focused_value().map(|v| v.chars().count()).unwrap_or(0);
1240    }
1241}
1242
1243/// Which snippet form field is focused.
1244#[derive(Debug, Clone, Copy, PartialEq)]
1245pub enum SnippetFormField {
1246    Name,
1247    Command,
1248    Description,
1249    /// Interactive shell. Not a text field: Space flips `SnippetForm::interactive`.
1250    Interactive,
1251    /// Default target hosts. Not a text field: Space opens the host
1252    /// multi-select picker; the chosen aliases live in `SnippetForm::default_hosts`.
1253    DefaultHosts,
1254}
1255
1256impl SnippetFormField {
1257    pub const ALL: &[SnippetFormField] = &[
1258        SnippetFormField::Name,
1259        SnippetFormField::Command,
1260        SnippetFormField::Description,
1261        SnippetFormField::Interactive,
1262        SnippetFormField::DefaultHosts,
1263    ];
1264
1265    /// True for the picker fields (currently just `DefaultHosts`): Space opens a
1266    /// picker and the field takes no typed text. Mirrors `FormField::is_picker`.
1267    pub fn is_picker(self) -> bool {
1268        matches!(self, SnippetFormField::DefaultHosts)
1269    }
1270
1271    /// True for the toggle fields (currently just `Interactive`): Space flips
1272    /// the value. Mirrors `ProviderFormField::is_toggle`.
1273    pub fn is_toggle(self) -> bool {
1274        matches!(self, SnippetFormField::Interactive)
1275    }
1276
1277    /// Field kind for [`crate::ui::design::FieldKind`], driving the dynamic
1278    /// footer hint (`Space toggle`, `Space pick` or none). Mirrors `FormField::kind`.
1279    pub fn kind(self) -> crate::ui::design::FieldKind {
1280        if self.is_toggle() {
1281            crate::ui::design::FieldKind::Toggle
1282        } else if self.is_picker() {
1283            crate::ui::design::FieldKind::Picker
1284        } else {
1285            crate::ui::design::FieldKind::Text
1286        }
1287    }
1288
1289    pub fn next(self) -> Self {
1290        let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1291        Self::ALL[(idx + 1) % Self::ALL.len()]
1292    }
1293
1294    pub fn prev(self) -> Self {
1295        let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1296        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
1297    }
1298
1299    pub fn label(self) -> &'static str {
1300        match self {
1301            SnippetFormField::Name => "Name",
1302            SnippetFormField::Command => "Command",
1303            SnippetFormField::Description => "Description",
1304            SnippetFormField::Interactive => "Interactive shell",
1305            SnippetFormField::DefaultHosts => "Default hosts",
1306        }
1307    }
1308}
1309
1310/// Form state for adding/editing a snippet.
1311#[derive(Debug, Clone)]
1312pub struct SnippetForm {
1313    pub name: String,
1314    pub command: String,
1315    pub description: String,
1316    pub interactive: bool,
1317    /// Default target host aliases, chosen via the host picker (the
1318    /// `DefaultHosts` field). Seeded from the snippet's saved targets on edit;
1319    /// persisted on submit. Empty means no default hosts.
1320    pub default_hosts: Vec<String>,
1321    pub focused_field: SnippetFormField,
1322    pub cursor_pos: usize,
1323}
1324
1325impl SnippetForm {
1326    pub(crate) fn new() -> Self {
1327        Self {
1328            name: String::new(),
1329            command: String::new(),
1330            description: String::new(),
1331            interactive: false,
1332            default_hosts: Vec::new(),
1333            focused_field: SnippetFormField::Name,
1334            cursor_pos: 0,
1335        }
1336    }
1337
1338    pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
1339        Self {
1340            name: snippet.name.clone(),
1341            command: snippet.command.clone(),
1342            description: snippet.description.clone(),
1343            interactive: snippet.interactive,
1344            // The caller seeds default_hosts from the store's saved targets;
1345            // a Snippet carries no host association of its own.
1346            default_hosts: Vec::new(),
1347            focused_field: SnippetFormField::Name,
1348            cursor_pos: snippet.name.chars().count(),
1349        }
1350    }
1351
1352    /// Advance the focused field to the next one and sync the cursor to
1353    /// the end of the new focused value.
1354    pub fn focus_next(&mut self) {
1355        self.focused_field = self.focused_field.next();
1356        self.sync_cursor_to_end();
1357    }
1358
1359    /// Retreat the focused field to the previous one and sync the cursor
1360    /// to the end of the new focused value.
1361    pub fn focus_prev(&mut self) {
1362        self.focused_field = self.focused_field.prev();
1363        self.sync_cursor_to_end();
1364    }
1365
1366    pub fn focused_value(&self) -> &str {
1367        match self.focused_field {
1368            SnippetFormField::Name => &self.name,
1369            SnippetFormField::Command => &self.command,
1370            SnippetFormField::Description => &self.description,
1371            // Not text fields; no editable value.
1372            SnippetFormField::Interactive | SnippetFormField::DefaultHosts => "",
1373        }
1374    }
1375
1376    /// Mutable text value of the focused field, or `None` for the non-text
1377    /// `Interactive` and `DefaultHosts` fields (so keystroke handlers no-op there).
1378    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1379        match self.focused_field {
1380            SnippetFormField::Name => Some(&mut self.name),
1381            SnippetFormField::Command => Some(&mut self.command),
1382            SnippetFormField::Description => Some(&mut self.description),
1383            SnippetFormField::Interactive | SnippetFormField::DefaultHosts => None,
1384        }
1385    }
1386
1387    pub fn insert_char(&mut self, c: char) {
1388        let pos = self.cursor_pos;
1389        let Some(val) = self.focused_value_mut() else {
1390            return;
1391        };
1392        let byte_pos = char_to_byte_pos(val, pos);
1393        val.insert(byte_pos, c);
1394        self.cursor_pos = pos + 1;
1395    }
1396
1397    pub fn delete_char_before_cursor(&mut self) {
1398        if self.cursor_pos == 0 {
1399            return;
1400        }
1401        let pos = self.cursor_pos;
1402        let Some(val) = self.focused_value_mut() else {
1403            return;
1404        };
1405        let byte_pos = char_to_byte_pos(val, pos);
1406        let prev = char_to_byte_pos(val, pos - 1);
1407        val.drain(prev..byte_pos);
1408        self.cursor_pos = pos - 1;
1409    }
1410
1411    pub fn sync_cursor_to_end(&mut self) {
1412        self.cursor_pos = self.focused_value().chars().count();
1413    }
1414
1415    pub fn validate(&self) -> Result<(), String> {
1416        crate::snippet::validate_name(&self.name)?;
1417        crate::snippet::validate_command(&self.command)?;
1418        if self.description.contains(|c: char| c.is_control()) {
1419            return Err(crate::messages::SNIPPET_DESCRIPTION_CONTROL_CHARS.to_string());
1420        }
1421        Ok(())
1422    }
1423}
1424
1425/// Output from snippet execution, per host.
1426#[derive(Debug, Clone)]
1427pub struct SnippetHostOutput {
1428    pub alias: String,
1429    pub stdout: String,
1430    pub stderr: String,
1431    pub exit_code: Option<i32>,
1432}
1433
1434impl SnippetHostOutput {
1435    /// True when the host reported "command not found" for a snippet that
1436    /// runs in a plain shell, so the output can point at the Interactive
1437    /// shell toggle.
1438    pub fn shows_not_found_hint(&self, interactive: bool) -> bool {
1439        crate::snippet::not_found_hint_applies(interactive, self.exit_code)
1440    }
1441}
1442
1443/// State for the snippet output screen.
1444#[derive(Debug, Clone)]
1445pub struct SnippetOutputState {
1446    pub run_id: u64,
1447    pub results: Vec<SnippetHostOutput>,
1448    pub scroll_offset: usize,
1449    pub completed: usize,
1450    pub total: usize,
1451    pub all_done: bool,
1452    pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1453    /// Whether the running snippet uses the Interactive shell toggle.
1454    pub interactive: bool,
1455}
1456
1457/// Form state for snippet parameter input.
1458#[derive(Debug, Clone)]
1459pub struct SnippetParamFormState {
1460    pub params: Vec<crate::snippet::SnippetParam>,
1461    pub values: Vec<String>,
1462    pub focused_index: usize,
1463    pub cursor_pos: usize,
1464    pub scroll_offset: usize,
1465    /// How many params actually fit on screen (set by renderer).
1466    pub visible_count: usize,
1467}
1468
1469impl SnippetParamFormState {
1470    pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
1471        let values: Vec<String> = params
1472            .iter()
1473            .map(|p| p.default.clone().unwrap_or_default())
1474            .collect();
1475        let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
1476        Self {
1477            params: params.to_vec(),
1478            values,
1479            focused_index: 0,
1480            cursor_pos,
1481            scroll_offset: 0,
1482            visible_count: params.len().min(8),
1483        }
1484    }
1485
1486    pub fn insert_char(&mut self, c: char) {
1487        let idx = self.focused_index;
1488        let pos = self.cursor_pos;
1489        let Some(val) = self.values.get_mut(idx) else {
1490            return;
1491        };
1492        let byte_pos = char_to_byte_pos(val, pos);
1493        val.insert(byte_pos, c);
1494        self.cursor_pos = pos + 1;
1495    }
1496
1497    pub fn delete_char_before_cursor(&mut self) {
1498        if self.cursor_pos == 0 {
1499            return;
1500        }
1501        let idx = self.focused_index;
1502        let pos = self.cursor_pos;
1503        let Some(val) = self.values.get_mut(idx) else {
1504            return;
1505        };
1506        let byte_pos = char_to_byte_pos(val, pos);
1507        let prev = char_to_byte_pos(val, pos - 1);
1508        val.drain(prev..byte_pos);
1509        self.cursor_pos = pos - 1;
1510    }
1511
1512    /// Build a map of param name to user-entered value for substitution.
1513    pub fn values_map(&self) -> HashMap<String, String> {
1514        self.params
1515            .iter()
1516            .enumerate()
1517            .map(|(i, p)| (p.name.clone(), self.values[i].clone()))
1518            .collect()
1519    }
1520
1521    /// Returns true if any parameter value differs from its default.
1522    pub fn is_dirty(&self) -> bool {
1523        self.params.iter().enumerate().any(|(i, p)| {
1524            let default = p.default.as_deref().unwrap_or("");
1525            self.values[i] != default
1526        })
1527    }
1528}
1529
1530#[cfg(test)]
1531mod host_form_method_tests {
1532    use super::*;
1533    use crate::quick_add::ParsedTarget;
1534
1535    #[test]
1536    fn apply_password_source_sets_value_and_keeps_focus_when_refocus_false() {
1537        let mut f = HostForm::new();
1538        f.focused_field = FormField::Alias;
1539        f.apply_password_source("vault:secret/ssh#pw".to_string(), false);
1540        assert_eq!(f.askpass, "vault:secret/ssh#pw");
1541        assert_eq!(f.focused_field, FormField::Alias);
1542        assert_eq!(f.cursor_pos, "vault:secret/ssh#pw".chars().count());
1543    }
1544
1545    #[test]
1546    fn apply_password_source_moves_focus_to_askpass_when_refocus_true() {
1547        let mut f = HostForm::new();
1548        f.focused_field = FormField::Hostname;
1549        f.apply_password_source("op://Vault/Item/pw".to_string(), true);
1550        assert_eq!(f.askpass, "op://Vault/Item/pw");
1551        assert_eq!(f.focused_field, FormField::AskPass);
1552        assert_eq!(f.cursor_pos, "op://Vault/Item/pw".chars().count());
1553    }
1554
1555    #[test]
1556    fn apply_password_source_clears_askpass_with_empty_value() {
1557        let mut f = HostForm::new();
1558        f.askpass = "old".into();
1559        f.focused_field = FormField::AskPass;
1560        f.apply_password_source(String::new(), false);
1561        assert_eq!(f.askpass, "");
1562        assert_eq!(f.cursor_pos, 0);
1563    }
1564
1565    #[test]
1566    fn apply_smart_paste_fills_empty_fields_and_overwrites_alias() {
1567        let mut f = HostForm::new();
1568        f.alias = "user@host:2222".into();
1569        let parsed = ParsedTarget {
1570            user: "alice".into(),
1571            hostname: "db.example.com".into(),
1572            port: 2222,
1573        };
1574        f.apply_smart_paste(parsed, "db".into());
1575        assert_eq!(f.alias, "db");
1576        assert_eq!(f.hostname, "db.example.com");
1577        assert_eq!(f.user, "alice");
1578        assert_eq!(f.port, "2222");
1579    }
1580
1581    #[test]
1582    fn apply_smart_paste_does_not_overwrite_non_empty_hostname() {
1583        let mut f = HostForm::new();
1584        f.hostname = "existing.com".into();
1585        let parsed = ParsedTarget {
1586            user: "u".into(),
1587            hostname: "parsed.com".into(),
1588            port: 22,
1589        };
1590        f.apply_smart_paste(parsed, "x".into());
1591        assert_eq!(f.hostname, "existing.com");
1592    }
1593
1594    #[test]
1595    fn apply_smart_paste_does_not_overwrite_non_empty_user() {
1596        let mut f = HostForm::new();
1597        f.user = "bob".into();
1598        let parsed = ParsedTarget {
1599            user: "alice".into(),
1600            hostname: "host".into(),
1601            port: 22,
1602        };
1603        f.apply_smart_paste(parsed, "x".into());
1604        assert_eq!(f.user, "bob");
1605    }
1606
1607    #[test]
1608    fn apply_smart_paste_keeps_default_port_when_parsed_port_is_default() {
1609        let mut f = HostForm::new();
1610        let parsed = ParsedTarget {
1611            user: "u".into(),
1612            hostname: "h".into(),
1613            port: 22,
1614        };
1615        f.apply_smart_paste(parsed, "x".into());
1616        assert_eq!(f.port, "22");
1617    }
1618
1619    #[test]
1620    fn apply_smart_paste_does_not_overwrite_user_when_parsed_user_is_empty() {
1621        let mut f = HostForm::new();
1622        let parsed = ParsedTarget {
1623            user: String::new(),
1624            hostname: "h".into(),
1625            port: 22,
1626        };
1627        f.apply_smart_paste(parsed, "x".into());
1628        assert_eq!(f.user, "");
1629    }
1630
1631    #[test]
1632    fn apply_smart_paste_keeps_user_custom_port_even_when_parsed_port_differs() {
1633        let mut f = HostForm::new();
1634        f.port = "8022".into();
1635        let parsed = ParsedTarget {
1636            user: "u".into(),
1637            hostname: "h".into(),
1638            port: 2222,
1639        };
1640        f.apply_smart_paste(parsed, "x".into());
1641        assert_eq!(f.port, "8022");
1642    }
1643
1644    #[test]
1645    fn apply_password_source_empty_value_with_refocus_moves_focus_and_zeros_cursor() {
1646        let mut f = HostForm::new();
1647        f.focused_field = FormField::Hostname;
1648        f.apply_password_source(String::new(), true);
1649        assert_eq!(f.askpass, "");
1650        assert_eq!(f.focused_field, FormField::AskPass);
1651        assert_eq!(f.cursor_pos, 0);
1652    }
1653
1654    #[test]
1655    fn tunnel_form_focus_next_from_type_goes_to_bind_port_and_resets_cursor() {
1656        let mut f = TunnelForm::new();
1657        f.bind_port = "8080".into();
1658        f.cursor_pos = 99;
1659        assert_eq!(f.focused_field, TunnelFormField::Type);
1660        f.focus_next();
1661        assert_eq!(f.focused_field, TunnelFormField::BindPort);
1662        // sync_cursor_to_end moves to end of focused value (chars count).
1663        assert_eq!(f.cursor_pos, 4);
1664    }
1665
1666    #[test]
1667    fn tunnel_form_focus_next_wraps_from_remote_port_back_to_type() {
1668        let mut f = TunnelForm::new();
1669        f.focused_field = TunnelFormField::RemotePort;
1670        // Non-empty source field so a wrong-ordering regression
1671        // (sync_cursor_to_end before the field assignment) would
1672        // produce cursor_pos = 3 instead of 0.
1673        f.remote_port = "443".into();
1674        f.cursor_pos = 99;
1675        f.focus_next();
1676        assert_eq!(f.focused_field, TunnelFormField::Type);
1677        assert_eq!(f.cursor_pos, 0);
1678    }
1679
1680    #[test]
1681    fn tunnel_form_focus_next_dynamic_skips_remote_fields() {
1682        let mut f = TunnelForm::new();
1683        f.tunnel_type = TunnelType::Dynamic;
1684        f.focused_field = TunnelFormField::BindPort;
1685        // Non-empty source field so a wrong-ordering regression would
1686        // yield cursor_pos = 4, distinguishing it from the correct 0.
1687        f.bind_port = "8080".into();
1688        f.cursor_pos = 99;
1689        f.focus_next();
1690        // Dynamic skips RemoteHost + RemotePort.
1691        assert_eq!(f.focused_field, TunnelFormField::Type);
1692        assert_eq!(f.cursor_pos, 0);
1693    }
1694
1695    #[test]
1696    fn tunnel_form_focus_prev_from_bind_port_goes_to_type() {
1697        let mut f = TunnelForm::new();
1698        f.focused_field = TunnelFormField::BindPort;
1699        // Non-empty source field so a wrong-ordering regression would
1700        // yield cursor_pos = 4, distinguishing it from the correct 0.
1701        f.bind_port = "8080".into();
1702        f.cursor_pos = 99;
1703        f.focus_prev();
1704        assert_eq!(f.focused_field, TunnelFormField::Type);
1705        assert_eq!(f.cursor_pos, 0);
1706    }
1707
1708    #[test]
1709    fn tunnel_form_focus_next_preserves_field_values_and_tunnel_type() {
1710        let mut f = TunnelForm {
1711            tunnel_type: TunnelType::Local,
1712            bind_port: "1234".into(),
1713            remote_host: "example.com".into(),
1714            remote_port: "5678".into(),
1715            bind_address: "127.0.0.1".into(),
1716            focused_field: TunnelFormField::Type,
1717            cursor_pos: 0,
1718        };
1719        f.focus_next();
1720        assert_eq!(f.bind_port, "1234");
1721        assert_eq!(f.remote_host, "example.com");
1722        assert_eq!(f.remote_port, "5678");
1723        assert_eq!(f.bind_address, "127.0.0.1");
1724        assert_eq!(f.tunnel_type, TunnelType::Local);
1725    }
1726
1727    #[test]
1728    fn snippet_form_focus_next_advances_field_and_syncs_cursor_to_target() {
1729        let mut f = SnippetForm::new();
1730        // Distinct lengths so a wrong-ordering regression (sync before
1731        // assignment) would land cursor at 3 instead of the correct 2.
1732        f.name = "abc".into();
1733        f.command = "de".into();
1734        f.focused_field = SnippetFormField::Name;
1735        f.cursor_pos = 99;
1736        f.focus_next();
1737        assert_eq!(f.focused_field, SnippetFormField::Command);
1738        assert_eq!(f.cursor_pos, 2);
1739    }
1740
1741    #[test]
1742    fn snippet_form_focus_prev_retreats_field_and_syncs_cursor_to_target() {
1743        let mut f = SnippetForm::new();
1744        // Source and target have distinct lengths: wrong ordering would
1745        // yield cursor_pos = 2 (end of source command), correct is 3.
1746        f.name = "xyz".into();
1747        f.command = "ab".into();
1748        f.focused_field = SnippetFormField::Command;
1749        f.cursor_pos = 99;
1750        f.focus_prev();
1751        assert_eq!(f.focused_field, SnippetFormField::Name);
1752        assert_eq!(f.cursor_pos, 3);
1753    }
1754
1755    #[test]
1756    fn snippet_form_focus_next_preserves_field_values() {
1757        let mut f = SnippetForm::new();
1758        f.name = "foo".into();
1759        f.command = "bar".into();
1760        f.description = "baz".into();
1761        f.focus_next();
1762        assert_eq!(f.name, "foo");
1763        assert_eq!(f.command, "bar");
1764        assert_eq!(f.description, "baz");
1765    }
1766}