Skip to main content

purple_ssh/app/
forms.rs

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