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    AliasPrefix,
573    User,
574    IdentityFile,
575    VerifyTls,
576    VaultRole,
577    VaultAddr,
578    AutoSync,
579}
580
581impl ProviderFormField {
582    const CLOUD_FIELDS: &[ProviderFormField] = &[
583        ProviderFormField::Token,
584        ProviderFormField::AliasPrefix,
585        ProviderFormField::User,
586        ProviderFormField::IdentityFile,
587        ProviderFormField::VaultRole,
588        ProviderFormField::VaultAddr,
589        ProviderFormField::AutoSync,
590    ];
591
592    const PROXMOX_FIELDS: &[ProviderFormField] = &[
593        ProviderFormField::Url,
594        ProviderFormField::Token,
595        ProviderFormField::AliasPrefix,
596        ProviderFormField::User,
597        ProviderFormField::IdentityFile,
598        ProviderFormField::VerifyTls,
599        ProviderFormField::VaultRole,
600        ProviderFormField::VaultAddr,
601        ProviderFormField::AutoSync,
602    ];
603
604    const AWS_FIELDS: &[ProviderFormField] = &[
605        ProviderFormField::Token,
606        ProviderFormField::Profile,
607        ProviderFormField::Regions,
608        ProviderFormField::AliasPrefix,
609        ProviderFormField::User,
610        ProviderFormField::IdentityFile,
611        ProviderFormField::VaultRole,
612        ProviderFormField::VaultAddr,
613        ProviderFormField::AutoSync,
614    ];
615
616    const SCALEWAY_FIELDS: &[ProviderFormField] = &[
617        ProviderFormField::Token,
618        ProviderFormField::Regions,
619        ProviderFormField::AliasPrefix,
620        ProviderFormField::User,
621        ProviderFormField::IdentityFile,
622        ProviderFormField::VaultRole,
623        ProviderFormField::VaultAddr,
624        ProviderFormField::AutoSync,
625    ];
626
627    const GCP_FIELDS: &[ProviderFormField] = &[
628        ProviderFormField::Token,
629        ProviderFormField::Project,
630        ProviderFormField::Regions,
631        ProviderFormField::AliasPrefix,
632        ProviderFormField::User,
633        ProviderFormField::IdentityFile,
634        ProviderFormField::VaultRole,
635        ProviderFormField::VaultAddr,
636        ProviderFormField::AutoSync,
637    ];
638
639    const AZURE_FIELDS: &[ProviderFormField] = &[
640        ProviderFormField::Token,
641        ProviderFormField::Regions,
642        ProviderFormField::AliasPrefix,
643        ProviderFormField::User,
644        ProviderFormField::IdentityFile,
645        ProviderFormField::VaultRole,
646        ProviderFormField::VaultAddr,
647        ProviderFormField::AutoSync,
648    ];
649
650    const ORACLE_FIELDS: &[ProviderFormField] = &[
651        ProviderFormField::Token,
652        ProviderFormField::Compartment,
653        ProviderFormField::Regions,
654        ProviderFormField::AliasPrefix,
655        ProviderFormField::User,
656        ProviderFormField::IdentityFile,
657        ProviderFormField::VaultRole,
658        ProviderFormField::VaultAddr,
659        ProviderFormField::AutoSync,
660    ];
661
662    /// Teleport has no token and issues its own certificates, so neither the
663    /// Token nor the Vault fields apply. User (the Teleport login) leads so the
664    /// collapsed form has a field to show.
665    const TELEPORT_FIELDS: &[ProviderFormField] = &[
666        ProviderFormField::User,
667        ProviderFormField::AliasPrefix,
668        ProviderFormField::IdentityFile,
669        ProviderFormField::AutoSync,
670    ];
671
672    const OVH_FIELDS: &[ProviderFormField] = &[
673        ProviderFormField::Token,
674        ProviderFormField::Project,
675        ProviderFormField::Regions,
676        ProviderFormField::AliasPrefix,
677        ProviderFormField::User,
678        ProviderFormField::IdentityFile,
679        ProviderFormField::VaultRole,
680        ProviderFormField::VaultAddr,
681        ProviderFormField::AutoSync,
682    ];
683
684    pub fn fields_for(provider: &str) -> &'static [ProviderFormField] {
685        let Ok(kind) = provider.parse::<ProviderKind>() else {
686            return Self::CLOUD_FIELDS;
687        };
688        match kind {
689            ProviderKind::Proxmox => Self::PROXMOX_FIELDS,
690            ProviderKind::Aws => Self::AWS_FIELDS,
691            ProviderKind::Scaleway => Self::SCALEWAY_FIELDS,
692            ProviderKind::Gcp => Self::GCP_FIELDS,
693            ProviderKind::Azure => Self::AZURE_FIELDS,
694            ProviderKind::Oracle => Self::ORACLE_FIELDS,
695            ProviderKind::Ovh => Self::OVH_FIELDS,
696            ProviderKind::Teleport => Self::TELEPORT_FIELDS,
697            ProviderKind::DigitalOcean
698            | ProviderKind::Hetzner
699            | ProviderKind::I3d
700            | ProviderKind::Leaseweb
701            | ProviderKind::Linode
702            | ProviderKind::Tailscale
703            | ProviderKind::Transip
704            | ProviderKind::UpCloud
705            | ProviderKind::Vultr => Self::CLOUD_FIELDS,
706        }
707    }
708
709    /// Required fields for this provider (always visible in collapsed mode).
710    /// Used in tests only; runtime code slices `fields_for()` directly.
711    #[cfg(test)]
712    pub fn required_fields_for(provider: &str) -> Vec<ProviderFormField> {
713        let all = Self::fields_for(provider);
714        all.iter()
715            .filter(|f| Self::is_required_field(**f, provider))
716            .copied()
717            .collect()
718    }
719
720    /// Optional fields for this provider (shown after expansion).
721    /// Used in tests only; runtime code slices `fields_for()` directly.
722    #[cfg(test)]
723    pub fn optional_fields_for(provider: &str) -> Vec<ProviderFormField> {
724        let all = Self::fields_for(provider);
725        all.iter()
726            .filter(|f| !Self::is_required_field(**f, provider))
727            .copied()
728            .collect()
729    }
730
731    /// Whether a field is mandatory for form submission (asterisk in renderer).
732    /// Distinct from `is_required_field` which controls progressive disclosure.
733    ///
734    /// Token: asterisked unless the provider can resolve credentials elsewhere
735    /// (`ProviderKind::token_is_optional`).
736    /// Profile: never asterisked. Only AWS shows it. AWS also takes a token or
737    /// environment credentials, so no single one of those fields is mandatory.
738    /// OVH: Regions (= Endpoint) is mandatory (unlike GCP/Oracle where it has
739    /// a meaningful default).
740    /// Label: only rendered when the form is in label-entry mode, and always
741    /// mandatory there. `validate_label("")` rejects empties at save time.
742    pub fn is_mandatory_field(field: ProviderFormField, provider: &str) -> bool {
743        let kind = provider.parse::<ProviderKind>().ok();
744        match field {
745            ProviderFormField::Label => true,
746            ProviderFormField::Url => true,
747            ProviderFormField::Token => !kind.is_some_and(ProviderKind::token_is_optional),
748            ProviderFormField::Profile => false,
749            ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
750            ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
751            ProviderFormField::Regions => {
752                kind.is_some_and(ProviderKind::regions_field_is_mandatory)
753            }
754            _ => false,
755        }
756    }
757
758    /// Whether a field is shown in collapsed mode (progressive disclosure).
759    /// Label is always required when present so the user is forced to fill it
760    /// before reaching the optional tail.
761    pub fn is_required_field(field: ProviderFormField, provider: &str) -> bool {
762        let kind = provider.parse::<ProviderKind>().ok();
763        match field {
764            ProviderFormField::Label => true,
765            ProviderFormField::Token => true,
766            ProviderFormField::Url => kind.is_some_and(ProviderKind::requires_url),
767            ProviderFormField::Profile => kind == Some(ProviderKind::Aws),
768            ProviderFormField::Project => kind.is_some_and(ProviderKind::has_project_field),
769            ProviderFormField::Compartment => kind == Some(ProviderKind::Oracle),
770            ProviderFormField::Regions => kind.is_some_and(ProviderKind::has_regions_field),
771            // Teleport shows the login user up front; every other provider
772            // keeps User behind the expand step.
773            ProviderFormField::User => kind == Some(ProviderKind::Teleport),
774            _ => false,
775        }
776    }
777
778    pub fn next(self, fields: &[Self]) -> Self {
779        debug_assert!(
780            fields.contains(&self),
781            "focused field {:?} not in fields slice",
782            self
783        );
784        let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
785        fields[(idx + 1) % fields.len()]
786    }
787
788    pub fn prev(self, fields: &[Self]) -> Self {
789        debug_assert!(
790            fields.contains(&self),
791            "focused field {:?} not in fields slice",
792            self
793        );
794        let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
795        fields[(idx + fields.len() - 1) % fields.len()]
796    }
797
798    pub fn label(self) -> &'static str {
799        match self {
800            ProviderFormField::Label => "Name",
801            ProviderFormField::Url => "URL",
802            ProviderFormField::Token => "Token",
803            ProviderFormField::Profile => "Profile",
804            ProviderFormField::Project => "Project ID",
805            ProviderFormField::Compartment => "Compartment",
806            ProviderFormField::Regions => "Regions",
807            ProviderFormField::AliasPrefix => "Alias Prefix",
808            ProviderFormField::User => "User",
809            ProviderFormField::IdentityFile => "Identity File",
810            ProviderFormField::VerifyTls => "Verify TLS",
811            ProviderFormField::VaultRole => "Vault SSH Role",
812            ProviderFormField::VaultAddr => "Vault SSH Address",
813            ProviderFormField::AutoSync => "Auto Sync",
814        }
815    }
816
817    /// Whether this field is a boolean toggle (Space flips the value).
818    pub fn is_toggle(self) -> bool {
819        matches!(
820            self,
821            ProviderFormField::VerifyTls | ProviderFormField::AutoSync
822        )
823    }
824
825    /// Whether this field opens a picker overlay when activated with Space.
826    ///
827    /// `IdentityFile` always opens the SSH key picker. `Regions` opens a
828    /// region picker only for providers with structured region lists
829    /// (aws/scaleway/gcp/oracle/ovh). Other providers (azure, proxmox, ...)
830    /// take Regions as free-form text input — Space inserts a literal space.
831    pub fn is_picker(self, provider: &str) -> bool {
832        match self {
833            ProviderFormField::IdentityFile => true,
834            ProviderFormField::Regions => provider
835                .parse::<ProviderKind>()
836                .ok()
837                .is_some_and(ProviderKind::regions_field_is_picker),
838            _ => false,
839        }
840    }
841
842    /// Field kind for [`crate::ui::design::FieldKind`]. Toggles take precedence
843    /// over pickers (no field is both).
844    pub fn kind(self, provider: &str) -> crate::ui::design::FieldKind {
845        if self.is_toggle() {
846            crate::ui::design::FieldKind::Toggle
847        } else if self.is_picker(provider) {
848            crate::ui::design::FieldKind::Picker
849        } else {
850            crate::ui::design::FieldKind::Text
851        }
852    }
853}
854
855/// Form state for configuring a provider.
856#[derive(Debug, Clone)]
857pub struct ProviderFormFields {
858    /// Label being entered for a new labeled config. Only visible in the form
859    /// when `label_entry` is true (the `_ =>` branch of `open_add_config_flow`).
860    /// Migration, bare add, and edit flows keep this empty and `label_entry`
861    /// false so the field stays hidden and the label is sourced from `form_id`.
862    pub label: String,
863    /// Whether the form was opened to collect a label from the user. Drives
864    /// `visible_fields` prepending `Label`, the initial focus, and the
865    /// submit-time write of `label` back into `form_id.label`.
866    pub label_entry: bool,
867    pub url: String,
868    pub token: String,
869    pub profile: String,
870    pub project: String,
871    pub compartment: String,
872    pub regions: String,
873    pub alias_prefix: String,
874    pub user: String,
875    pub identity_file: String,
876    pub verify_tls: bool,
877    pub auto_sync: bool,
878    pub vault_role: String,
879    /// Optional `VAULT_ADDR` override. Empty = inherit parent env. The
880    /// rendered input is progressively disclosed: the field is only visible
881    /// in the provider form when `vault_role` is non-empty.
882    pub vault_addr: String,
883    pub focused_field: ProviderFormField,
884    pub cursor_pos: usize,
885    /// Progressive disclosure: false = required fields only, true = all fields.
886    /// Excluded from dirty detection (UI-only state).
887    pub expanded: bool,
888}
889
890impl ProviderFormFields {
891    pub(crate) fn new() -> Self {
892        Self {
893            label: String::new(),
894            label_entry: false,
895            url: String::new(),
896            token: String::new(),
897            profile: String::new(),
898            project: String::new(),
899            compartment: String::new(),
900            regions: String::new(),
901            alias_prefix: String::new(),
902            user: "root".to_string(),
903            identity_file: String::new(),
904            verify_tls: true,
905            auto_sync: true,
906            vault_role: String::new(),
907            vault_addr: String::new(),
908            focused_field: ProviderFormField::Token,
909            cursor_pos: 0,
910            expanded: false,
911        }
912    }
913
914    pub fn focused_value(&self) -> &str {
915        match self.focused_field {
916            ProviderFormField::Label => &self.label,
917            ProviderFormField::Url => &self.url,
918            ProviderFormField::Token => &self.token,
919            ProviderFormField::Profile => &self.profile,
920            ProviderFormField::Project => &self.project,
921            ProviderFormField::Compartment => &self.compartment,
922            ProviderFormField::Regions => &self.regions,
923            ProviderFormField::AliasPrefix => &self.alias_prefix,
924            ProviderFormField::User => &self.user,
925            ProviderFormField::IdentityFile => &self.identity_file,
926            ProviderFormField::VaultRole => &self.vault_role,
927            ProviderFormField::VaultAddr => &self.vault_addr,
928            ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
929        }
930    }
931
932    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
933        match self.focused_field {
934            ProviderFormField::Label => Some(&mut self.label),
935            ProviderFormField::Url => Some(&mut self.url),
936            ProviderFormField::Token => Some(&mut self.token),
937            ProviderFormField::Profile => Some(&mut self.profile),
938            ProviderFormField::Project => Some(&mut self.project),
939            ProviderFormField::Compartment => Some(&mut self.compartment),
940            ProviderFormField::Regions => Some(&mut self.regions),
941            ProviderFormField::AliasPrefix => Some(&mut self.alias_prefix),
942            ProviderFormField::User => Some(&mut self.user),
943            ProviderFormField::IdentityFile => Some(&mut self.identity_file),
944            ProviderFormField::VaultRole => Some(&mut self.vault_role),
945            ProviderFormField::VaultAddr => Some(&mut self.vault_addr),
946            ProviderFormField::VerifyTls | ProviderFormField::AutoSync => None,
947        }
948    }
949
950    /// Filter `fields_for(provider)` to the fields that should actually be
951    /// rendered and receive focus. Two dynamic adjustments:
952    /// 1. `Label` is prepended when `label_entry` is true so the user is asked
953    ///    for a new config name on the third-and-onward add (issue #51).
954    /// 2. `VaultAddr` is only visible when `vault_role` is non-empty,
955    ///    mirroring the host form's progressive disclosure.
956    pub fn visible_fields(&self, provider: &str) -> Vec<ProviderFormField> {
957        let role_set = !self.vault_role.trim().is_empty();
958        let base = ProviderFormField::fields_for(provider)
959            .iter()
960            .copied()
961            .filter(|f| *f != ProviderFormField::VaultAddr || role_set);
962        if self.label_entry {
963            std::iter::once(ProviderFormField::Label)
964                .chain(base)
965                .collect()
966        } else {
967            base.collect()
968        }
969    }
970
971    pub fn insert_char(&mut self, c: char) {
972        let pos = self.cursor_pos;
973        if let Some(val) = self.focused_value_mut() {
974            let byte_pos = char_to_byte_pos(val, pos);
975            val.insert(byte_pos, c);
976            self.cursor_pos = pos + 1;
977        }
978    }
979
980    pub fn delete_char_before_cursor(&mut self) {
981        if self.cursor_pos == 0 {
982            return;
983        }
984        let pos = self.cursor_pos;
985        if let Some(val) = self.focused_value_mut() {
986            let byte_pos = char_to_byte_pos(val, pos);
987            let prev = char_to_byte_pos(val, pos - 1);
988            val.drain(prev..byte_pos);
989            self.cursor_pos = pos - 1;
990        }
991    }
992
993    pub fn sync_cursor_to_end(&mut self) {
994        self.cursor_pos = self.focused_value().chars().count();
995    }
996}
997
998pub(crate) fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
999    s.char_indices()
1000        .nth(char_pos)
1001        .map(|(i, _)| i)
1002        .unwrap_or(s.len())
1003}
1004
1005/// Which tunnel form field is focused.
1006#[derive(Debug, Clone, Copy, PartialEq)]
1007pub enum TunnelFormField {
1008    Type,
1009    BindPort,
1010    RemoteHost,
1011    RemotePort,
1012}
1013
1014impl TunnelFormField {
1015    /// Next field, skipping remote fields when Dynamic.
1016    pub fn next(self, tunnel_type: TunnelType) -> Self {
1017        match (self, tunnel_type) {
1018            (TunnelFormField::Type, _) => TunnelFormField::BindPort,
1019            (TunnelFormField::BindPort, TunnelType::Dynamic) => TunnelFormField::Type,
1020            (TunnelFormField::BindPort, _) => TunnelFormField::RemoteHost,
1021            (TunnelFormField::RemoteHost, _) => TunnelFormField::RemotePort,
1022            (TunnelFormField::RemotePort, _) => TunnelFormField::Type,
1023        }
1024    }
1025
1026    /// Previous field, skipping remote fields when Dynamic.
1027    pub fn prev(self, tunnel_type: TunnelType) -> Self {
1028        match (self, tunnel_type) {
1029            (TunnelFormField::Type, TunnelType::Dynamic) => TunnelFormField::BindPort,
1030            (TunnelFormField::Type, _) => TunnelFormField::RemotePort,
1031            (TunnelFormField::BindPort, _) => TunnelFormField::Type,
1032            (TunnelFormField::RemoteHost, _) => TunnelFormField::BindPort,
1033            (TunnelFormField::RemotePort, _) => TunnelFormField::RemoteHost,
1034        }
1035    }
1036
1037    pub fn label(self) -> &'static str {
1038        match self {
1039            TunnelFormField::Type => "Type",
1040            TunnelFormField::BindPort => "Bind Port",
1041            TunnelFormField::RemoteHost => "Remote Host",
1042            TunnelFormField::RemotePort => "Remote Port",
1043        }
1044    }
1045}
1046
1047/// Form state for adding/editing a tunnel.
1048#[derive(Debug, Clone)]
1049pub struct TunnelForm {
1050    pub tunnel_type: TunnelType,
1051    pub bind_port: String,
1052    pub remote_host: String,
1053    pub remote_port: String,
1054    /// Hidden field: preserved during edit, not exposed in the form UI.
1055    pub bind_address: String,
1056    pub focused_field: TunnelFormField,
1057    pub cursor_pos: usize,
1058}
1059
1060impl TunnelForm {
1061    pub(crate) fn new() -> Self {
1062        Self {
1063            tunnel_type: TunnelType::Local,
1064            bind_port: String::new(),
1065            remote_host: "localhost".to_string(),
1066            remote_port: String::new(),
1067            bind_address: String::new(),
1068            focused_field: TunnelFormField::Type,
1069            cursor_pos: 0,
1070        }
1071    }
1072
1073    pub fn from_rule(rule: &TunnelRule) -> Self {
1074        Self {
1075            tunnel_type: rule.tunnel_type,
1076            bind_port: rule.bind_port.to_string(),
1077            remote_host: rule.remote_host.clone(),
1078            remote_port: if rule.remote_port > 0 {
1079                rule.remote_port.to_string()
1080            } else {
1081                String::new()
1082            },
1083            bind_address: rule.bind_address.clone(),
1084            focused_field: TunnelFormField::Type,
1085            cursor_pos: 0,
1086        }
1087    }
1088
1089    /// Advance the focused field to the next one (skipping remote fields
1090    /// when tunnel_type is Dynamic) and sync the cursor to the end of the
1091    /// new focused value.
1092    pub fn focus_next(&mut self) {
1093        self.focused_field = self.focused_field.next(self.tunnel_type);
1094        self.sync_cursor_to_end();
1095    }
1096
1097    /// Retreat the focused field to the previous one (skipping remote
1098    /// fields when tunnel_type is Dynamic) and sync the cursor to the end
1099    /// of the new focused value.
1100    pub fn focus_prev(&mut self) {
1101        self.focused_field = self.focused_field.prev(self.tunnel_type);
1102        self.sync_cursor_to_end();
1103    }
1104
1105    /// Validate the form. Returns error message if invalid.
1106    pub fn validate(&self) -> Result<(), String> {
1107        // Reject control characters in all fields
1108        let fields = [
1109            (&self.bind_port, "Bind Port"),
1110            (&self.remote_host, "Remote Host"),
1111            (&self.remote_port, "Remote Port"),
1112        ];
1113        for (value, name) in &fields {
1114            if value.chars().any(|c| c.is_control()) {
1115                return Err(crate::messages::field_control_chars_short(name));
1116            }
1117        }
1118        let port: u16 = self
1119            .bind_port
1120            .parse()
1121            .map_err(|_| crate::messages::TUNNEL_BIND_PORT_INVALID.to_string())?;
1122        if port == 0 {
1123            return Err(crate::messages::TUNNEL_BIND_PORT_ZERO.to_string());
1124        }
1125        if self.tunnel_type != TunnelType::Dynamic {
1126            let host = self.remote_host.trim();
1127            if host.is_empty() {
1128                return Err(crate::messages::TUNNEL_REMOTE_HOST_EMPTY.to_string());
1129            }
1130            if host.contains(char::is_whitespace) {
1131                return Err(crate::messages::TUNNEL_REMOTE_HOST_SPACES.to_string());
1132            }
1133            let rport: u16 = self
1134                .remote_port
1135                .parse()
1136                .map_err(|_| crate::messages::TUNNEL_REMOTE_PORT_INVALID.to_string())?;
1137            if rport == 0 {
1138                return Err(crate::messages::TUNNEL_REMOTE_PORT_ZERO.to_string());
1139            }
1140        }
1141        Ok(())
1142    }
1143
1144    /// Convert to directive key and value for writing to SSH config.
1145    /// Uses TunnelRule for correct IPv6 bracketing and bind_address preservation.
1146    pub fn to_directive(&self) -> (&'static str, String) {
1147        let key = self.tunnel_type.directive_key();
1148        let bind_port: u16 = self.bind_port.parse().unwrap_or(0);
1149        let remote_port: u16 = self.remote_port.parse().unwrap_or(0);
1150        let rule = TunnelRule {
1151            tunnel_type: self.tunnel_type,
1152            bind_address: self.bind_address.clone(),
1153            bind_port,
1154            remote_host: self.remote_host.clone(),
1155            remote_port,
1156        };
1157        (key, rule.to_directive_value())
1158    }
1159
1160    pub fn focused_value(&self) -> Option<&str> {
1161        match self.focused_field {
1162            TunnelFormField::Type => None,
1163            TunnelFormField::BindPort => Some(&self.bind_port),
1164            TunnelFormField::RemoteHost => Some(&self.remote_host),
1165            TunnelFormField::RemotePort => Some(&self.remote_port),
1166        }
1167    }
1168
1169    /// Get mutable reference to the focused text field's value.
1170    /// Returns None for Type field (uses Left/Right, not text input).
1171    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1172        match self.focused_field {
1173            TunnelFormField::Type => None,
1174            TunnelFormField::BindPort => Some(&mut self.bind_port),
1175            TunnelFormField::RemoteHost => Some(&mut self.remote_host),
1176            TunnelFormField::RemotePort => Some(&mut self.remote_port),
1177        }
1178    }
1179
1180    pub fn insert_char(&mut self, c: char) {
1181        let pos = self.cursor_pos;
1182        if let Some(val) = self.focused_value_mut() {
1183            let byte_pos = char_to_byte_pos(val, pos);
1184            val.insert(byte_pos, c);
1185            self.cursor_pos = pos + 1;
1186        }
1187    }
1188
1189    pub fn delete_char_before_cursor(&mut self) {
1190        if self.cursor_pos == 0 {
1191            return;
1192        }
1193        let pos = self.cursor_pos;
1194        if let Some(val) = self.focused_value_mut() {
1195            let byte_pos = char_to_byte_pos(val, pos);
1196            let prev = char_to_byte_pos(val, pos - 1);
1197            val.drain(prev..byte_pos);
1198            self.cursor_pos = pos - 1;
1199        }
1200    }
1201
1202    pub fn sync_cursor_to_end(&mut self) {
1203        self.cursor_pos = self.focused_value().map(|v| v.chars().count()).unwrap_or(0);
1204    }
1205}
1206
1207/// Which snippet form field is focused.
1208#[derive(Debug, Clone, Copy, PartialEq)]
1209pub enum SnippetFormField {
1210    Name,
1211    Command,
1212    Description,
1213    /// Default target hosts. Not a text field: Space opens the host
1214    /// multi-select picker; the chosen aliases live in `SnippetForm::default_hosts`.
1215    DefaultHosts,
1216}
1217
1218impl SnippetFormField {
1219    pub const ALL: &[SnippetFormField] = &[
1220        SnippetFormField::Name,
1221        SnippetFormField::Command,
1222        SnippetFormField::Description,
1223        SnippetFormField::DefaultHosts,
1224    ];
1225
1226    /// True for the picker fields (currently just `DefaultHosts`): Space opens a
1227    /// picker and the field takes no typed text. Mirrors `FormField::is_picker`.
1228    pub fn is_picker(self) -> bool {
1229        matches!(self, SnippetFormField::DefaultHosts)
1230    }
1231
1232    /// Field kind for [`crate::ui::design::FieldKind`], driving the dynamic
1233    /// footer hint (`Space pick` vs none). Mirrors `FormField::kind`.
1234    pub fn kind(self) -> crate::ui::design::FieldKind {
1235        if self.is_picker() {
1236            crate::ui::design::FieldKind::Picker
1237        } else {
1238            crate::ui::design::FieldKind::Text
1239        }
1240    }
1241
1242    pub fn next(self) -> Self {
1243        let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1244        Self::ALL[(idx + 1) % Self::ALL.len()]
1245    }
1246
1247    pub fn prev(self) -> Self {
1248        let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
1249        Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
1250    }
1251
1252    pub fn label(self) -> &'static str {
1253        match self {
1254            SnippetFormField::Name => "Name",
1255            SnippetFormField::Command => "Command",
1256            SnippetFormField::Description => "Description",
1257            SnippetFormField::DefaultHosts => "Default hosts",
1258        }
1259    }
1260}
1261
1262/// Form state for adding/editing a snippet.
1263#[derive(Debug, Clone)]
1264pub struct SnippetForm {
1265    pub name: String,
1266    pub command: String,
1267    pub description: String,
1268    /// Default target host aliases, chosen via the host picker (the
1269    /// `DefaultHosts` field). Seeded from the snippet's saved targets on edit;
1270    /// persisted on submit. Empty means no default hosts.
1271    pub default_hosts: Vec<String>,
1272    pub focused_field: SnippetFormField,
1273    pub cursor_pos: usize,
1274}
1275
1276impl SnippetForm {
1277    pub(crate) fn new() -> Self {
1278        Self {
1279            name: String::new(),
1280            command: String::new(),
1281            description: String::new(),
1282            default_hosts: Vec::new(),
1283            focused_field: SnippetFormField::Name,
1284            cursor_pos: 0,
1285        }
1286    }
1287
1288    pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
1289        Self {
1290            name: snippet.name.clone(),
1291            command: snippet.command.clone(),
1292            description: snippet.description.clone(),
1293            // The caller seeds default_hosts from the store's saved targets;
1294            // a Snippet carries no host association of its own.
1295            default_hosts: Vec::new(),
1296            focused_field: SnippetFormField::Name,
1297            cursor_pos: snippet.name.chars().count(),
1298        }
1299    }
1300
1301    /// Advance the focused field to the next one and sync the cursor to
1302    /// the end of the new focused value.
1303    pub fn focus_next(&mut self) {
1304        self.focused_field = self.focused_field.next();
1305        self.sync_cursor_to_end();
1306    }
1307
1308    /// Retreat the focused field to the previous one and sync the cursor
1309    /// to the end of the new focused value.
1310    pub fn focus_prev(&mut self) {
1311        self.focused_field = self.focused_field.prev();
1312        self.sync_cursor_to_end();
1313    }
1314
1315    pub fn focused_value(&self) -> &str {
1316        match self.focused_field {
1317            SnippetFormField::Name => &self.name,
1318            SnippetFormField::Command => &self.command,
1319            SnippetFormField::Description => &self.description,
1320            // Not a text field; no editable value.
1321            SnippetFormField::DefaultHosts => "",
1322        }
1323    }
1324
1325    /// Mutable text value of the focused field, or `None` for the non-text
1326    /// `DefaultHosts` field (so keystroke handlers no-op there).
1327    pub fn focused_value_mut(&mut self) -> Option<&mut String> {
1328        match self.focused_field {
1329            SnippetFormField::Name => Some(&mut self.name),
1330            SnippetFormField::Command => Some(&mut self.command),
1331            SnippetFormField::Description => Some(&mut self.description),
1332            SnippetFormField::DefaultHosts => None,
1333        }
1334    }
1335
1336    pub fn insert_char(&mut self, c: char) {
1337        let pos = self.cursor_pos;
1338        let Some(val) = self.focused_value_mut() else {
1339            return;
1340        };
1341        let byte_pos = char_to_byte_pos(val, pos);
1342        val.insert(byte_pos, c);
1343        self.cursor_pos = pos + 1;
1344    }
1345
1346    pub fn delete_char_before_cursor(&mut self) {
1347        if self.cursor_pos == 0 {
1348            return;
1349        }
1350        let pos = self.cursor_pos;
1351        let Some(val) = self.focused_value_mut() else {
1352            return;
1353        };
1354        let byte_pos = char_to_byte_pos(val, pos);
1355        let prev = char_to_byte_pos(val, pos - 1);
1356        val.drain(prev..byte_pos);
1357        self.cursor_pos = pos - 1;
1358    }
1359
1360    pub fn sync_cursor_to_end(&mut self) {
1361        self.cursor_pos = self.focused_value().chars().count();
1362    }
1363
1364    pub fn validate(&self) -> Result<(), String> {
1365        crate::snippet::validate_name(&self.name)?;
1366        crate::snippet::validate_command(&self.command)?;
1367        if self.description.contains(|c: char| c.is_control()) {
1368            return Err(crate::messages::SNIPPET_DESCRIPTION_CONTROL_CHARS.to_string());
1369        }
1370        Ok(())
1371    }
1372}
1373
1374/// Output from snippet execution, per host.
1375#[derive(Debug, Clone)]
1376pub struct SnippetHostOutput {
1377    pub alias: String,
1378    pub stdout: String,
1379    pub stderr: String,
1380    pub exit_code: Option<i32>,
1381}
1382
1383/// State for the snippet output screen.
1384#[derive(Debug, Clone)]
1385pub struct SnippetOutputState {
1386    pub run_id: u64,
1387    pub results: Vec<SnippetHostOutput>,
1388    pub scroll_offset: usize,
1389    pub completed: usize,
1390    pub total: usize,
1391    pub all_done: bool,
1392    pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
1393}
1394
1395/// Form state for snippet parameter input.
1396#[derive(Debug, Clone)]
1397pub struct SnippetParamFormState {
1398    pub params: Vec<crate::snippet::SnippetParam>,
1399    pub values: Vec<String>,
1400    pub focused_index: usize,
1401    pub cursor_pos: usize,
1402    pub scroll_offset: usize,
1403    /// How many params actually fit on screen (set by renderer).
1404    pub visible_count: usize,
1405}
1406
1407impl SnippetParamFormState {
1408    pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
1409        let values: Vec<String> = params
1410            .iter()
1411            .map(|p| p.default.clone().unwrap_or_default())
1412            .collect();
1413        let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
1414        Self {
1415            params: params.to_vec(),
1416            values,
1417            focused_index: 0,
1418            cursor_pos,
1419            scroll_offset: 0,
1420            visible_count: params.len().min(8),
1421        }
1422    }
1423
1424    pub fn insert_char(&mut self, c: char) {
1425        let idx = self.focused_index;
1426        let pos = self.cursor_pos;
1427        let Some(val) = self.values.get_mut(idx) else {
1428            return;
1429        };
1430        let byte_pos = char_to_byte_pos(val, pos);
1431        val.insert(byte_pos, c);
1432        self.cursor_pos = pos + 1;
1433    }
1434
1435    pub fn delete_char_before_cursor(&mut self) {
1436        if self.cursor_pos == 0 {
1437            return;
1438        }
1439        let idx = self.focused_index;
1440        let pos = self.cursor_pos;
1441        let Some(val) = self.values.get_mut(idx) else {
1442            return;
1443        };
1444        let byte_pos = char_to_byte_pos(val, pos);
1445        let prev = char_to_byte_pos(val, pos - 1);
1446        val.drain(prev..byte_pos);
1447        self.cursor_pos = pos - 1;
1448    }
1449
1450    /// Build a map of param name to user-entered value for substitution.
1451    pub fn values_map(&self) -> HashMap<String, String> {
1452        self.params
1453            .iter()
1454            .enumerate()
1455            .map(|(i, p)| (p.name.clone(), self.values[i].clone()))
1456            .collect()
1457    }
1458
1459    /// Returns true if any parameter value differs from its default.
1460    pub fn is_dirty(&self) -> bool {
1461        self.params.iter().enumerate().any(|(i, p)| {
1462            let default = p.default.as_deref().unwrap_or("");
1463            self.values[i] != default
1464        })
1465    }
1466}
1467
1468#[cfg(test)]
1469mod host_form_method_tests {
1470    use super::*;
1471    use crate::quick_add::ParsedTarget;
1472
1473    #[test]
1474    fn apply_password_source_sets_value_and_keeps_focus_when_refocus_false() {
1475        let mut f = HostForm::new();
1476        f.focused_field = FormField::Alias;
1477        f.apply_password_source("vault:secret/ssh#pw".to_string(), false);
1478        assert_eq!(f.askpass, "vault:secret/ssh#pw");
1479        assert_eq!(f.focused_field, FormField::Alias);
1480        assert_eq!(f.cursor_pos, "vault:secret/ssh#pw".chars().count());
1481    }
1482
1483    #[test]
1484    fn apply_password_source_moves_focus_to_askpass_when_refocus_true() {
1485        let mut f = HostForm::new();
1486        f.focused_field = FormField::Hostname;
1487        f.apply_password_source("op://Vault/Item/pw".to_string(), true);
1488        assert_eq!(f.askpass, "op://Vault/Item/pw");
1489        assert_eq!(f.focused_field, FormField::AskPass);
1490        assert_eq!(f.cursor_pos, "op://Vault/Item/pw".chars().count());
1491    }
1492
1493    #[test]
1494    fn apply_password_source_clears_askpass_with_empty_value() {
1495        let mut f = HostForm::new();
1496        f.askpass = "old".into();
1497        f.focused_field = FormField::AskPass;
1498        f.apply_password_source(String::new(), false);
1499        assert_eq!(f.askpass, "");
1500        assert_eq!(f.cursor_pos, 0);
1501    }
1502
1503    #[test]
1504    fn apply_smart_paste_fills_empty_fields_and_overwrites_alias() {
1505        let mut f = HostForm::new();
1506        f.alias = "user@host:2222".into();
1507        let parsed = ParsedTarget {
1508            user: "alice".into(),
1509            hostname: "db.example.com".into(),
1510            port: 2222,
1511        };
1512        f.apply_smart_paste(parsed, "db".into());
1513        assert_eq!(f.alias, "db");
1514        assert_eq!(f.hostname, "db.example.com");
1515        assert_eq!(f.user, "alice");
1516        assert_eq!(f.port, "2222");
1517    }
1518
1519    #[test]
1520    fn apply_smart_paste_does_not_overwrite_non_empty_hostname() {
1521        let mut f = HostForm::new();
1522        f.hostname = "existing.com".into();
1523        let parsed = ParsedTarget {
1524            user: "u".into(),
1525            hostname: "parsed.com".into(),
1526            port: 22,
1527        };
1528        f.apply_smart_paste(parsed, "x".into());
1529        assert_eq!(f.hostname, "existing.com");
1530    }
1531
1532    #[test]
1533    fn apply_smart_paste_does_not_overwrite_non_empty_user() {
1534        let mut f = HostForm::new();
1535        f.user = "bob".into();
1536        let parsed = ParsedTarget {
1537            user: "alice".into(),
1538            hostname: "host".into(),
1539            port: 22,
1540        };
1541        f.apply_smart_paste(parsed, "x".into());
1542        assert_eq!(f.user, "bob");
1543    }
1544
1545    #[test]
1546    fn apply_smart_paste_keeps_default_port_when_parsed_port_is_default() {
1547        let mut f = HostForm::new();
1548        let parsed = ParsedTarget {
1549            user: "u".into(),
1550            hostname: "h".into(),
1551            port: 22,
1552        };
1553        f.apply_smart_paste(parsed, "x".into());
1554        assert_eq!(f.port, "22");
1555    }
1556
1557    #[test]
1558    fn apply_smart_paste_does_not_overwrite_user_when_parsed_user_is_empty() {
1559        let mut f = HostForm::new();
1560        let parsed = ParsedTarget {
1561            user: String::new(),
1562            hostname: "h".into(),
1563            port: 22,
1564        };
1565        f.apply_smart_paste(parsed, "x".into());
1566        assert_eq!(f.user, "");
1567    }
1568
1569    #[test]
1570    fn apply_smart_paste_keeps_user_custom_port_even_when_parsed_port_differs() {
1571        let mut f = HostForm::new();
1572        f.port = "8022".into();
1573        let parsed = ParsedTarget {
1574            user: "u".into(),
1575            hostname: "h".into(),
1576            port: 2222,
1577        };
1578        f.apply_smart_paste(parsed, "x".into());
1579        assert_eq!(f.port, "8022");
1580    }
1581
1582    #[test]
1583    fn apply_password_source_empty_value_with_refocus_moves_focus_and_zeros_cursor() {
1584        let mut f = HostForm::new();
1585        f.focused_field = FormField::Hostname;
1586        f.apply_password_source(String::new(), true);
1587        assert_eq!(f.askpass, "");
1588        assert_eq!(f.focused_field, FormField::AskPass);
1589        assert_eq!(f.cursor_pos, 0);
1590    }
1591
1592    #[test]
1593    fn tunnel_form_focus_next_from_type_goes_to_bind_port_and_resets_cursor() {
1594        let mut f = TunnelForm::new();
1595        f.bind_port = "8080".into();
1596        f.cursor_pos = 99;
1597        assert_eq!(f.focused_field, TunnelFormField::Type);
1598        f.focus_next();
1599        assert_eq!(f.focused_field, TunnelFormField::BindPort);
1600        // sync_cursor_to_end moves to end of focused value (chars count).
1601        assert_eq!(f.cursor_pos, 4);
1602    }
1603
1604    #[test]
1605    fn tunnel_form_focus_next_wraps_from_remote_port_back_to_type() {
1606        let mut f = TunnelForm::new();
1607        f.focused_field = TunnelFormField::RemotePort;
1608        // Non-empty source field so a wrong-ordering regression
1609        // (sync_cursor_to_end before the field assignment) would
1610        // produce cursor_pos = 3 instead of 0.
1611        f.remote_port = "443".into();
1612        f.cursor_pos = 99;
1613        f.focus_next();
1614        assert_eq!(f.focused_field, TunnelFormField::Type);
1615        assert_eq!(f.cursor_pos, 0);
1616    }
1617
1618    #[test]
1619    fn tunnel_form_focus_next_dynamic_skips_remote_fields() {
1620        let mut f = TunnelForm::new();
1621        f.tunnel_type = TunnelType::Dynamic;
1622        f.focused_field = TunnelFormField::BindPort;
1623        // Non-empty source field so a wrong-ordering regression would
1624        // yield cursor_pos = 4, distinguishing it from the correct 0.
1625        f.bind_port = "8080".into();
1626        f.cursor_pos = 99;
1627        f.focus_next();
1628        // Dynamic skips RemoteHost + RemotePort.
1629        assert_eq!(f.focused_field, TunnelFormField::Type);
1630        assert_eq!(f.cursor_pos, 0);
1631    }
1632
1633    #[test]
1634    fn tunnel_form_focus_prev_from_bind_port_goes_to_type() {
1635        let mut f = TunnelForm::new();
1636        f.focused_field = TunnelFormField::BindPort;
1637        // Non-empty source field so a wrong-ordering regression would
1638        // yield cursor_pos = 4, distinguishing it from the correct 0.
1639        f.bind_port = "8080".into();
1640        f.cursor_pos = 99;
1641        f.focus_prev();
1642        assert_eq!(f.focused_field, TunnelFormField::Type);
1643        assert_eq!(f.cursor_pos, 0);
1644    }
1645
1646    #[test]
1647    fn tunnel_form_focus_next_preserves_field_values_and_tunnel_type() {
1648        let mut f = TunnelForm {
1649            tunnel_type: TunnelType::Local,
1650            bind_port: "1234".into(),
1651            remote_host: "example.com".into(),
1652            remote_port: "5678".into(),
1653            bind_address: "127.0.0.1".into(),
1654            focused_field: TunnelFormField::Type,
1655            cursor_pos: 0,
1656        };
1657        f.focus_next();
1658        assert_eq!(f.bind_port, "1234");
1659        assert_eq!(f.remote_host, "example.com");
1660        assert_eq!(f.remote_port, "5678");
1661        assert_eq!(f.bind_address, "127.0.0.1");
1662        assert_eq!(f.tunnel_type, TunnelType::Local);
1663    }
1664
1665    #[test]
1666    fn snippet_form_focus_next_advances_field_and_syncs_cursor_to_target() {
1667        let mut f = SnippetForm::new();
1668        // Distinct lengths so a wrong-ordering regression (sync before
1669        // assignment) would land cursor at 3 instead of the correct 2.
1670        f.name = "abc".into();
1671        f.command = "de".into();
1672        f.focused_field = SnippetFormField::Name;
1673        f.cursor_pos = 99;
1674        f.focus_next();
1675        assert_eq!(f.focused_field, SnippetFormField::Command);
1676        assert_eq!(f.cursor_pos, 2);
1677    }
1678
1679    #[test]
1680    fn snippet_form_focus_prev_retreats_field_and_syncs_cursor_to_target() {
1681        let mut f = SnippetForm::new();
1682        // Source and target have distinct lengths: wrong ordering would
1683        // yield cursor_pos = 2 (end of source command), correct is 3.
1684        f.name = "xyz".into();
1685        f.command = "ab".into();
1686        f.focused_field = SnippetFormField::Command;
1687        f.cursor_pos = 99;
1688        f.focus_prev();
1689        assert_eq!(f.focused_field, SnippetFormField::Name);
1690        assert_eq!(f.cursor_pos, 3);
1691    }
1692
1693    #[test]
1694    fn snippet_form_focus_next_preserves_field_values() {
1695        let mut f = SnippetForm::new();
1696        f.name = "foo".into();
1697        f.command = "bar".into();
1698        f.description = "baz".into();
1699        f.focus_next();
1700        assert_eq!(f.name, "foo");
1701        assert_eq!(f.command, "bar");
1702        assert_eq!(f.description, "baz");
1703    }
1704}