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