Skip to main content

purple_ssh/ssh_config/
model.rs

1use std::path::PathBuf;
2
3/// Display name for a provider used in `# purple:group` headers.
4/// Mirrors `providers::provider_display_name()` without a cross-module dependency.
5fn provider_group_display_name(name: &str) -> &str {
6    match name {
7        "digitalocean" => "DigitalOcean",
8        "vultr" => "Vultr",
9        "linode" => "Linode",
10        "hetzner" => "Hetzner",
11        "upcloud" => "UpCloud",
12        "proxmox" => "Proxmox VE",
13        "aws" => "AWS EC2",
14        "scaleway" => "Scaleway",
15        "gcp" => "GCP",
16        "azure" => "Azure",
17        "tailscale" => "Tailscale",
18        "oracle" => "Oracle Cloud",
19        other => other,
20    }
21}
22
23/// Represents the entire SSH config file as a sequence of elements.
24/// Preserves the original structure for round-trip fidelity.
25#[derive(Debug, Clone)]
26pub struct SshConfigFile {
27    pub elements: Vec<ConfigElement>,
28    pub path: PathBuf,
29    /// Whether the original file used CRLF line endings.
30    pub crlf: bool,
31    /// Whether the original file started with a UTF-8 BOM.
32    pub bom: bool,
33}
34
35/// An Include directive that references other config files.
36#[derive(Debug, Clone)]
37pub struct IncludeDirective {
38    pub raw_line: String,
39    pub pattern: String,
40    pub resolved_files: Vec<IncludedFile>,
41}
42
43/// A file resolved from an Include directive.
44#[derive(Debug, Clone)]
45pub struct IncludedFile {
46    pub path: PathBuf,
47    pub elements: Vec<ConfigElement>,
48}
49
50/// A single element in the config file.
51#[derive(Debug, Clone)]
52pub enum ConfigElement {
53    /// A Host block: the `Host <pattern>` line plus all indented directives.
54    HostBlock(HostBlock),
55    /// A comment, blank line, or global directive not inside a Host block.
56    GlobalLine(String),
57    /// An Include directive referencing other config files (read-only).
58    Include(IncludeDirective),
59}
60
61/// A parsed Host block with its directives.
62#[derive(Debug, Clone)]
63pub struct HostBlock {
64    /// The host alias/pattern (the value after "Host").
65    pub host_pattern: String,
66    /// The original raw "Host ..." line for faithful reproduction.
67    pub raw_host_line: String,
68    /// Parsed directives inside this block.
69    pub directives: Vec<Directive>,
70}
71
72/// A directive line inside a Host block.
73#[derive(Debug, Clone)]
74pub struct Directive {
75    /// The directive key (e.g., "HostName", "User", "Port").
76    pub key: String,
77    /// The directive value.
78    pub value: String,
79    /// The original raw line (preserves indentation, inline comments).
80    pub raw_line: String,
81    /// Whether this is a comment-only or blank line inside a host block.
82    pub is_non_directive: bool,
83}
84
85/// Convenience view for the TUI — extracted from a HostBlock.
86#[derive(Debug, Clone)]
87pub struct HostEntry {
88    pub alias: String,
89    pub hostname: String,
90    pub user: String,
91    pub port: u16,
92    pub identity_file: String,
93    pub proxy_jump: String,
94    /// If this host comes from an included file, the file path.
95    pub source_file: Option<PathBuf>,
96    /// User-added tags from purple:tags comment.
97    pub tags: Vec<String>,
98    /// Provider-synced tags from purple:provider_tags comment.
99    pub provider_tags: Vec<String>,
100    /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
101    pub has_provider_tags: bool,
102    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
103    pub provider: Option<String>,
104    /// Number of tunnel forwarding directives.
105    pub tunnel_count: u16,
106    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
107    pub askpass: Option<String>,
108    /// Vault SSH certificate signing role from purple:vault-ssh comment.
109    pub vault_ssh: Option<String>,
110    /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
111    /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
112    /// this host's signing, overriding the parent shell. Empty = inherit env.
113    pub vault_addr: Option<String>,
114    /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
115    pub certificate_file: String,
116    /// Provider metadata from purple:meta comment (region, plan, etc.).
117    pub provider_meta: Vec<(String, String)>,
118    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
119    pub stale: Option<u64>,
120}
121
122impl Default for HostEntry {
123    fn default() -> Self {
124        Self {
125            alias: String::new(),
126            hostname: String::new(),
127            user: String::new(),
128            port: 22,
129            identity_file: String::new(),
130            proxy_jump: String::new(),
131            source_file: None,
132            tags: Vec::new(),
133            provider_tags: Vec::new(),
134            has_provider_tags: false,
135            provider: None,
136            tunnel_count: 0,
137            askpass: None,
138            vault_ssh: None,
139            vault_addr: None,
140            certificate_file: String::new(),
141            provider_meta: Vec::new(),
142            stale: None,
143        }
144    }
145}
146
147impl HostEntry {
148    /// Build the SSH command string for this host.
149    /// Includes `-F <config_path>` when the config is non-default so the alias
150    /// resolves correctly when pasted into a terminal.
151    /// Shell-quotes both the config path and alias to prevent injection.
152    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
153        let escaped = self.alias.replace('\'', "'\\''");
154        let default = dirs::home_dir()
155            .map(|h| h.join(".ssh/config"))
156            .unwrap_or_default();
157        if config_path == default {
158            format!("ssh -- '{}'", escaped)
159        } else {
160            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
161            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
162        }
163    }
164}
165
166/// Convenience view for pattern Host blocks in the TUI.
167#[derive(Debug, Clone, Default)]
168pub struct PatternEntry {
169    pub pattern: String,
170    pub hostname: String,
171    pub user: String,
172    pub port: u16,
173    pub identity_file: String,
174    pub proxy_jump: String,
175    pub tags: Vec<String>,
176    pub askpass: Option<String>,
177    pub source_file: Option<PathBuf>,
178    /// All non-comment directives as key-value pairs for display.
179    pub directives: Vec<(String, String)>,
180}
181
182/// Inherited field hints from matching patterns. Each field is `Some((value,
183/// source_pattern))` when a pattern provides that directive, `None` otherwise.
184#[derive(Debug, Clone, Default)]
185pub struct InheritedHints {
186    pub proxy_jump: Option<(String, String)>,
187    pub user: Option<(String, String)>,
188    pub identity_file: Option<(String, String)>,
189}
190
191/// Returns true if the host pattern contains wildcards, character classes,
192/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
193/// These are SSH match patterns, not concrete hosts.
194pub fn is_host_pattern(pattern: &str) -> bool {
195    pattern.contains('*')
196        || pattern.contains('?')
197        || pattern.contains('[')
198        || pattern.starts_with('!')
199        || pattern.contains(' ')
200        || pattern.contains('\t')
201}
202
203/// Match a text string against an SSH host pattern.
204/// Supports `*` (any sequence), `?` (single char), `[charset]` (character class),
205/// `[!charset]`/`[^charset]` (negated class), `[a-z]` (ranges) and `!pattern` (negation).
206pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
207    if let Some(rest) = pattern.strip_prefix('!') {
208        return !match_glob(rest, text);
209    }
210    match_glob(pattern, text)
211}
212
213/// Core glob matcher without negation prefix handling.
214/// Empty text only matches empty pattern.
215fn match_glob(pattern: &str, text: &str) -> bool {
216    if text.is_empty() {
217        return pattern.is_empty();
218    }
219    if pattern.is_empty() {
220        return false;
221    }
222    let pat: Vec<char> = pattern.chars().collect();
223    let txt: Vec<char> = text.chars().collect();
224    glob_match(&pat, &txt)
225}
226
227/// Iterative glob matching with star-backtracking.
228fn glob_match(pat: &[char], txt: &[char]) -> bool {
229    let mut pi = 0;
230    let mut ti = 0;
231    let mut star: Option<(usize, usize)> = None; // (pattern_pos, text_pos)
232
233    while ti < txt.len() {
234        if pi < pat.len() && pat[pi] == '?' {
235            pi += 1;
236            ti += 1;
237        } else if pi < pat.len() && pat[pi] == '*' {
238            star = Some((pi + 1, ti));
239            pi += 1;
240        } else if pi < pat.len() && pat[pi] == '[' {
241            if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
242                if matches {
243                    pi = end;
244                    ti += 1;
245                } else if let Some((spi, sti)) = star {
246                    let sti = sti + 1;
247                    star = Some((spi, sti));
248                    pi = spi;
249                    ti = sti;
250                } else {
251                    return false;
252                }
253            } else if let Some((spi, sti)) = star {
254                // Malformed class: backtrack
255                let sti = sti + 1;
256                star = Some((spi, sti));
257                pi = spi;
258                ti = sti;
259            } else {
260                return false;
261            }
262        } else if pi < pat.len() && pat[pi] == txt[ti] {
263            pi += 1;
264            ti += 1;
265        } else if let Some((spi, sti)) = star {
266            let sti = sti + 1;
267            star = Some((spi, sti));
268            pi = spi;
269            ti = sti;
270        } else {
271            return false;
272        }
273    }
274
275    while pi < pat.len() && pat[pi] == '*' {
276        pi += 1;
277    }
278    pi == pat.len()
279}
280
281/// Parse and match a `[...]` character class starting at `pat[start]`.
282/// Returns `Some((matched, end_index))` where `end_index` is past `]`.
283/// Returns `None` if no closing `]` is found.
284fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
285    let mut i = start + 1;
286    if i >= pat.len() {
287        return None;
288    }
289
290    let negate = pat[i] == '!' || pat[i] == '^';
291    if negate {
292        i += 1;
293    }
294
295    let mut matched = false;
296    while i < pat.len() && pat[i] != ']' {
297        if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
298            let lo = pat[i];
299            let hi = pat[i + 2];
300            if ch >= lo && ch <= hi {
301                matched = true;
302            }
303            i += 3;
304        } else {
305            matched |= pat[i] == ch;
306            i += 1;
307        }
308    }
309
310    if i >= pat.len() {
311        return None;
312    }
313
314    let result = if negate { !matched } else { matched };
315    Some((result, i + 1))
316}
317
318/// Check whether a `Host` pattern matches a given alias.
319/// OpenSSH `Host` keyword matches only against the target alias typed on the
320/// command line, never against the resolved HostName.
321pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
322    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
323    if patterns.is_empty() {
324        return false;
325    }
326
327    let mut any_positive_match = false;
328    for pat in &patterns {
329        if let Some(neg) = pat.strip_prefix('!') {
330            if match_glob(neg, alias) {
331                return false;
332            }
333        } else if ssh_pattern_match(pat, alias) {
334            any_positive_match = true;
335        }
336    }
337
338    any_positive_match
339}
340
341/// Returns true if any hop in a (possibly comma-separated) ProxyJump value
342/// matches the given alias. Strips optional `user@` prefix and `:port`
343/// suffix from each hop before comparing. Handles IPv6 bracket notation
344/// `[addr]:port`. Used to detect self-referencing loops.
345pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
346    proxy_jump.split(',').any(|hop| {
347        let h = hop.trim();
348        // Strip optional user@ prefix (take everything after the first @).
349        let h = h.split_once('@').map_or(h, |(_, host)| host);
350        // Strip optional :port suffix. Handle [IPv6]:port bracket notation.
351        let h = if let Some(bracketed) = h.strip_prefix('[') {
352            bracketed.split_once(']').map_or(h, |(host, _)| host)
353        } else {
354            h.rsplit_once(':').map_or(h, |(host, _)| host)
355        };
356        h == alias
357    })
358}
359
360/// Apply first-match-wins inheritance from a pattern to mutable field refs.
361/// Only fills fields that are still empty. Self-referencing ProxyJump values
362/// are assigned (SSH would do the same) so the UI can warn about the loop.
363fn apply_first_match_fields(
364    proxy_jump: &mut String,
365    user: &mut String,
366    identity_file: &mut String,
367    p: &PatternEntry,
368) {
369    if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
370        proxy_jump.clone_from(&p.proxy_jump);
371    }
372    if user.is_empty() && !p.user.is_empty() {
373        user.clone_from(&p.user);
374    }
375    if identity_file.is_empty() && !p.identity_file.is_empty() {
376        identity_file.clone_from(&p.identity_file);
377    }
378}
379
380impl HostBlock {
381    /// Index of the first trailing blank line (for inserting content before separators).
382    fn content_end(&self) -> usize {
383        let mut pos = self.directives.len();
384        while pos > 0 {
385            if self.directives[pos - 1].is_non_directive
386                && self.directives[pos - 1].raw_line.trim().is_empty()
387            {
388                pos -= 1;
389            } else {
390                break;
391            }
392        }
393        pos
394    }
395
396    /// Remove and return trailing blank lines.
397    #[allow(dead_code)]
398    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
399        let end = self.content_end();
400        self.directives.drain(end..).collect()
401    }
402
403    /// Ensure exactly one trailing blank line.
404    #[allow(dead_code)]
405    fn ensure_trailing_blank(&mut self) {
406        self.pop_trailing_blanks();
407        self.directives.push(Directive {
408            key: String::new(),
409            value: String::new(),
410            raw_line: String::new(),
411            is_non_directive: true,
412        });
413    }
414
415    /// Detect indentation used by existing directives (falls back to "  ").
416    fn detect_indent(&self) -> String {
417        for d in &self.directives {
418            if !d.is_non_directive && !d.raw_line.is_empty() {
419                let trimmed = d.raw_line.trim_start();
420                let indent_len = d.raw_line.len() - trimmed.len();
421                if indent_len > 0 {
422                    return d.raw_line[..indent_len].to_string();
423                }
424            }
425        }
426        "  ".to_string()
427    }
428
429    /// Extract tags from purple:tags comment in directives.
430    pub fn tags(&self) -> Vec<String> {
431        for d in &self.directives {
432            if d.is_non_directive {
433                let trimmed = d.raw_line.trim();
434                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
435                    return rest
436                        .split(',')
437                        .map(|t| t.trim().to_string())
438                        .filter(|t| !t.is_empty())
439                        .collect();
440                }
441            }
442        }
443        Vec::new()
444    }
445
446    /// Extract provider-synced tags from purple:provider_tags comment.
447    pub fn provider_tags(&self) -> Vec<String> {
448        for d in &self.directives {
449            if d.is_non_directive {
450                let trimmed = d.raw_line.trim();
451                if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
452                    return rest
453                        .split(',')
454                        .map(|t| t.trim().to_string())
455                        .filter(|t| !t.is_empty())
456                        .collect();
457                }
458            }
459        }
460        Vec::new()
461    }
462
463    /// Check if a purple:provider_tags comment exists (even if empty).
464    /// Used to distinguish "never migrated" from "migrated with no tags".
465    pub fn has_provider_tags_comment(&self) -> bool {
466        self.directives.iter().any(|d| {
467            d.is_non_directive && {
468                let t = d.raw_line.trim();
469                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
470            }
471        })
472    }
473
474    /// Extract provider info from purple:provider comment in directives.
475    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
476    pub fn provider(&self) -> Option<(String, String)> {
477        for d in &self.directives {
478            if d.is_non_directive {
479                let trimmed = d.raw_line.trim();
480                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
481                    if let Some((name, id)) = rest.split_once(':') {
482                        return Some((name.trim().to_string(), id.trim().to_string()));
483                    }
484                }
485            }
486        }
487        None
488    }
489
490    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
491    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
492        let indent = self.detect_indent();
493        self.directives.retain(|d| {
494            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
495        });
496        let pos = self.content_end();
497        self.directives.insert(
498            pos,
499            Directive {
500                key: String::new(),
501                value: String::new(),
502                raw_line: format!(
503                    "{}# purple:provider {}:{}",
504                    indent, provider_name, server_id
505                ),
506                is_non_directive: true,
507            },
508        );
509    }
510
511    /// Extract askpass source from purple:askpass comment in directives.
512    pub fn askpass(&self) -> Option<String> {
513        for d in &self.directives {
514            if d.is_non_directive {
515                let trimmed = d.raw_line.trim();
516                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
517                    let val = rest.trim();
518                    if !val.is_empty() {
519                        return Some(val.to_string());
520                    }
521                }
522            }
523        }
524        None
525    }
526
527    /// Extract vault-ssh role from purple:vault-ssh comment.
528    pub fn vault_ssh(&self) -> Option<String> {
529        for d in &self.directives {
530            if d.is_non_directive {
531                let trimmed = d.raw_line.trim();
532                if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
533                    let val = rest.trim();
534                    if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
535                        return Some(val.to_string());
536                    }
537                }
538            }
539        }
540        None
541    }
542
543    /// Set vault-ssh role. Replaces existing comment or adds one. Empty string removes.
544    pub fn set_vault_ssh(&mut self, role: &str) {
545        let indent = self.detect_indent();
546        self.directives.retain(|d| {
547            !(d.is_non_directive && {
548                let t = d.raw_line.trim();
549                t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
550            })
551        });
552        if !role.is_empty() {
553            let pos = self.content_end();
554            self.directives.insert(
555                pos,
556                Directive {
557                    key: String::new(),
558                    value: String::new(),
559                    raw_line: format!("{}# purple:vault-ssh {}", indent, role),
560                    is_non_directive: true,
561                },
562            );
563        }
564    }
565
566    /// Extract the Vault SSH endpoint from a `# purple:vault-addr` comment.
567    /// Returns None when the comment is absent, blank or contains an invalid
568    /// URL value. Validation is intentionally minimal: we reject empty,
569    /// whitespace-containing and control-character values but otherwise let
570    /// the Vault CLI surface its own error on typos.
571    pub fn vault_addr(&self) -> Option<String> {
572        for d in &self.directives {
573            if d.is_non_directive {
574                let trimmed = d.raw_line.trim();
575                if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
576                    let val = rest.trim();
577                    if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
578                        return Some(val.to_string());
579                    }
580                }
581            }
582        }
583        None
584    }
585
586    /// Set vault-addr endpoint. Replaces existing comment or adds one. Empty
587    /// string removes. Caller is expected to have validated the URL upstream
588    /// (e.g. via `is_valid_vault_addr`) — this function does not re-validate.
589    pub fn set_vault_addr(&mut self, url: &str) {
590        let indent = self.detect_indent();
591        self.directives.retain(|d| {
592            !(d.is_non_directive && {
593                let t = d.raw_line.trim();
594                t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
595            })
596        });
597        if !url.is_empty() {
598            let pos = self.content_end();
599            self.directives.insert(
600                pos,
601                Directive {
602                    key: String::new(),
603                    value: String::new(),
604                    raw_line: format!("{}# purple:vault-addr {}", indent, url),
605                    is_non_directive: true,
606                },
607            );
608        }
609    }
610
611    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
612    /// Pass an empty string to remove the comment.
613    pub fn set_askpass(&mut self, source: &str) {
614        let indent = self.detect_indent();
615        self.directives.retain(|d| {
616            !(d.is_non_directive && {
617                let t = d.raw_line.trim();
618                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
619            })
620        });
621        if !source.is_empty() {
622            let pos = self.content_end();
623            self.directives.insert(
624                pos,
625                Directive {
626                    key: String::new(),
627                    value: String::new(),
628                    raw_line: format!("{}# purple:askpass {}", indent, source),
629                    is_non_directive: true,
630                },
631            );
632        }
633    }
634
635    /// Extract provider metadata from purple:meta comment in directives.
636    /// Format: `# purple:meta key=value,key=value`
637    pub fn meta(&self) -> Vec<(String, String)> {
638        for d in &self.directives {
639            if d.is_non_directive {
640                let trimmed = d.raw_line.trim();
641                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
642                    return rest
643                        .split(',')
644                        .filter_map(|pair| {
645                            let (k, v) = pair.split_once('=')?;
646                            let k = k.trim();
647                            let v = v.trim();
648                            if k.is_empty() {
649                                None
650                            } else {
651                                Some((k.to_string(), v.to_string()))
652                            }
653                        })
654                        .collect();
655                }
656            }
657        }
658        Vec::new()
659    }
660
661    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
662    /// Pass an empty slice to remove the comment.
663    pub fn set_meta(&mut self, meta: &[(String, String)]) {
664        let indent = self.detect_indent();
665        self.directives.retain(|d| {
666            !(d.is_non_directive && {
667                let t = d.raw_line.trim();
668                t == "# purple:meta" || t.starts_with("# purple:meta ")
669            })
670        });
671        if !meta.is_empty() {
672            let encoded: Vec<String> = meta
673                .iter()
674                .map(|(k, v)| {
675                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
676                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
677                    format!("{}={}", clean_k, clean_v)
678                })
679                .collect();
680            let pos = self.content_end();
681            self.directives.insert(
682                pos,
683                Directive {
684                    key: String::new(),
685                    value: String::new(),
686                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
687                    is_non_directive: true,
688                },
689            );
690        }
691    }
692
693    /// Extract stale timestamp from purple:stale comment in directives.
694    /// Returns `None` if absent or malformed.
695    pub fn stale(&self) -> Option<u64> {
696        for d in &self.directives {
697            if d.is_non_directive {
698                let trimmed = d.raw_line.trim();
699                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
700                    return rest.trim().parse::<u64>().ok();
701                }
702            }
703        }
704        None
705    }
706
707    /// Mark a host block as stale with a unix timestamp.
708    /// Replaces existing purple:stale comment or adds one.
709    pub fn set_stale(&mut self, timestamp: u64) {
710        let indent = self.detect_indent();
711        self.clear_stale();
712        let pos = self.content_end();
713        self.directives.insert(
714            pos,
715            Directive {
716                key: String::new(),
717                value: String::new(),
718                raw_line: format!("{}# purple:stale {}", indent, timestamp),
719                is_non_directive: true,
720            },
721        );
722    }
723
724    /// Remove stale marking from a host block.
725    pub fn clear_stale(&mut self) {
726        self.directives.retain(|d| {
727            !(d.is_non_directive && {
728                let t = d.raw_line.trim();
729                t == "# purple:stale" || t.starts_with("# purple:stale ")
730            })
731        });
732    }
733
734    /// Sanitize a tag value: strip control characters, commas (delimiter),
735    /// and Unicode format/bidi override characters. Truncate to 128 chars.
736    fn sanitize_tag(tag: &str) -> String {
737        tag.chars()
738            .filter(|c| {
739                !c.is_control()
740                    && *c != ','
741                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
742                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
743                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
744                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
745            })
746            .take(128)
747            .collect()
748    }
749
750    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
751    pub fn set_tags(&mut self, tags: &[String]) {
752        let indent = self.detect_indent();
753        self.directives.retain(|d| {
754            !(d.is_non_directive && {
755                let t = d.raw_line.trim();
756                t == "# purple:tags" || t.starts_with("# purple:tags ")
757            })
758        });
759        let sanitized: Vec<String> = tags
760            .iter()
761            .map(|t| Self::sanitize_tag(t))
762            .filter(|t| !t.is_empty())
763            .collect();
764        if !sanitized.is_empty() {
765            let pos = self.content_end();
766            self.directives.insert(
767                pos,
768                Directive {
769                    key: String::new(),
770                    value: String::new(),
771                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
772                    is_non_directive: true,
773                },
774            );
775        }
776    }
777
778    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
779    /// Always writes the comment (even when empty) as a migration sentinel.
780    pub fn set_provider_tags(&mut self, tags: &[String]) {
781        let indent = self.detect_indent();
782        self.directives.retain(|d| {
783            !(d.is_non_directive && {
784                let t = d.raw_line.trim();
785                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
786            })
787        });
788        let sanitized: Vec<String> = tags
789            .iter()
790            .map(|t| Self::sanitize_tag(t))
791            .filter(|t| !t.is_empty())
792            .collect();
793        let raw = if sanitized.is_empty() {
794            format!("{}# purple:provider_tags", indent)
795        } else {
796            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
797        };
798        let pos = self.content_end();
799        self.directives.insert(
800            pos,
801            Directive {
802                key: String::new(),
803                value: String::new(),
804                raw_line: raw,
805                is_non_directive: true,
806            },
807        );
808    }
809
810    /// Extract a convenience HostEntry view from this block.
811    pub fn to_host_entry(&self) -> HostEntry {
812        let mut entry = HostEntry {
813            alias: self.host_pattern.clone(),
814            port: 22,
815            ..Default::default()
816        };
817        for d in &self.directives {
818            if d.is_non_directive {
819                continue;
820            }
821            if d.key.eq_ignore_ascii_case("hostname") {
822                entry.hostname = d.value.clone();
823            } else if d.key.eq_ignore_ascii_case("user") {
824                entry.user = d.value.clone();
825            } else if d.key.eq_ignore_ascii_case("port") {
826                entry.port = d.value.parse().unwrap_or(22);
827            } else if d.key.eq_ignore_ascii_case("identityfile") {
828                if entry.identity_file.is_empty() {
829                    entry.identity_file = d.value.clone();
830                }
831            } else if d.key.eq_ignore_ascii_case("proxyjump") {
832                entry.proxy_jump = d.value.clone();
833            } else if d.key.eq_ignore_ascii_case("certificatefile")
834                && entry.certificate_file.is_empty()
835            {
836                entry.certificate_file = d.value.clone();
837            }
838        }
839        entry.tags = self.tags();
840        entry.provider_tags = self.provider_tags();
841        entry.has_provider_tags = self.has_provider_tags_comment();
842        entry.provider = self.provider().map(|(name, _)| name);
843        entry.tunnel_count = self.tunnel_count();
844        entry.askpass = self.askpass();
845        entry.vault_ssh = self.vault_ssh();
846        entry.vault_addr = self.vault_addr();
847        entry.provider_meta = self.meta();
848        entry.stale = self.stale();
849        entry
850    }
851
852    /// Extract a convenience PatternEntry view from this block.
853    pub fn to_pattern_entry(&self) -> PatternEntry {
854        let mut entry = PatternEntry {
855            pattern: self.host_pattern.clone(),
856            hostname: String::new(),
857            user: String::new(),
858            port: 22,
859            identity_file: String::new(),
860            proxy_jump: String::new(),
861            tags: self.tags(),
862            askpass: self.askpass(),
863            source_file: None,
864            directives: Vec::new(),
865        };
866        for d in &self.directives {
867            if d.is_non_directive {
868                continue;
869            }
870            match d.key.to_ascii_lowercase().as_str() {
871                "hostname" => entry.hostname = d.value.clone(),
872                "user" => entry.user = d.value.clone(),
873                "port" => entry.port = d.value.parse().unwrap_or(22),
874                "identityfile" if entry.identity_file.is_empty() => {
875                    entry.identity_file = d.value.clone();
876                }
877                "proxyjump" => entry.proxy_jump = d.value.clone(),
878                _ => {}
879            }
880            entry.directives.push((d.key.clone(), d.value.clone()));
881        }
882        entry
883    }
884
885    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
886    pub fn tunnel_count(&self) -> u16 {
887        let count = self
888            .directives
889            .iter()
890            .filter(|d| {
891                !d.is_non_directive
892                    && (d.key.eq_ignore_ascii_case("localforward")
893                        || d.key.eq_ignore_ascii_case("remoteforward")
894                        || d.key.eq_ignore_ascii_case("dynamicforward"))
895            })
896            .count();
897        count.min(u16::MAX as usize) as u16
898    }
899
900    /// Check if this block has any tunnel forwarding directives.
901    #[allow(dead_code)]
902    pub fn has_tunnels(&self) -> bool {
903        self.directives.iter().any(|d| {
904            !d.is_non_directive
905                && (d.key.eq_ignore_ascii_case("localforward")
906                    || d.key.eq_ignore_ascii_case("remoteforward")
907                    || d.key.eq_ignore_ascii_case("dynamicforward"))
908        })
909    }
910
911    /// Extract tunnel rules from forwarding directives.
912    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
913        self.directives
914            .iter()
915            .filter(|d| !d.is_non_directive)
916            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
917            .collect()
918    }
919}
920
921impl SshConfigFile {
922    /// Get all host entries as convenience views (including from Include files).
923    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
924    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
925    /// SSH will actually apply when connecting via `ssh <alias>`.
926    pub fn host_entries(&self) -> Vec<HostEntry> {
927        let mut entries = Vec::new();
928        Self::collect_host_entries(&self.elements, &mut entries);
929        self.apply_pattern_inheritance(&mut entries);
930        entries
931    }
932
933    /// Get a single host entry by alias without pattern inheritance applied.
934    /// Returns the raw directives from the host's own block only. Used by the
935    /// edit form so inherited values can be shown as dimmed placeholders.
936    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
937        Self::find_raw_host_entry(&self.elements, alias)
938    }
939
940    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
941        for e in elements {
942            match e {
943                ConfigElement::HostBlock(block)
944                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
945                {
946                    return Some(block.to_host_entry());
947                }
948                ConfigElement::Include(inc) => {
949                    for file in &inc.resolved_files {
950                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
951                            if found.source_file.is_none() {
952                                found.source_file = Some(file.path.clone());
953                            }
954                            return Some(found);
955                        }
956                    }
957                }
958                _ => {}
959            }
960        }
961        None
962    }
963
964    /// Apply SSH first-match-wins pattern inheritance to host entries.
965    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
966    /// match the token typed on the command line, not the resolved `Hostname`).
967    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
968        // Patterns are pre-collected once. Host entries never contain pattern
969        // aliases — collect_host_entries skips is_host_pattern blocks.
970        let all_patterns = self.pattern_entries();
971        for entry in entries.iter_mut() {
972            if !entry.proxy_jump.is_empty()
973                && !entry.user.is_empty()
974                && !entry.identity_file.is_empty()
975            {
976                continue;
977            }
978            for p in &all_patterns {
979                if !host_pattern_matches(&p.pattern, &entry.alias) {
980                    continue;
981                }
982                apply_first_match_fields(
983                    &mut entry.proxy_jump,
984                    &mut entry.user,
985                    &mut entry.identity_file,
986                    p,
987                );
988                if !entry.proxy_jump.is_empty()
989                    && !entry.user.is_empty()
990                    && !entry.identity_file.is_empty()
991                {
992                    break;
993                }
994            }
995        }
996    }
997
998    /// Compute pattern-provided field hints for a host alias. Returns first-match
999    /// values and their source patterns for ProxyJump, User and IdentityFile.
1000    /// These are returned regardless of whether the host has its own values for
1001    /// those fields. The caller (form rendering) decides visibility based on
1002    /// whether the field is empty. Matches by alias only (SSH-faithful).
1003    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1004        let patterns = self.matching_patterns(alias);
1005        let mut hints = InheritedHints::default();
1006        for p in &patterns {
1007            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1008                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1009            }
1010            if hints.user.is_none() && !p.user.is_empty() {
1011                hints.user = Some((p.user.clone(), p.pattern.clone()));
1012            }
1013            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1014                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1015            }
1016            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1017                break;
1018            }
1019        }
1020        hints
1021    }
1022
1023    /// Get all pattern entries as convenience views (including from Include files).
1024    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1025        let mut entries = Vec::new();
1026        Self::collect_pattern_entries(&self.elements, &mut entries);
1027        entries
1028    }
1029
1030    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1031        for e in elements {
1032            match e {
1033                ConfigElement::HostBlock(block) => {
1034                    if !is_host_pattern(&block.host_pattern) {
1035                        continue;
1036                    }
1037                    entries.push(block.to_pattern_entry());
1038                }
1039                ConfigElement::Include(include) => {
1040                    for file in &include.resolved_files {
1041                        let start = entries.len();
1042                        Self::collect_pattern_entries(&file.elements, entries);
1043                        for entry in &mut entries[start..] {
1044                            if entry.source_file.is_none() {
1045                                entry.source_file = Some(file.path.clone());
1046                            }
1047                        }
1048                    }
1049                }
1050                ConfigElement::GlobalLine(_) => {}
1051            }
1052        }
1053    }
1054
1055    /// Find all pattern blocks that match a given host alias and hostname.
1056    /// Returns entries in config order (first match first).
1057    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1058        let mut matches = Vec::new();
1059        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1060        matches
1061    }
1062
1063    fn collect_matching_patterns(
1064        elements: &[ConfigElement],
1065        alias: &str,
1066        matches: &mut Vec<PatternEntry>,
1067    ) {
1068        for e in elements {
1069            match e {
1070                ConfigElement::HostBlock(block) => {
1071                    if !is_host_pattern(&block.host_pattern) {
1072                        continue;
1073                    }
1074                    if host_pattern_matches(&block.host_pattern, alias) {
1075                        matches.push(block.to_pattern_entry());
1076                    }
1077                }
1078                ConfigElement::Include(include) => {
1079                    for file in &include.resolved_files {
1080                        let start = matches.len();
1081                        Self::collect_matching_patterns(&file.elements, alias, matches);
1082                        for entry in &mut matches[start..] {
1083                            if entry.source_file.is_none() {
1084                                entry.source_file = Some(file.path.clone());
1085                            }
1086                        }
1087                    }
1088                }
1089                ConfigElement::GlobalLine(_) => {}
1090            }
1091        }
1092    }
1093
1094    /// Collect all resolved Include file paths (recursively).
1095    pub fn include_paths(&self) -> Vec<PathBuf> {
1096        let mut paths = Vec::new();
1097        Self::collect_include_paths(&self.elements, &mut paths);
1098        paths
1099    }
1100
1101    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1102        for e in elements {
1103            if let ConfigElement::Include(include) = e {
1104                for file in &include.resolved_files {
1105                    paths.push(file.path.clone());
1106                    Self::collect_include_paths(&file.elements, paths);
1107                }
1108            }
1109        }
1110    }
1111
1112    /// Collect parent directories of Include glob patterns.
1113    /// When a file is added/removed under a glob dir, the directory's mtime changes.
1114    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1115        let config_dir = self.path.parent();
1116        let mut seen = std::collections::HashSet::new();
1117        let mut dirs = Vec::new();
1118        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1119        dirs
1120    }
1121
1122    fn collect_include_glob_dirs(
1123        elements: &[ConfigElement],
1124        config_dir: Option<&std::path::Path>,
1125        seen: &mut std::collections::HashSet<PathBuf>,
1126        dirs: &mut Vec<PathBuf>,
1127    ) {
1128        for e in elements {
1129            if let ConfigElement::Include(include) = e {
1130                // Split respecting quoted paths (same as resolve_include does)
1131                for single in Self::split_include_patterns(&include.pattern) {
1132                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1133                    let resolved = if expanded.starts_with('/') {
1134                        PathBuf::from(&expanded)
1135                    } else if let Some(dir) = config_dir {
1136                        dir.join(&expanded)
1137                    } else {
1138                        continue;
1139                    };
1140                    if let Some(parent) = resolved.parent() {
1141                        let parent = parent.to_path_buf();
1142                        if seen.insert(parent.clone()) {
1143                            dirs.push(parent);
1144                        }
1145                    }
1146                }
1147                // Recurse into resolved files
1148                for file in &include.resolved_files {
1149                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1150                }
1151            }
1152        }
1153    }
1154
1155    /// Remove `# purple:group <Name>` headers that have no corresponding
1156    /// provider hosts. Returns the number of headers removed.
1157    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1158        // Collect all provider display names that have at least one host.
1159        let active_providers: std::collections::HashSet<String> = self
1160            .elements
1161            .iter()
1162            .filter_map(|e| {
1163                if let ConfigElement::HostBlock(block) = e {
1164                    block
1165                        .provider()
1166                        .map(|(name, _)| provider_group_display_name(&name).to_string())
1167                } else {
1168                    None
1169                }
1170            })
1171            .collect();
1172
1173        let mut removed = 0;
1174        self.elements.retain(|e| {
1175            if let ConfigElement::GlobalLine(line) = e {
1176                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1177                    if !active_providers.contains(rest.trim()) {
1178                        removed += 1;
1179                        return false;
1180                    }
1181                }
1182            }
1183            true
1184        });
1185        removed
1186    }
1187
1188    /// Repair configs where `# purple:group` comments were absorbed into the
1189    /// preceding host block's directives instead of being stored as GlobalLines.
1190    /// Returns the number of blocks that were repaired.
1191    pub fn repair_absorbed_group_comments(&mut self) -> usize {
1192        let mut repaired = 0;
1193        let mut idx = 0;
1194        while idx < self.elements.len() {
1195            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1196                block
1197                    .directives
1198                    .iter()
1199                    .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1200            } else {
1201                false
1202            };
1203
1204            if !needs_repair {
1205                idx += 1;
1206                continue;
1207            }
1208
1209            // Find the index of the first absorbed group comment in this block's directives.
1210            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1211                block
1212            } else {
1213                unreachable!()
1214            };
1215
1216            let group_idx = block
1217                .directives
1218                .iter()
1219                .position(|d| {
1220                    d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1221                })
1222                .unwrap();
1223
1224            // Find where trailing blanks before the group comment start.
1225            let mut keep_end = group_idx;
1226            while keep_end > 0
1227                && block.directives[keep_end - 1].is_non_directive
1228                && block.directives[keep_end - 1].raw_line.trim().is_empty()
1229            {
1230                keep_end -= 1;
1231            }
1232
1233            // Collect everything from keep_end onward as GlobalLines.
1234            let extracted: Vec<ConfigElement> = block
1235                .directives
1236                .drain(keep_end..)
1237                .map(|d| ConfigElement::GlobalLine(d.raw_line))
1238                .collect();
1239
1240            // Insert extracted GlobalLines right after this HostBlock.
1241            let insert_at = idx + 1;
1242            for (i, elem) in extracted.into_iter().enumerate() {
1243                self.elements.insert(insert_at + i, elem);
1244            }
1245
1246            repaired += 1;
1247            // Advance past the inserted elements.
1248            idx = insert_at;
1249            // Skip the inserted elements to continue scanning.
1250            while idx < self.elements.len() {
1251                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1252                    break;
1253                }
1254                idx += 1;
1255            }
1256        }
1257        repaired
1258    }
1259
1260    /// Recursively collect host entries from a list of elements.
1261    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1262        for e in elements {
1263            match e {
1264                ConfigElement::HostBlock(block) => {
1265                    if is_host_pattern(&block.host_pattern) {
1266                        continue;
1267                    }
1268                    entries.push(block.to_host_entry());
1269                }
1270                ConfigElement::Include(include) => {
1271                    for file in &include.resolved_files {
1272                        let start = entries.len();
1273                        Self::collect_host_entries(&file.elements, entries);
1274                        for entry in &mut entries[start..] {
1275                            if entry.source_file.is_none() {
1276                                entry.source_file = Some(file.path.clone());
1277                            }
1278                        }
1279                    }
1280                }
1281                ConfigElement::GlobalLine(_) => {}
1282            }
1283        }
1284    }
1285
1286    /// Check if a host alias already exists (including in Include files).
1287    /// Walks the element tree directly without building HostEntry structs.
1288    pub fn has_host(&self, alias: &str) -> bool {
1289        Self::has_host_in_elements(&self.elements, alias)
1290    }
1291
1292    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1293        for e in elements {
1294            match e {
1295                ConfigElement::HostBlock(block) => {
1296                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1297                        return true;
1298                    }
1299                }
1300                ConfigElement::Include(include) => {
1301                    for file in &include.resolved_files {
1302                        if Self::has_host_in_elements(&file.elements, alias) {
1303                            return true;
1304                        }
1305                    }
1306                }
1307                ConfigElement::GlobalLine(_) => {}
1308            }
1309        }
1310        false
1311    }
1312
1313    /// Check if a host block with exactly this host_pattern exists (top-level only).
1314    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
1315    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
1316    /// Does not search Include files (patterns from includes are read-only).
1317    pub fn has_host_block(&self, pattern: &str) -> bool {
1318        self.elements
1319            .iter()
1320            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1321    }
1322
1323    /// Check if a host alias is from an included file (read-only).
1324    /// Handles multi-pattern Host lines by splitting on whitespace.
1325    pub fn is_included_host(&self, alias: &str) -> bool {
1326        // Not in top-level elements → must be in an Include
1327        for e in &self.elements {
1328            match e {
1329                ConfigElement::HostBlock(block) => {
1330                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1331                        return false;
1332                    }
1333                }
1334                ConfigElement::Include(include) => {
1335                    for file in &include.resolved_files {
1336                        if Self::has_host_in_elements(&file.elements, alias) {
1337                            return true;
1338                        }
1339                    }
1340                }
1341                ConfigElement::GlobalLine(_) => {}
1342            }
1343        }
1344        false
1345    }
1346
1347    /// Add a new host entry to the config.
1348    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
1349    /// so that SSH "first match wins" semantics are preserved. If wildcards are
1350    /// only at the top of the file (acting as global defaults), appends at end.
1351    pub fn add_host(&mut self, entry: &HostEntry) {
1352        let block = Self::entry_to_block(entry);
1353        let insert_pos = self.find_trailing_pattern_start();
1354
1355        if let Some(pos) = insert_pos {
1356            // Insert before the trailing pattern group, with blank separators
1357            let needs_blank_before = pos > 0
1358                && !matches!(
1359                    self.elements.get(pos - 1),
1360                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1361                );
1362            let mut idx = pos;
1363            if needs_blank_before {
1364                self.elements
1365                    .insert(idx, ConfigElement::GlobalLine(String::new()));
1366                idx += 1;
1367            }
1368            self.elements.insert(idx, ConfigElement::HostBlock(block));
1369            // Ensure a blank separator after the new block (before the wildcard group)
1370            let after = idx + 1;
1371            if after < self.elements.len()
1372                && !matches!(
1373                    self.elements.get(after),
1374                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1375                )
1376            {
1377                self.elements
1378                    .insert(after, ConfigElement::GlobalLine(String::new()));
1379            }
1380        } else {
1381            // No trailing patterns: append at end
1382            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1383                self.elements.push(ConfigElement::GlobalLine(String::new()));
1384            }
1385            self.elements.push(ConfigElement::HostBlock(block));
1386        }
1387    }
1388
1389    /// Find the start of a trailing group of wildcard/pattern Host blocks.
1390    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
1391    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
1392    /// i.e. patterns start at position 0 — in that case we append at end).
1393    fn find_trailing_pattern_start(&self) -> Option<usize> {
1394        let mut first_pattern_pos = None;
1395        for i in (0..self.elements.len()).rev() {
1396            match &self.elements[i] {
1397                ConfigElement::HostBlock(block) => {
1398                    if is_host_pattern(&block.host_pattern) {
1399                        first_pattern_pos = Some(i);
1400                    } else {
1401                        // Found a concrete host: the trailing group starts after this
1402                        break;
1403                    }
1404                }
1405                ConfigElement::GlobalLine(_) => {
1406                    // Blank lines, comments, Match blocks between patterns: keep scanning
1407                    if first_pattern_pos.is_some() {
1408                        first_pattern_pos = Some(i);
1409                    }
1410                }
1411                ConfigElement::Include(_) => break,
1412            }
1413        }
1414        // Don't return position 0 — that means everything is patterns (or patterns at top)
1415        first_pattern_pos.filter(|&pos| pos > 0)
1416    }
1417
1418    /// Check if the last element already ends with a blank line.
1419    pub fn last_element_has_trailing_blank(&self) -> bool {
1420        match self.elements.last() {
1421            Some(ConfigElement::HostBlock(block)) => block
1422                .directives
1423                .last()
1424                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1425            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1426            _ => false,
1427        }
1428    }
1429
1430    /// Update an existing host entry by alias.
1431    /// Merges changes into the existing block, preserving unknown directives.
1432    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1433        for element in &mut self.elements {
1434            if let ConfigElement::HostBlock(block) = element {
1435                if block.host_pattern == old_alias {
1436                    // Update host pattern (preserve raw_host_line when alias unchanged)
1437                    if entry.alias != block.host_pattern {
1438                        block.host_pattern = entry.alias.clone();
1439                        block.raw_host_line = format!("Host {}", entry.alias);
1440                    }
1441
1442                    // Merge known directives (update existing, add missing, remove empty)
1443                    Self::upsert_directive(block, "HostName", &entry.hostname);
1444                    Self::upsert_directive(block, "User", &entry.user);
1445                    if entry.port != 22 {
1446                        Self::upsert_directive(block, "Port", &entry.port.to_string());
1447                    } else {
1448                        // Remove explicit Port 22 (it's the default)
1449                        block
1450                            .directives
1451                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1452                    }
1453                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1454                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1455                    return;
1456                }
1457            }
1458        }
1459    }
1460
1461    /// Update a directive in-place, add it if missing, or remove it if value is empty.
1462    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1463        if value.is_empty() {
1464            block
1465                .directives
1466                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1467            return;
1468        }
1469        let indent = block.detect_indent();
1470        for d in &mut block.directives {
1471            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1472                // Only rebuild raw_line when value actually changed (preserves inline comments)
1473                if d.value != value {
1474                    d.value = value.to_string();
1475                    // Detect separator style from original raw_line and preserve it.
1476                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
1477                    // Only considers '=' as separator if it appears before any
1478                    // non-whitespace content (avoids matching '=' inside values
1479                    // like "IdentityFile ~/.ssh/id=prod").
1480                    let trimmed = d.raw_line.trim_start();
1481                    let after_key = &trimmed[d.key.len()..];
1482                    let sep = if after_key.trim_start().starts_with('=') {
1483                        let eq_pos = after_key.find('=').unwrap();
1484                        let after_eq = &after_key[eq_pos + 1..];
1485                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1486                        after_key[..eq_pos + 1 + trailing_ws].to_string()
1487                    } else {
1488                        " ".to_string()
1489                    };
1490                    // Preserve inline comment from original raw_line (e.g. "# production")
1491                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1492                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1493                }
1494                return;
1495            }
1496        }
1497        // Not found — insert before trailing blanks
1498        let pos = block.content_end();
1499        block.directives.insert(
1500            pos,
1501            Directive {
1502                key: key.to_string(),
1503                value: value.to_string(),
1504                raw_line: format!("{}{} {}", indent, key, value),
1505                is_non_directive: false,
1506            },
1507        );
1508    }
1509
1510    /// Extract the inline comment suffix from a directive's raw line.
1511    /// Returns the trailing portion (e.g. " # production") or empty string.
1512    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
1513    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1514        let trimmed = raw_line.trim_start();
1515        if trimmed.len() <= key.len() {
1516            return String::new();
1517        }
1518        // Skip past key and separator to reach the value portion
1519        let after_key = &trimmed[key.len()..];
1520        let rest = after_key.trim_start();
1521        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1522        // Scan for inline comment (# preceded by whitespace, outside quotes)
1523        let bytes = rest.as_bytes();
1524        let mut in_quote = false;
1525        for i in 0..bytes.len() {
1526            if bytes[i] == b'"' {
1527                in_quote = !in_quote;
1528            } else if !in_quote
1529                && bytes[i] == b'#'
1530                && i > 0
1531                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1532            {
1533                // Found comment start. The clean value ends before the whitespace preceding #.
1534                let clean_end = rest[..i].trim_end().len();
1535                return rest[clean_end..].to_string();
1536            }
1537        }
1538        String::new()
1539    }
1540
1541    /// Set provider on a host block by alias.
1542    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1543        for element in &mut self.elements {
1544            if let ConfigElement::HostBlock(block) = element {
1545                if block.host_pattern == alias {
1546                    block.set_provider(provider_name, server_id);
1547                    return;
1548                }
1549            }
1550        }
1551    }
1552
1553    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
1554    /// Searches both top-level elements and Include files so that provider hosts
1555    /// in included configs are recognized during sync (prevents duplicate additions).
1556    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1557        let mut results = Vec::new();
1558        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1559        results
1560    }
1561
1562    fn collect_provider_hosts(
1563        elements: &[ConfigElement],
1564        provider_name: &str,
1565        results: &mut Vec<(String, String)>,
1566    ) {
1567        for element in elements {
1568            match element {
1569                ConfigElement::HostBlock(block) => {
1570                    if let Some((name, id)) = block.provider() {
1571                        if name == provider_name {
1572                            results.push((block.host_pattern.clone(), id));
1573                        }
1574                    }
1575                }
1576                ConfigElement::Include(include) => {
1577                    for file in &include.resolved_files {
1578                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1579                    }
1580                }
1581                ConfigElement::GlobalLine(_) => {}
1582            }
1583        }
1584    }
1585
1586    /// Compare two directive values with whitespace normalization.
1587    /// Handles hand-edited configs with tabs or multiple spaces.
1588    fn values_match(a: &str, b: &str) -> bool {
1589        a.split_whitespace().eq(b.split_whitespace())
1590    }
1591
1592    /// Add a forwarding directive to a host block.
1593    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1594    /// Uses split_whitespace matching for multi-pattern Host lines.
1595    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1596        for element in &mut self.elements {
1597            if let ConfigElement::HostBlock(block) = element {
1598                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1599                    let indent = block.detect_indent();
1600                    let pos = block.content_end();
1601                    block.directives.insert(
1602                        pos,
1603                        Directive {
1604                            key: directive_key.to_string(),
1605                            value: value.to_string(),
1606                            raw_line: format!("{}{} {}", indent, directive_key, value),
1607                            is_non_directive: false,
1608                        },
1609                    );
1610                    return;
1611                }
1612            }
1613        }
1614    }
1615
1616    /// Remove a specific forwarding directive from a host block.
1617    /// Matches key (case-insensitive) and value (whitespace-normalized).
1618    /// Uses split_whitespace matching for multi-pattern Host lines.
1619    /// Returns true if a directive was actually removed.
1620    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1621        for element in &mut self.elements {
1622            if let ConfigElement::HostBlock(block) = element {
1623                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1624                    if let Some(pos) = block.directives.iter().position(|d| {
1625                        !d.is_non_directive
1626                            && d.key.eq_ignore_ascii_case(directive_key)
1627                            && Self::values_match(&d.value, value)
1628                    }) {
1629                        block.directives.remove(pos);
1630                        return true;
1631                    }
1632                    return false;
1633                }
1634            }
1635        }
1636        false
1637    }
1638
1639    /// Check if a host block has a specific forwarding directive.
1640    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1641    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1642        for element in &self.elements {
1643            if let ConfigElement::HostBlock(block) = element {
1644                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1645                    return block.directives.iter().any(|d| {
1646                        !d.is_non_directive
1647                            && d.key.eq_ignore_ascii_case(directive_key)
1648                            && Self::values_match(&d.value, value)
1649                    });
1650                }
1651            }
1652        }
1653        false
1654    }
1655
1656    /// Find tunnel directives for a host alias, searching all elements including
1657    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1658    /// Host lines.
1659    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1660        Self::find_tunnel_directives_in(&self.elements, alias)
1661    }
1662
1663    fn find_tunnel_directives_in(
1664        elements: &[ConfigElement],
1665        alias: &str,
1666    ) -> Vec<crate::tunnel::TunnelRule> {
1667        for element in elements {
1668            match element {
1669                ConfigElement::HostBlock(block) => {
1670                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1671                        return block.tunnel_directives();
1672                    }
1673                }
1674                ConfigElement::Include(include) => {
1675                    for file in &include.resolved_files {
1676                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1677                        if !rules.is_empty() {
1678                            return rules;
1679                        }
1680                    }
1681                }
1682                ConfigElement::GlobalLine(_) => {}
1683            }
1684        }
1685        Vec::new()
1686    }
1687
1688    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1689    pub fn deduplicate_alias(&self, base: &str) -> String {
1690        self.deduplicate_alias_excluding(base, None)
1691    }
1692
1693    /// Generate a unique alias, optionally excluding one alias from collision detection.
1694    /// Used during rename so the host being renamed doesn't collide with itself.
1695    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1696        let is_taken = |alias: &str| {
1697            if exclude == Some(alias) {
1698                return false;
1699            }
1700            self.has_host(alias)
1701        };
1702        if !is_taken(base) {
1703            return base.to_string();
1704        }
1705        for n in 2..=9999 {
1706            let candidate = format!("{}-{}", base, n);
1707            if !is_taken(&candidate) {
1708                return candidate;
1709            }
1710        }
1711        // Practically unreachable: fall back to PID-based suffix
1712        format!("{}-{}", base, std::process::id())
1713    }
1714
1715    /// Set tags on a host block by alias.
1716    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1717        for element in &mut self.elements {
1718            if let ConfigElement::HostBlock(block) = element {
1719                if block.host_pattern == alias {
1720                    block.set_tags(tags);
1721                    return;
1722                }
1723            }
1724        }
1725    }
1726
1727    /// Set provider-synced tags on a host block by alias.
1728    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1729        for element in &mut self.elements {
1730            if let ConfigElement::HostBlock(block) = element {
1731                if block.host_pattern == alias {
1732                    block.set_provider_tags(tags);
1733                    return;
1734                }
1735            }
1736        }
1737    }
1738
1739    /// Set askpass source on a host block by alias.
1740    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1741        for element in &mut self.elements {
1742            if let ConfigElement::HostBlock(block) = element {
1743                if block.host_pattern == alias {
1744                    block.set_askpass(source);
1745                    return;
1746                }
1747            }
1748        }
1749    }
1750
1751    /// Set vault-ssh role on a host block by alias.
1752    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1753        for element in &mut self.elements {
1754            if let ConfigElement::HostBlock(block) = element {
1755                if block.host_pattern == alias {
1756                    block.set_vault_ssh(role);
1757                    return;
1758                }
1759            }
1760        }
1761    }
1762
1763    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1764    /// Empty `url` removes the comment.
1765    ///
1766    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1767    /// aliases are refused to avoid accidentally applying a vault address to
1768    /// every host resolved through a pattern, and Match blocks are not
1769    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1770    /// successful mutation, `false` when the alias is invalid or the block
1771    /// is not found.
1772    ///
1773    /// Callers that run asynchronously (e.g. form submit handlers that
1774    /// resolve the alias before writing) MUST check the return value so a
1775    /// silent config mutation failure is surfaced instead of pretending the
1776    /// vault address was wired up.
1777    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1778    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1779        // Same guard as `set_host_certificate_file`: refuse empty aliases
1780        // and any SSH pattern shape. `is_host_pattern` already covers
1781        // wildcards, negation and whitespace-separated multi-host forms.
1782        if alias.is_empty() || is_host_pattern(alias) {
1783            return false;
1784        }
1785        for element in &mut self.elements {
1786            if let ConfigElement::HostBlock(block) = element {
1787                if block.host_pattern == alias {
1788                    block.set_vault_addr(url);
1789                    return true;
1790                }
1791            }
1792        }
1793        false
1794    }
1795
1796    /// Set or remove the CertificateFile directive on a host block by alias.
1797    /// Empty path removes the directive.
1798    /// Set the `CertificateFile` directive on the host block that matches
1799    /// `alias` exactly. Returns `true` if a matching block was found and
1800    /// updated, `false` if no top-level `HostBlock` matched (alias was
1801    /// renamed, deleted or lives only inside an `Include`d file).
1802    ///
1803    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1804    /// MUST check the return value so a silent config mutation failure is
1805    /// surfaced to the user instead of pretending the cert was wired up.
1806    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1807    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1808        // Defense in depth: refuse to mutate a host block when the requested
1809        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1810        // leading `!`, or whitespace-separated multi-host form like
1811        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1812        // block is almost never what a user intends and would affect every
1813        // host that resolves through that pattern. Reusing `is_host_pattern`
1814        // keeps this check in sync with the form-level pattern detection.
1815        if alias.is_empty() || is_host_pattern(alias) {
1816            return false;
1817        }
1818        for element in &mut self.elements {
1819            if let ConfigElement::HostBlock(block) = element {
1820                if block.host_pattern == alias {
1821                    Self::upsert_directive(block, "CertificateFile", path);
1822                    return true;
1823                }
1824            }
1825        }
1826        false
1827    }
1828
1829    /// Set provider metadata on a host block by alias.
1830    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1831        for element in &mut self.elements {
1832            if let ConfigElement::HostBlock(block) = element {
1833                if block.host_pattern == alias {
1834                    block.set_meta(meta);
1835                    return;
1836                }
1837            }
1838        }
1839    }
1840
1841    /// Mark a host as stale by alias.
1842    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1843        for element in &mut self.elements {
1844            if let ConfigElement::HostBlock(block) = element {
1845                if block.host_pattern == alias {
1846                    block.set_stale(timestamp);
1847                    return;
1848                }
1849            }
1850        }
1851    }
1852
1853    /// Clear stale marking from a host by alias.
1854    pub fn clear_host_stale(&mut self, alias: &str) {
1855        for element in &mut self.elements {
1856            if let ConfigElement::HostBlock(block) = element {
1857                if block.host_pattern == alias {
1858                    block.clear_stale();
1859                    return;
1860                }
1861            }
1862        }
1863    }
1864
1865    /// Collect all stale hosts with their timestamps.
1866    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1867        let mut result = Vec::new();
1868        for element in &self.elements {
1869            if let ConfigElement::HostBlock(block) = element {
1870                if let Some(ts) = block.stale() {
1871                    result.push((block.host_pattern.clone(), ts));
1872                }
1873            }
1874        }
1875        result
1876    }
1877
1878    /// Delete a host entry by alias.
1879    pub fn delete_host(&mut self, alias: &str) {
1880        // Before deletion, check if this host belongs to a provider so we can
1881        // clean up an orphaned group header afterwards.
1882        let provider_name = self.elements.iter().find_map(|e| {
1883            if let ConfigElement::HostBlock(b) = e {
1884                if b.host_pattern == alias {
1885                    return b.provider().map(|(name, _)| name);
1886                }
1887            }
1888            None
1889        });
1890
1891        self.elements.retain(|e| match e {
1892            ConfigElement::HostBlock(block) => block.host_pattern != alias,
1893            _ => true,
1894        });
1895
1896        // Remove orphaned group header if no hosts remain for the provider.
1897        if let Some(name) = provider_name {
1898            self.remove_orphaned_group_header(&name);
1899        }
1900
1901        // Collapse consecutive blank lines left by deletion
1902        self.elements.dedup_by(|a, b| {
1903            matches!(
1904                (&*a, &*b),
1905                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1906                if x.trim().is_empty() && y.trim().is_empty()
1907            )
1908        });
1909    }
1910
1911    /// Delete a host and return the removed element and its position for undo.
1912    /// Does NOT collapse blank lines or remove group headers so the position
1913    /// stays valid for re-insertion via `insert_host_at()`.
1914    /// Orphaned group headers (if any) are cleaned up at next startup.
1915    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1916        let pos = self
1917            .elements
1918            .iter()
1919            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1920        let element = self.elements.remove(pos);
1921        Some((element, pos))
1922    }
1923
1924    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
1925    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
1926    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1927        if self.find_hosts_by_provider(provider_name).is_empty() {
1928            let display = provider_group_display_name(provider_name);
1929            let header = format!("# purple:group {}", display);
1930            self.elements
1931                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1932        }
1933    }
1934
1935    /// Insert a host block at a specific position (for undo).
1936    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1937        let pos = position.min(self.elements.len());
1938        self.elements.insert(pos, element);
1939    }
1940
1941    /// Find the position after the last HostBlock that belongs to a provider.
1942    /// Returns `None` if no hosts for this provider exist in the config.
1943    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1944    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1945        let mut last_pos = None;
1946        for (i, element) in self.elements.iter().enumerate() {
1947            if let ConfigElement::HostBlock(block) = element {
1948                if let Some((name, _)) = block.provider() {
1949                    if name == provider_name {
1950                        last_pos = Some(i);
1951                    }
1952                }
1953            }
1954        }
1955        // Return position after the last provider host
1956        last_pos.map(|p| p + 1)
1957    }
1958
1959    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1960    #[allow(dead_code)]
1961    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1962        let pos_a = self
1963            .elements
1964            .iter()
1965            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1966        let pos_b = self
1967            .elements
1968            .iter()
1969            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1970        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1971            if a == b {
1972                return false;
1973            }
1974            let (first, second) = (a.min(b), a.max(b));
1975
1976            // Strip trailing blanks from both blocks before swap
1977            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1978                block.pop_trailing_blanks();
1979            }
1980            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1981                block.pop_trailing_blanks();
1982            }
1983
1984            // Swap
1985            self.elements.swap(first, second);
1986
1987            // Add trailing blank to first block (separator between the two)
1988            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1989                block.ensure_trailing_blank();
1990            }
1991
1992            // Add trailing blank to second only if not the last element
1993            if second < self.elements.len() - 1 {
1994                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1995                    block.ensure_trailing_blank();
1996                }
1997            }
1998
1999            return true;
2000        }
2001        false
2002    }
2003
2004    /// Convert a HostEntry into a new HostBlock with clean formatting.
2005    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2006        // Defense-in-depth: callers must validate before reaching here.
2007        // Newlines in values would inject extra SSH config directives.
2008        debug_assert!(
2009            !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2010            "entry_to_block: alias contains newline"
2011        );
2012        debug_assert!(
2013            !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2014            "entry_to_block: hostname contains newline"
2015        );
2016        debug_assert!(
2017            !entry.user.contains('\n') && !entry.user.contains('\r'),
2018            "entry_to_block: user contains newline"
2019        );
2020
2021        let mut directives = Vec::new();
2022
2023        if !entry.hostname.is_empty() {
2024            directives.push(Directive {
2025                key: "HostName".to_string(),
2026                value: entry.hostname.clone(),
2027                raw_line: format!("  HostName {}", entry.hostname),
2028                is_non_directive: false,
2029            });
2030        }
2031        if !entry.user.is_empty() {
2032            directives.push(Directive {
2033                key: "User".to_string(),
2034                value: entry.user.clone(),
2035                raw_line: format!("  User {}", entry.user),
2036                is_non_directive: false,
2037            });
2038        }
2039        if entry.port != 22 {
2040            directives.push(Directive {
2041                key: "Port".to_string(),
2042                value: entry.port.to_string(),
2043                raw_line: format!("  Port {}", entry.port),
2044                is_non_directive: false,
2045            });
2046        }
2047        if !entry.identity_file.is_empty() {
2048            directives.push(Directive {
2049                key: "IdentityFile".to_string(),
2050                value: entry.identity_file.clone(),
2051                raw_line: format!("  IdentityFile {}", entry.identity_file),
2052                is_non_directive: false,
2053            });
2054        }
2055        if !entry.proxy_jump.is_empty() {
2056            directives.push(Directive {
2057                key: "ProxyJump".to_string(),
2058                value: entry.proxy_jump.clone(),
2059                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
2060                is_non_directive: false,
2061            });
2062        }
2063
2064        HostBlock {
2065            host_pattern: entry.alias.clone(),
2066            raw_host_line: format!("Host {}", entry.alias),
2067            directives,
2068        }
2069    }
2070}
2071
2072#[cfg(test)]
2073#[path = "model_tests.rs"]
2074mod tests;