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" => {
875                    if entry.identity_file.is_empty() {
876                        entry.identity_file = d.value.clone();
877                    }
878                }
879                "proxyjump" => entry.proxy_jump = d.value.clone(),
880                _ => {}
881            }
882            entry.directives.push((d.key.clone(), d.value.clone()));
883        }
884        entry
885    }
886
887    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
888    pub fn tunnel_count(&self) -> u16 {
889        let count = self
890            .directives
891            .iter()
892            .filter(|d| {
893                !d.is_non_directive
894                    && (d.key.eq_ignore_ascii_case("localforward")
895                        || d.key.eq_ignore_ascii_case("remoteforward")
896                        || d.key.eq_ignore_ascii_case("dynamicforward"))
897            })
898            .count();
899        count.min(u16::MAX as usize) as u16
900    }
901
902    /// Check if this block has any tunnel forwarding directives.
903    #[allow(dead_code)]
904    pub fn has_tunnels(&self) -> bool {
905        self.directives.iter().any(|d| {
906            !d.is_non_directive
907                && (d.key.eq_ignore_ascii_case("localforward")
908                    || d.key.eq_ignore_ascii_case("remoteforward")
909                    || d.key.eq_ignore_ascii_case("dynamicforward"))
910        })
911    }
912
913    /// Extract tunnel rules from forwarding directives.
914    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
915        self.directives
916            .iter()
917            .filter(|d| !d.is_non_directive)
918            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
919            .collect()
920    }
921}
922
923impl SshConfigFile {
924    /// Get all host entries as convenience views (including from Include files).
925    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
926    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
927    /// SSH will actually apply when connecting via `ssh <alias>`.
928    pub fn host_entries(&self) -> Vec<HostEntry> {
929        let mut entries = Vec::new();
930        Self::collect_host_entries(&self.elements, &mut entries);
931        self.apply_pattern_inheritance(&mut entries);
932        entries
933    }
934
935    /// Get a single host entry by alias without pattern inheritance applied.
936    /// Returns the raw directives from the host's own block only. Used by the
937    /// edit form so inherited values can be shown as dimmed placeholders.
938    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
939        Self::find_raw_host_entry(&self.elements, alias)
940    }
941
942    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
943        for e in elements {
944            match e {
945                ConfigElement::HostBlock(block)
946                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
947                {
948                    return Some(block.to_host_entry());
949                }
950                ConfigElement::Include(inc) => {
951                    for file in &inc.resolved_files {
952                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
953                            if found.source_file.is_none() {
954                                found.source_file = Some(file.path.clone());
955                            }
956                            return Some(found);
957                        }
958                    }
959                }
960                _ => {}
961            }
962        }
963        None
964    }
965
966    /// Apply SSH first-match-wins pattern inheritance to host entries.
967    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
968    /// match the token typed on the command line, not the resolved `Hostname`).
969    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
970        // Patterns are pre-collected once. Host entries never contain pattern
971        // aliases — collect_host_entries skips is_host_pattern blocks.
972        let all_patterns = self.pattern_entries();
973        for entry in entries.iter_mut() {
974            if !entry.proxy_jump.is_empty()
975                && !entry.user.is_empty()
976                && !entry.identity_file.is_empty()
977            {
978                continue;
979            }
980            for p in &all_patterns {
981                if !host_pattern_matches(&p.pattern, &entry.alias) {
982                    continue;
983                }
984                apply_first_match_fields(
985                    &mut entry.proxy_jump,
986                    &mut entry.user,
987                    &mut entry.identity_file,
988                    p,
989                );
990                if !entry.proxy_jump.is_empty()
991                    && !entry.user.is_empty()
992                    && !entry.identity_file.is_empty()
993                {
994                    break;
995                }
996            }
997        }
998    }
999
1000    /// Compute pattern-provided field hints for a host alias. Returns first-match
1001    /// values and their source patterns for ProxyJump, User and IdentityFile.
1002    /// These are returned regardless of whether the host has its own values for
1003    /// those fields. The caller (form rendering) decides visibility based on
1004    /// whether the field is empty. Matches by alias only (SSH-faithful).
1005    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1006        let patterns = self.matching_patterns(alias);
1007        let mut hints = InheritedHints::default();
1008        for p in &patterns {
1009            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1010                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1011            }
1012            if hints.user.is_none() && !p.user.is_empty() {
1013                hints.user = Some((p.user.clone(), p.pattern.clone()));
1014            }
1015            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1016                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1017            }
1018            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1019                break;
1020            }
1021        }
1022        hints
1023    }
1024
1025    /// Get all pattern entries as convenience views (including from Include files).
1026    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1027        let mut entries = Vec::new();
1028        Self::collect_pattern_entries(&self.elements, &mut entries);
1029        entries
1030    }
1031
1032    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1033        for e in elements {
1034            match e {
1035                ConfigElement::HostBlock(block) => {
1036                    if !is_host_pattern(&block.host_pattern) {
1037                        continue;
1038                    }
1039                    entries.push(block.to_pattern_entry());
1040                }
1041                ConfigElement::Include(include) => {
1042                    for file in &include.resolved_files {
1043                        let start = entries.len();
1044                        Self::collect_pattern_entries(&file.elements, entries);
1045                        for entry in &mut entries[start..] {
1046                            if entry.source_file.is_none() {
1047                                entry.source_file = Some(file.path.clone());
1048                            }
1049                        }
1050                    }
1051                }
1052                ConfigElement::GlobalLine(_) => {}
1053            }
1054        }
1055    }
1056
1057    /// Find all pattern blocks that match a given host alias and hostname.
1058    /// Returns entries in config order (first match first).
1059    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1060        let mut matches = Vec::new();
1061        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1062        matches
1063    }
1064
1065    fn collect_matching_patterns(
1066        elements: &[ConfigElement],
1067        alias: &str,
1068        matches: &mut Vec<PatternEntry>,
1069    ) {
1070        for e in elements {
1071            match e {
1072                ConfigElement::HostBlock(block) => {
1073                    if !is_host_pattern(&block.host_pattern) {
1074                        continue;
1075                    }
1076                    if host_pattern_matches(&block.host_pattern, alias) {
1077                        matches.push(block.to_pattern_entry());
1078                    }
1079                }
1080                ConfigElement::Include(include) => {
1081                    for file in &include.resolved_files {
1082                        let start = matches.len();
1083                        Self::collect_matching_patterns(&file.elements, alias, matches);
1084                        for entry in &mut matches[start..] {
1085                            if entry.source_file.is_none() {
1086                                entry.source_file = Some(file.path.clone());
1087                            }
1088                        }
1089                    }
1090                }
1091                ConfigElement::GlobalLine(_) => {}
1092            }
1093        }
1094    }
1095
1096    /// Collect all resolved Include file paths (recursively).
1097    pub fn include_paths(&self) -> Vec<PathBuf> {
1098        let mut paths = Vec::new();
1099        Self::collect_include_paths(&self.elements, &mut paths);
1100        paths
1101    }
1102
1103    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1104        for e in elements {
1105            if let ConfigElement::Include(include) = e {
1106                for file in &include.resolved_files {
1107                    paths.push(file.path.clone());
1108                    Self::collect_include_paths(&file.elements, paths);
1109                }
1110            }
1111        }
1112    }
1113
1114    /// Collect parent directories of Include glob patterns.
1115    /// When a file is added/removed under a glob dir, the directory's mtime changes.
1116    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1117        let config_dir = self.path.parent();
1118        let mut seen = std::collections::HashSet::new();
1119        let mut dirs = Vec::new();
1120        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1121        dirs
1122    }
1123
1124    fn collect_include_glob_dirs(
1125        elements: &[ConfigElement],
1126        config_dir: Option<&std::path::Path>,
1127        seen: &mut std::collections::HashSet<PathBuf>,
1128        dirs: &mut Vec<PathBuf>,
1129    ) {
1130        for e in elements {
1131            if let ConfigElement::Include(include) = e {
1132                // Split respecting quoted paths (same as resolve_include does)
1133                for single in Self::split_include_patterns(&include.pattern) {
1134                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1135                    let resolved = if expanded.starts_with('/') {
1136                        PathBuf::from(&expanded)
1137                    } else if let Some(dir) = config_dir {
1138                        dir.join(&expanded)
1139                    } else {
1140                        continue;
1141                    };
1142                    if let Some(parent) = resolved.parent() {
1143                        let parent = parent.to_path_buf();
1144                        if seen.insert(parent.clone()) {
1145                            dirs.push(parent);
1146                        }
1147                    }
1148                }
1149                // Recurse into resolved files
1150                for file in &include.resolved_files {
1151                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1152                }
1153            }
1154        }
1155    }
1156
1157    /// Remove `# purple:group <Name>` headers that have no corresponding
1158    /// provider hosts. Returns the number of headers removed.
1159    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1160        // Collect all provider display names that have at least one host.
1161        let active_providers: std::collections::HashSet<String> = self
1162            .elements
1163            .iter()
1164            .filter_map(|e| {
1165                if let ConfigElement::HostBlock(block) = e {
1166                    block
1167                        .provider()
1168                        .map(|(name, _)| provider_group_display_name(&name).to_string())
1169                } else {
1170                    None
1171                }
1172            })
1173            .collect();
1174
1175        let mut removed = 0;
1176        self.elements.retain(|e| {
1177            if let ConfigElement::GlobalLine(line) = e {
1178                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1179                    if !active_providers.contains(rest.trim()) {
1180                        removed += 1;
1181                        return false;
1182                    }
1183                }
1184            }
1185            true
1186        });
1187        removed
1188    }
1189
1190    /// Repair configs where `# purple:group` comments were absorbed into the
1191    /// preceding host block's directives instead of being stored as GlobalLines.
1192    /// Returns the number of blocks that were repaired.
1193    pub fn repair_absorbed_group_comments(&mut self) -> usize {
1194        let mut repaired = 0;
1195        let mut idx = 0;
1196        while idx < self.elements.len() {
1197            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1198                block
1199                    .directives
1200                    .iter()
1201                    .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1202            } else {
1203                false
1204            };
1205
1206            if !needs_repair {
1207                idx += 1;
1208                continue;
1209            }
1210
1211            // Find the index of the first absorbed group comment in this block's directives.
1212            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1213                block
1214            } else {
1215                unreachable!()
1216            };
1217
1218            let group_idx = block
1219                .directives
1220                .iter()
1221                .position(|d| {
1222                    d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1223                })
1224                .unwrap();
1225
1226            // Find where trailing blanks before the group comment start.
1227            let mut keep_end = group_idx;
1228            while keep_end > 0
1229                && block.directives[keep_end - 1].is_non_directive
1230                && block.directives[keep_end - 1].raw_line.trim().is_empty()
1231            {
1232                keep_end -= 1;
1233            }
1234
1235            // Collect everything from keep_end onward as GlobalLines.
1236            let extracted: Vec<ConfigElement> = block
1237                .directives
1238                .drain(keep_end..)
1239                .map(|d| ConfigElement::GlobalLine(d.raw_line))
1240                .collect();
1241
1242            // Insert extracted GlobalLines right after this HostBlock.
1243            let insert_at = idx + 1;
1244            for (i, elem) in extracted.into_iter().enumerate() {
1245                self.elements.insert(insert_at + i, elem);
1246            }
1247
1248            repaired += 1;
1249            // Advance past the inserted elements.
1250            idx = insert_at;
1251            // Skip the inserted elements to continue scanning.
1252            while idx < self.elements.len() {
1253                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1254                    break;
1255                }
1256                idx += 1;
1257            }
1258        }
1259        repaired
1260    }
1261
1262    /// Recursively collect host entries from a list of elements.
1263    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1264        for e in elements {
1265            match e {
1266                ConfigElement::HostBlock(block) => {
1267                    if is_host_pattern(&block.host_pattern) {
1268                        continue;
1269                    }
1270                    entries.push(block.to_host_entry());
1271                }
1272                ConfigElement::Include(include) => {
1273                    for file in &include.resolved_files {
1274                        let start = entries.len();
1275                        Self::collect_host_entries(&file.elements, entries);
1276                        for entry in &mut entries[start..] {
1277                            if entry.source_file.is_none() {
1278                                entry.source_file = Some(file.path.clone());
1279                            }
1280                        }
1281                    }
1282                }
1283                ConfigElement::GlobalLine(_) => {}
1284            }
1285        }
1286    }
1287
1288    /// Check if a host alias already exists (including in Include files).
1289    /// Walks the element tree directly without building HostEntry structs.
1290    pub fn has_host(&self, alias: &str) -> bool {
1291        Self::has_host_in_elements(&self.elements, alias)
1292    }
1293
1294    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1295        for e in elements {
1296            match e {
1297                ConfigElement::HostBlock(block) => {
1298                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1299                        return true;
1300                    }
1301                }
1302                ConfigElement::Include(include) => {
1303                    for file in &include.resolved_files {
1304                        if Self::has_host_in_elements(&file.elements, alias) {
1305                            return true;
1306                        }
1307                    }
1308                }
1309                ConfigElement::GlobalLine(_) => {}
1310            }
1311        }
1312        false
1313    }
1314
1315    /// Check if a host block with exactly this host_pattern exists (top-level only).
1316    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
1317    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
1318    /// Does not search Include files (patterns from includes are read-only).
1319    pub fn has_host_block(&self, pattern: &str) -> bool {
1320        self.elements
1321            .iter()
1322            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1323    }
1324
1325    /// Check if a host alias is from an included file (read-only).
1326    /// Handles multi-pattern Host lines by splitting on whitespace.
1327    pub fn is_included_host(&self, alias: &str) -> bool {
1328        // Not in top-level elements → must be in an Include
1329        for e in &self.elements {
1330            match e {
1331                ConfigElement::HostBlock(block) => {
1332                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1333                        return false;
1334                    }
1335                }
1336                ConfigElement::Include(include) => {
1337                    for file in &include.resolved_files {
1338                        if Self::has_host_in_elements(&file.elements, alias) {
1339                            return true;
1340                        }
1341                    }
1342                }
1343                ConfigElement::GlobalLine(_) => {}
1344            }
1345        }
1346        false
1347    }
1348
1349    /// Add a new host entry to the config.
1350    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
1351    /// so that SSH "first match wins" semantics are preserved. If wildcards are
1352    /// only at the top of the file (acting as global defaults), appends at end.
1353    pub fn add_host(&mut self, entry: &HostEntry) {
1354        let block = Self::entry_to_block(entry);
1355        let insert_pos = self.find_trailing_pattern_start();
1356
1357        if let Some(pos) = insert_pos {
1358            // Insert before the trailing pattern group, with blank separators
1359            let needs_blank_before = pos > 0
1360                && !matches!(
1361                    self.elements.get(pos - 1),
1362                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1363                );
1364            let mut idx = pos;
1365            if needs_blank_before {
1366                self.elements
1367                    .insert(idx, ConfigElement::GlobalLine(String::new()));
1368                idx += 1;
1369            }
1370            self.elements.insert(idx, ConfigElement::HostBlock(block));
1371            // Ensure a blank separator after the new block (before the wildcard group)
1372            let after = idx + 1;
1373            if after < self.elements.len()
1374                && !matches!(
1375                    self.elements.get(after),
1376                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1377                )
1378            {
1379                self.elements
1380                    .insert(after, ConfigElement::GlobalLine(String::new()));
1381            }
1382        } else {
1383            // No trailing patterns: append at end
1384            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1385                self.elements.push(ConfigElement::GlobalLine(String::new()));
1386            }
1387            self.elements.push(ConfigElement::HostBlock(block));
1388        }
1389    }
1390
1391    /// Find the start of a trailing group of wildcard/pattern Host blocks.
1392    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
1393    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
1394    /// i.e. patterns start at position 0 — in that case we append at end).
1395    fn find_trailing_pattern_start(&self) -> Option<usize> {
1396        let mut first_pattern_pos = None;
1397        for i in (0..self.elements.len()).rev() {
1398            match &self.elements[i] {
1399                ConfigElement::HostBlock(block) => {
1400                    if is_host_pattern(&block.host_pattern) {
1401                        first_pattern_pos = Some(i);
1402                    } else {
1403                        // Found a concrete host: the trailing group starts after this
1404                        break;
1405                    }
1406                }
1407                ConfigElement::GlobalLine(_) => {
1408                    // Blank lines, comments, Match blocks between patterns: keep scanning
1409                    if first_pattern_pos.is_some() {
1410                        first_pattern_pos = Some(i);
1411                    }
1412                }
1413                ConfigElement::Include(_) => break,
1414            }
1415        }
1416        // Don't return position 0 — that means everything is patterns (or patterns at top)
1417        first_pattern_pos.filter(|&pos| pos > 0)
1418    }
1419
1420    /// Check if the last element already ends with a blank line.
1421    pub fn last_element_has_trailing_blank(&self) -> bool {
1422        match self.elements.last() {
1423            Some(ConfigElement::HostBlock(block)) => block
1424                .directives
1425                .last()
1426                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1427            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1428            _ => false,
1429        }
1430    }
1431
1432    /// Update an existing host entry by alias.
1433    /// Merges changes into the existing block, preserving unknown directives.
1434    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1435        for element in &mut self.elements {
1436            if let ConfigElement::HostBlock(block) = element {
1437                if block.host_pattern == old_alias {
1438                    // Update host pattern (preserve raw_host_line when alias unchanged)
1439                    if entry.alias != block.host_pattern {
1440                        block.host_pattern = entry.alias.clone();
1441                        block.raw_host_line = format!("Host {}", entry.alias);
1442                    }
1443
1444                    // Merge known directives (update existing, add missing, remove empty)
1445                    Self::upsert_directive(block, "HostName", &entry.hostname);
1446                    Self::upsert_directive(block, "User", &entry.user);
1447                    if entry.port != 22 {
1448                        Self::upsert_directive(block, "Port", &entry.port.to_string());
1449                    } else {
1450                        // Remove explicit Port 22 (it's the default)
1451                        block
1452                            .directives
1453                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1454                    }
1455                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1456                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1457                    return;
1458                }
1459            }
1460        }
1461    }
1462
1463    /// Update a directive in-place, add it if missing, or remove it if value is empty.
1464    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1465        if value.is_empty() {
1466            block
1467                .directives
1468                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1469            return;
1470        }
1471        let indent = block.detect_indent();
1472        for d in &mut block.directives {
1473            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1474                // Only rebuild raw_line when value actually changed (preserves inline comments)
1475                if d.value != value {
1476                    d.value = value.to_string();
1477                    // Detect separator style from original raw_line and preserve it.
1478                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
1479                    // Only considers '=' as separator if it appears before any
1480                    // non-whitespace content (avoids matching '=' inside values
1481                    // like "IdentityFile ~/.ssh/id=prod").
1482                    let trimmed = d.raw_line.trim_start();
1483                    let after_key = &trimmed[d.key.len()..];
1484                    let sep = if after_key.trim_start().starts_with('=') {
1485                        let eq_pos = after_key.find('=').unwrap();
1486                        let after_eq = &after_key[eq_pos + 1..];
1487                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1488                        after_key[..eq_pos + 1 + trailing_ws].to_string()
1489                    } else {
1490                        " ".to_string()
1491                    };
1492                    // Preserve inline comment from original raw_line (e.g. "# production")
1493                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1494                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1495                }
1496                return;
1497            }
1498        }
1499        // Not found — insert before trailing blanks
1500        let pos = block.content_end();
1501        block.directives.insert(
1502            pos,
1503            Directive {
1504                key: key.to_string(),
1505                value: value.to_string(),
1506                raw_line: format!("{}{} {}", indent, key, value),
1507                is_non_directive: false,
1508            },
1509        );
1510    }
1511
1512    /// Extract the inline comment suffix from a directive's raw line.
1513    /// Returns the trailing portion (e.g. " # production") or empty string.
1514    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
1515    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1516        let trimmed = raw_line.trim_start();
1517        if trimmed.len() <= key.len() {
1518            return String::new();
1519        }
1520        // Skip past key and separator to reach the value portion
1521        let after_key = &trimmed[key.len()..];
1522        let rest = after_key.trim_start();
1523        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1524        // Scan for inline comment (# preceded by whitespace, outside quotes)
1525        let bytes = rest.as_bytes();
1526        let mut in_quote = false;
1527        for i in 0..bytes.len() {
1528            if bytes[i] == b'"' {
1529                in_quote = !in_quote;
1530            } else if !in_quote
1531                && bytes[i] == b'#'
1532                && i > 0
1533                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1534            {
1535                // Found comment start. The clean value ends before the whitespace preceding #.
1536                let clean_end = rest[..i].trim_end().len();
1537                return rest[clean_end..].to_string();
1538            }
1539        }
1540        String::new()
1541    }
1542
1543    /// Set provider on a host block by alias.
1544    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1545        for element in &mut self.elements {
1546            if let ConfigElement::HostBlock(block) = element {
1547                if block.host_pattern == alias {
1548                    block.set_provider(provider_name, server_id);
1549                    return;
1550                }
1551            }
1552        }
1553    }
1554
1555    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
1556    /// Searches both top-level elements and Include files so that provider hosts
1557    /// in included configs are recognized during sync (prevents duplicate additions).
1558    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1559        let mut results = Vec::new();
1560        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1561        results
1562    }
1563
1564    fn collect_provider_hosts(
1565        elements: &[ConfigElement],
1566        provider_name: &str,
1567        results: &mut Vec<(String, String)>,
1568    ) {
1569        for element in elements {
1570            match element {
1571                ConfigElement::HostBlock(block) => {
1572                    if let Some((name, id)) = block.provider() {
1573                        if name == provider_name {
1574                            results.push((block.host_pattern.clone(), id));
1575                        }
1576                    }
1577                }
1578                ConfigElement::Include(include) => {
1579                    for file in &include.resolved_files {
1580                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1581                    }
1582                }
1583                ConfigElement::GlobalLine(_) => {}
1584            }
1585        }
1586    }
1587
1588    /// Compare two directive values with whitespace normalization.
1589    /// Handles hand-edited configs with tabs or multiple spaces.
1590    fn values_match(a: &str, b: &str) -> bool {
1591        a.split_whitespace().eq(b.split_whitespace())
1592    }
1593
1594    /// Add a forwarding directive to a host block.
1595    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1596    /// Uses split_whitespace matching for multi-pattern Host lines.
1597    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1598        for element in &mut self.elements {
1599            if let ConfigElement::HostBlock(block) = element {
1600                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1601                    let indent = block.detect_indent();
1602                    let pos = block.content_end();
1603                    block.directives.insert(
1604                        pos,
1605                        Directive {
1606                            key: directive_key.to_string(),
1607                            value: value.to_string(),
1608                            raw_line: format!("{}{} {}", indent, directive_key, value),
1609                            is_non_directive: false,
1610                        },
1611                    );
1612                    return;
1613                }
1614            }
1615        }
1616    }
1617
1618    /// Remove a specific forwarding directive from a host block.
1619    /// Matches key (case-insensitive) and value (whitespace-normalized).
1620    /// Uses split_whitespace matching for multi-pattern Host lines.
1621    /// Returns true if a directive was actually removed.
1622    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1623        for element in &mut self.elements {
1624            if let ConfigElement::HostBlock(block) = element {
1625                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1626                    if let Some(pos) = block.directives.iter().position(|d| {
1627                        !d.is_non_directive
1628                            && d.key.eq_ignore_ascii_case(directive_key)
1629                            && Self::values_match(&d.value, value)
1630                    }) {
1631                        block.directives.remove(pos);
1632                        return true;
1633                    }
1634                    return false;
1635                }
1636            }
1637        }
1638        false
1639    }
1640
1641    /// Check if a host block has a specific forwarding directive.
1642    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1643    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1644        for element in &self.elements {
1645            if let ConfigElement::HostBlock(block) = element {
1646                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1647                    return block.directives.iter().any(|d| {
1648                        !d.is_non_directive
1649                            && d.key.eq_ignore_ascii_case(directive_key)
1650                            && Self::values_match(&d.value, value)
1651                    });
1652                }
1653            }
1654        }
1655        false
1656    }
1657
1658    /// Find tunnel directives for a host alias, searching all elements including
1659    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1660    /// Host lines.
1661    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1662        Self::find_tunnel_directives_in(&self.elements, alias)
1663    }
1664
1665    fn find_tunnel_directives_in(
1666        elements: &[ConfigElement],
1667        alias: &str,
1668    ) -> Vec<crate::tunnel::TunnelRule> {
1669        for element in elements {
1670            match element {
1671                ConfigElement::HostBlock(block) => {
1672                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1673                        return block.tunnel_directives();
1674                    }
1675                }
1676                ConfigElement::Include(include) => {
1677                    for file in &include.resolved_files {
1678                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1679                        if !rules.is_empty() {
1680                            return rules;
1681                        }
1682                    }
1683                }
1684                ConfigElement::GlobalLine(_) => {}
1685            }
1686        }
1687        Vec::new()
1688    }
1689
1690    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1691    pub fn deduplicate_alias(&self, base: &str) -> String {
1692        self.deduplicate_alias_excluding(base, None)
1693    }
1694
1695    /// Generate a unique alias, optionally excluding one alias from collision detection.
1696    /// Used during rename so the host being renamed doesn't collide with itself.
1697    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1698        let is_taken = |alias: &str| {
1699            if exclude == Some(alias) {
1700                return false;
1701            }
1702            self.has_host(alias)
1703        };
1704        if !is_taken(base) {
1705            return base.to_string();
1706        }
1707        for n in 2..=9999 {
1708            let candidate = format!("{}-{}", base, n);
1709            if !is_taken(&candidate) {
1710                return candidate;
1711            }
1712        }
1713        // Practically unreachable: fall back to PID-based suffix
1714        format!("{}-{}", base, std::process::id())
1715    }
1716
1717    /// Set tags on a host block by alias.
1718    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1719        for element in &mut self.elements {
1720            if let ConfigElement::HostBlock(block) = element {
1721                if block.host_pattern == alias {
1722                    block.set_tags(tags);
1723                    return;
1724                }
1725            }
1726        }
1727    }
1728
1729    /// Set provider-synced tags on a host block by alias.
1730    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1731        for element in &mut self.elements {
1732            if let ConfigElement::HostBlock(block) = element {
1733                if block.host_pattern == alias {
1734                    block.set_provider_tags(tags);
1735                    return;
1736                }
1737            }
1738        }
1739    }
1740
1741    /// Set askpass source on a host block by alias.
1742    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1743        for element in &mut self.elements {
1744            if let ConfigElement::HostBlock(block) = element {
1745                if block.host_pattern == alias {
1746                    block.set_askpass(source);
1747                    return;
1748                }
1749            }
1750        }
1751    }
1752
1753    /// Set vault-ssh role on a host block by alias.
1754    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1755        for element in &mut self.elements {
1756            if let ConfigElement::HostBlock(block) = element {
1757                if block.host_pattern == alias {
1758                    block.set_vault_ssh(role);
1759                    return;
1760                }
1761            }
1762        }
1763    }
1764
1765    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1766    /// Empty `url` removes the comment.
1767    ///
1768    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1769    /// aliases are refused to avoid accidentally applying a vault address to
1770    /// every host resolved through a pattern, and Match blocks are not
1771    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1772    /// successful mutation, `false` when the alias is invalid or the block
1773    /// is not found.
1774    ///
1775    /// Callers that run asynchronously (e.g. form submit handlers that
1776    /// resolve the alias before writing) MUST check the return value so a
1777    /// silent config mutation failure is surfaced instead of pretending the
1778    /// vault address was wired up.
1779    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1780    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1781        // Same guard as `set_host_certificate_file`: refuse empty aliases
1782        // and any SSH pattern shape. `is_host_pattern` already covers
1783        // wildcards, negation and whitespace-separated multi-host forms.
1784        if alias.is_empty() || is_host_pattern(alias) {
1785            return false;
1786        }
1787        for element in &mut self.elements {
1788            if let ConfigElement::HostBlock(block) = element {
1789                if block.host_pattern == alias {
1790                    block.set_vault_addr(url);
1791                    return true;
1792                }
1793            }
1794        }
1795        false
1796    }
1797
1798    /// Set or remove the CertificateFile directive on a host block by alias.
1799    /// Empty path removes the directive.
1800    /// Set the `CertificateFile` directive on the host block that matches
1801    /// `alias` exactly. Returns `true` if a matching block was found and
1802    /// updated, `false` if no top-level `HostBlock` matched (alias was
1803    /// renamed, deleted or lives only inside an `Include`d file).
1804    ///
1805    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1806    /// MUST check the return value so a silent config mutation failure is
1807    /// surfaced to the user instead of pretending the cert was wired up.
1808    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1809    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1810        // Defense in depth: refuse to mutate a host block when the requested
1811        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1812        // leading `!`, or whitespace-separated multi-host form like
1813        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1814        // block is almost never what a user intends and would affect every
1815        // host that resolves through that pattern. Reusing `is_host_pattern`
1816        // keeps this check in sync with the form-level pattern detection.
1817        if alias.is_empty() || is_host_pattern(alias) {
1818            return false;
1819        }
1820        for element in &mut self.elements {
1821            if let ConfigElement::HostBlock(block) = element {
1822                if block.host_pattern == alias {
1823                    Self::upsert_directive(block, "CertificateFile", path);
1824                    return true;
1825                }
1826            }
1827        }
1828        false
1829    }
1830
1831    /// Set provider metadata on a host block by alias.
1832    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1833        for element in &mut self.elements {
1834            if let ConfigElement::HostBlock(block) = element {
1835                if block.host_pattern == alias {
1836                    block.set_meta(meta);
1837                    return;
1838                }
1839            }
1840        }
1841    }
1842
1843    /// Mark a host as stale by alias.
1844    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1845        for element in &mut self.elements {
1846            if let ConfigElement::HostBlock(block) = element {
1847                if block.host_pattern == alias {
1848                    block.set_stale(timestamp);
1849                    return;
1850                }
1851            }
1852        }
1853    }
1854
1855    /// Clear stale marking from a host by alias.
1856    pub fn clear_host_stale(&mut self, alias: &str) {
1857        for element in &mut self.elements {
1858            if let ConfigElement::HostBlock(block) = element {
1859                if block.host_pattern == alias {
1860                    block.clear_stale();
1861                    return;
1862                }
1863            }
1864        }
1865    }
1866
1867    /// Collect all stale hosts with their timestamps.
1868    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1869        let mut result = Vec::new();
1870        for element in &self.elements {
1871            if let ConfigElement::HostBlock(block) = element {
1872                if let Some(ts) = block.stale() {
1873                    result.push((block.host_pattern.clone(), ts));
1874                }
1875            }
1876        }
1877        result
1878    }
1879
1880    /// Delete a host entry by alias.
1881    pub fn delete_host(&mut self, alias: &str) {
1882        // Before deletion, check if this host belongs to a provider so we can
1883        // clean up an orphaned group header afterwards.
1884        let provider_name = self.elements.iter().find_map(|e| {
1885            if let ConfigElement::HostBlock(b) = e {
1886                if b.host_pattern == alias {
1887                    return b.provider().map(|(name, _)| name);
1888                }
1889            }
1890            None
1891        });
1892
1893        self.elements.retain(|e| match e {
1894            ConfigElement::HostBlock(block) => block.host_pattern != alias,
1895            _ => true,
1896        });
1897
1898        // Remove orphaned group header if no hosts remain for the provider.
1899        if let Some(name) = provider_name {
1900            self.remove_orphaned_group_header(&name);
1901        }
1902
1903        // Collapse consecutive blank lines left by deletion
1904        self.elements.dedup_by(|a, b| {
1905            matches!(
1906                (&*a, &*b),
1907                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1908                if x.trim().is_empty() && y.trim().is_empty()
1909            )
1910        });
1911    }
1912
1913    /// Delete a host and return the removed element and its position for undo.
1914    /// Does NOT collapse blank lines or remove group headers so the position
1915    /// stays valid for re-insertion via `insert_host_at()`.
1916    /// Orphaned group headers (if any) are cleaned up at next startup.
1917    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1918        let pos = self
1919            .elements
1920            .iter()
1921            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1922        let element = self.elements.remove(pos);
1923        Some((element, pos))
1924    }
1925
1926    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
1927    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
1928    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1929        if self.find_hosts_by_provider(provider_name).is_empty() {
1930            let display = provider_group_display_name(provider_name);
1931            let header = format!("# purple:group {}", display);
1932            self.elements
1933                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1934        }
1935    }
1936
1937    /// Insert a host block at a specific position (for undo).
1938    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1939        let pos = position.min(self.elements.len());
1940        self.elements.insert(pos, element);
1941    }
1942
1943    /// Find the position after the last HostBlock that belongs to a provider.
1944    /// Returns `None` if no hosts for this provider exist in the config.
1945    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1946    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1947        let mut last_pos = None;
1948        for (i, element) in self.elements.iter().enumerate() {
1949            if let ConfigElement::HostBlock(block) = element {
1950                if let Some((name, _)) = block.provider() {
1951                    if name == provider_name {
1952                        last_pos = Some(i);
1953                    }
1954                }
1955            }
1956        }
1957        // Return position after the last provider host
1958        last_pos.map(|p| p + 1)
1959    }
1960
1961    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1962    #[allow(dead_code)]
1963    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1964        let pos_a = self
1965            .elements
1966            .iter()
1967            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1968        let pos_b = self
1969            .elements
1970            .iter()
1971            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1972        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1973            if a == b {
1974                return false;
1975            }
1976            let (first, second) = (a.min(b), a.max(b));
1977
1978            // Strip trailing blanks from both blocks before swap
1979            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1980                block.pop_trailing_blanks();
1981            }
1982            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1983                block.pop_trailing_blanks();
1984            }
1985
1986            // Swap
1987            self.elements.swap(first, second);
1988
1989            // Add trailing blank to first block (separator between the two)
1990            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1991                block.ensure_trailing_blank();
1992            }
1993
1994            // Add trailing blank to second only if not the last element
1995            if second < self.elements.len() - 1 {
1996                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1997                    block.ensure_trailing_blank();
1998                }
1999            }
2000
2001            return true;
2002        }
2003        false
2004    }
2005
2006    /// Convert a HostEntry into a new HostBlock with clean formatting.
2007    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2008        // Defense-in-depth: callers must validate before reaching here.
2009        // Newlines in values would inject extra SSH config directives.
2010        debug_assert!(
2011            !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2012            "entry_to_block: alias contains newline"
2013        );
2014        debug_assert!(
2015            !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2016            "entry_to_block: hostname contains newline"
2017        );
2018        debug_assert!(
2019            !entry.user.contains('\n') && !entry.user.contains('\r'),
2020            "entry_to_block: user contains newline"
2021        );
2022
2023        let mut directives = Vec::new();
2024
2025        if !entry.hostname.is_empty() {
2026            directives.push(Directive {
2027                key: "HostName".to_string(),
2028                value: entry.hostname.clone(),
2029                raw_line: format!("  HostName {}", entry.hostname),
2030                is_non_directive: false,
2031            });
2032        }
2033        if !entry.user.is_empty() {
2034            directives.push(Directive {
2035                key: "User".to_string(),
2036                value: entry.user.clone(),
2037                raw_line: format!("  User {}", entry.user),
2038                is_non_directive: false,
2039            });
2040        }
2041        if entry.port != 22 {
2042            directives.push(Directive {
2043                key: "Port".to_string(),
2044                value: entry.port.to_string(),
2045                raw_line: format!("  Port {}", entry.port),
2046                is_non_directive: false,
2047            });
2048        }
2049        if !entry.identity_file.is_empty() {
2050            directives.push(Directive {
2051                key: "IdentityFile".to_string(),
2052                value: entry.identity_file.clone(),
2053                raw_line: format!("  IdentityFile {}", entry.identity_file),
2054                is_non_directive: false,
2055            });
2056        }
2057        if !entry.proxy_jump.is_empty() {
2058            directives.push(Directive {
2059                key: "ProxyJump".to_string(),
2060                value: entry.proxy_jump.clone(),
2061                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
2062                is_non_directive: false,
2063            });
2064        }
2065
2066        HostBlock {
2067            host_pattern: entry.alias.clone(),
2068            raw_host_line: format!("Host {}", entry.alias),
2069            directives,
2070        }
2071    }
2072}
2073
2074#[cfg(test)]
2075#[path = "model_tests.rs"]
2076mod tests;