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)]
37#[allow(dead_code)]
38pub struct IncludeDirective {
39    pub raw_line: String,
40    pub pattern: String,
41    pub resolved_files: Vec<IncludedFile>,
42}
43
44/// A file resolved from an Include directive.
45#[derive(Debug, Clone)]
46pub struct IncludedFile {
47    pub path: PathBuf,
48    pub elements: Vec<ConfigElement>,
49}
50
51/// A single element in the config file.
52#[derive(Debug, Clone)]
53pub enum ConfigElement {
54    /// A Host block: the "Host <pattern>" line plus all indented directives.
55    HostBlock(HostBlock),
56    /// A comment, blank line, or global directive not inside a Host block.
57    GlobalLine(String),
58    /// An Include directive referencing other config files (read-only).
59    Include(IncludeDirective),
60}
61
62/// A parsed Host block with its directives.
63#[derive(Debug, Clone)]
64pub struct HostBlock {
65    /// The host alias/pattern (the value after "Host").
66    pub host_pattern: String,
67    /// The original raw "Host ..." line for faithful reproduction.
68    pub raw_host_line: String,
69    /// Parsed directives inside this block.
70    pub directives: Vec<Directive>,
71}
72
73/// A directive line inside a Host block.
74#[derive(Debug, Clone)]
75pub struct Directive {
76    /// The directive key (e.g., "HostName", "User", "Port").
77    pub key: String,
78    /// The directive value.
79    pub value: String,
80    /// The original raw line (preserves indentation, inline comments).
81    pub raw_line: String,
82    /// Whether this is a comment-only or blank line inside a host block.
83    pub is_non_directive: bool,
84}
85
86/// Convenience view for the TUI — extracted from a HostBlock.
87#[derive(Debug, Clone)]
88pub struct HostEntry {
89    pub alias: String,
90    pub hostname: String,
91    pub user: String,
92    pub port: u16,
93    pub identity_file: String,
94    pub proxy_jump: String,
95    /// If this host comes from an included file, the file path.
96    pub source_file: Option<PathBuf>,
97    /// User-added tags from purple:tags comment.
98    pub tags: Vec<String>,
99    /// Provider-synced tags from purple:provider_tags comment.
100    pub provider_tags: Vec<String>,
101    /// Whether a purple:provider_tags comment exists (distinguishes "never migrated" from "empty").
102    pub has_provider_tags: bool,
103    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
104    pub provider: Option<String>,
105    /// Number of tunnel forwarding directives.
106    pub tunnel_count: u16,
107    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
108    pub askpass: Option<String>,
109    /// Vault SSH certificate signing role from purple:vault-ssh comment.
110    pub vault_ssh: Option<String>,
111    /// Optional Vault HTTP endpoint from purple:vault-addr comment. When
112    /// set, purple passes it as `VAULT_ADDR` to the `vault` subprocess for
113    /// this host's signing, overriding the parent shell. Empty = inherit env.
114    pub vault_addr: Option<String>,
115    /// CertificateFile directive value (e.g. "~/.ssh/my-cert.pub").
116    pub certificate_file: String,
117    /// Provider metadata from purple:meta comment (region, plan, etc.).
118    pub provider_meta: Vec<(String, String)>,
119    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
120    pub stale: Option<u64>,
121}
122
123impl Default for HostEntry {
124    fn default() -> Self {
125        Self {
126            alias: String::new(),
127            hostname: String::new(),
128            user: String::new(),
129            port: 22,
130            identity_file: String::new(),
131            proxy_jump: String::new(),
132            source_file: None,
133            tags: Vec::new(),
134            provider_tags: Vec::new(),
135            has_provider_tags: false,
136            provider: None,
137            tunnel_count: 0,
138            askpass: None,
139            vault_ssh: None,
140            vault_addr: None,
141            certificate_file: String::new(),
142            provider_meta: Vec::new(),
143            stale: None,
144        }
145    }
146}
147
148impl HostEntry {
149    /// Build the SSH command string for this host.
150    /// Includes `-F <config_path>` when the config is non-default so the alias
151    /// resolves correctly when pasted into a terminal.
152    /// Shell-quotes both the config path and alias to prevent injection.
153    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
154        let escaped = self.alias.replace('\'', "'\\''");
155        let default = dirs::home_dir()
156            .map(|h| h.join(".ssh/config"))
157            .unwrap_or_default();
158        if config_path == default {
159            format!("ssh -- '{}'", escaped)
160        } else {
161            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
162            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
163        }
164    }
165}
166
167/// Convenience view for pattern Host blocks in the TUI.
168#[derive(Debug, Clone, Default)]
169pub struct PatternEntry {
170    pub pattern: String,
171    pub hostname: String,
172    pub user: String,
173    pub port: u16,
174    pub identity_file: String,
175    pub proxy_jump: String,
176    pub tags: Vec<String>,
177    pub askpass: Option<String>,
178    pub source_file: Option<PathBuf>,
179    /// All non-comment directives as key-value pairs for display.
180    pub directives: Vec<(String, String)>,
181}
182
183/// Inherited field hints from matching patterns. Each field is `Some((value,
184/// source_pattern))` when a pattern provides that directive, `None` otherwise.
185#[derive(Debug, Clone, Default)]
186pub struct InheritedHints {
187    pub proxy_jump: Option<(String, String)>,
188    pub user: Option<(String, String)>,
189    pub identity_file: Option<(String, String)>,
190}
191
192/// Returns true if the host pattern contains wildcards, character classes,
193/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
194/// These are SSH match patterns, not concrete hosts.
195pub fn is_host_pattern(pattern: &str) -> bool {
196    pattern.contains('*')
197        || pattern.contains('?')
198        || pattern.contains('[')
199        || pattern.starts_with('!')
200        || pattern.contains(' ')
201        || pattern.contains('\t')
202}
203
204/// Match a text string against an SSH host pattern.
205/// Supports `*` (any sequence), `?` (single char), `[charset]` (character class),
206/// `[!charset]`/`[^charset]` (negated class), `[a-z]` (ranges) and `!pattern` (negation).
207pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
208    if let Some(rest) = pattern.strip_prefix('!') {
209        return !match_glob(rest, text);
210    }
211    match_glob(pattern, text)
212}
213
214/// Core glob matcher without negation prefix handling.
215/// Empty text only matches empty pattern.
216fn match_glob(pattern: &str, text: &str) -> bool {
217    if text.is_empty() {
218        return pattern.is_empty();
219    }
220    if pattern.is_empty() {
221        return false;
222    }
223    let pat: Vec<char> = pattern.chars().collect();
224    let txt: Vec<char> = text.chars().collect();
225    glob_match(&pat, &txt)
226}
227
228/// Iterative glob matching with star-backtracking.
229fn glob_match(pat: &[char], txt: &[char]) -> bool {
230    let mut pi = 0;
231    let mut ti = 0;
232    let mut star: Option<(usize, usize)> = None; // (pattern_pos, text_pos)
233
234    while ti < txt.len() {
235        if pi < pat.len() && pat[pi] == '?' {
236            pi += 1;
237            ti += 1;
238        } else if pi < pat.len() && pat[pi] == '*' {
239            star = Some((pi + 1, ti));
240            pi += 1;
241        } else if pi < pat.len() && pat[pi] == '[' {
242            if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
243                if matches {
244                    pi = end;
245                    ti += 1;
246                } else if let Some((spi, sti)) = star {
247                    let sti = sti + 1;
248                    star = Some((spi, sti));
249                    pi = spi;
250                    ti = sti;
251                } else {
252                    return false;
253                }
254            } else if let Some((spi, sti)) = star {
255                // Malformed class: backtrack
256                let sti = sti + 1;
257                star = Some((spi, sti));
258                pi = spi;
259                ti = sti;
260            } else {
261                return false;
262            }
263        } else if pi < pat.len() && pat[pi] == txt[ti] {
264            pi += 1;
265            ti += 1;
266        } else if let Some((spi, sti)) = star {
267            let sti = sti + 1;
268            star = Some((spi, sti));
269            pi = spi;
270            ti = sti;
271        } else {
272            return false;
273        }
274    }
275
276    while pi < pat.len() && pat[pi] == '*' {
277        pi += 1;
278    }
279    pi == pat.len()
280}
281
282/// Parse and match a `[...]` character class starting at `pat[start]`.
283/// Returns `Some((matched, end_index))` where `end_index` is past `]`.
284/// Returns `None` if no closing `]` is found.
285fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
286    let mut i = start + 1;
287    if i >= pat.len() {
288        return None;
289    }
290
291    let negate = pat[i] == '!' || pat[i] == '^';
292    if negate {
293        i += 1;
294    }
295
296    let mut matched = false;
297    while i < pat.len() && pat[i] != ']' {
298        if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
299            let lo = pat[i];
300            let hi = pat[i + 2];
301            if ch >= lo && ch <= hi {
302                matched = true;
303            }
304            i += 3;
305        } else {
306            matched |= pat[i] == ch;
307            i += 1;
308        }
309    }
310
311    if i >= pat.len() {
312        return None;
313    }
314
315    let result = if negate { !matched } else { matched };
316    Some((result, i + 1))
317}
318
319/// Check whether a `Host` pattern matches a given alias.
320/// OpenSSH `Host` keyword matches only against the target alias typed on the
321/// command line, never against the resolved HostName.
322pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
323    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
324    if patterns.is_empty() {
325        return false;
326    }
327
328    let mut any_positive_match = false;
329    for pat in &patterns {
330        if let Some(neg) = pat.strip_prefix('!') {
331            if match_glob(neg, alias) {
332                return false;
333            }
334        } else if ssh_pattern_match(pat, alias) {
335            any_positive_match = true;
336        }
337    }
338
339    any_positive_match
340}
341
342/// Returns true if any hop in a (possibly comma-separated) ProxyJump value
343/// matches the given alias. Strips optional `user@` prefix and `:port`
344/// suffix from each hop before comparing. Handles IPv6 bracket notation
345/// `[addr]:port`. Used to detect self-referencing loops.
346pub fn proxy_jump_contains_self(proxy_jump: &str, alias: &str) -> bool {
347    proxy_jump.split(',').any(|hop| {
348        let h = hop.trim();
349        // Strip optional user@ prefix (take everything after the first @).
350        let h = h.split_once('@').map_or(h, |(_, host)| host);
351        // Strip optional :port suffix. Handle [IPv6]:port bracket notation.
352        let h = if let Some(bracketed) = h.strip_prefix('[') {
353            bracketed.split_once(']').map_or(h, |(host, _)| host)
354        } else {
355            h.rsplit_once(':').map_or(h, |(host, _)| host)
356        };
357        h == alias
358    })
359}
360
361/// Apply first-match-wins inheritance from a pattern to mutable field refs.
362/// Only fills fields that are still empty. Self-referencing ProxyJump values
363/// are assigned (SSH would do the same) so the UI can warn about the loop.
364fn apply_first_match_fields(
365    proxy_jump: &mut String,
366    user: &mut String,
367    identity_file: &mut String,
368    p: &PatternEntry,
369) {
370    if proxy_jump.is_empty() && !p.proxy_jump.is_empty() {
371        proxy_jump.clone_from(&p.proxy_jump);
372    }
373    if user.is_empty() && !p.user.is_empty() {
374        user.clone_from(&p.user);
375    }
376    if identity_file.is_empty() && !p.identity_file.is_empty() {
377        identity_file.clone_from(&p.identity_file);
378    }
379}
380
381impl HostBlock {
382    /// Index of the first trailing blank line (for inserting content before separators).
383    fn content_end(&self) -> usize {
384        let mut pos = self.directives.len();
385        while pos > 0 {
386            if self.directives[pos - 1].is_non_directive
387                && self.directives[pos - 1].raw_line.trim().is_empty()
388            {
389                pos -= 1;
390            } else {
391                break;
392            }
393        }
394        pos
395    }
396
397    /// Remove and return trailing blank lines.
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    fn ensure_trailing_blank(&mut self) {
405        self.pop_trailing_blanks();
406        self.directives.push(Directive {
407            key: String::new(),
408            value: String::new(),
409            raw_line: String::new(),
410            is_non_directive: true,
411        });
412    }
413
414    /// Detect indentation used by existing directives (falls back to "  ").
415    fn detect_indent(&self) -> String {
416        for d in &self.directives {
417            if !d.is_non_directive && !d.raw_line.is_empty() {
418                let trimmed = d.raw_line.trim_start();
419                let indent_len = d.raw_line.len() - trimmed.len();
420                if indent_len > 0 {
421                    return d.raw_line[..indent_len].to_string();
422                }
423            }
424        }
425        "  ".to_string()
426    }
427
428    /// Extract tags from purple:tags comment in directives.
429    pub fn tags(&self) -> Vec<String> {
430        for d in &self.directives {
431            if d.is_non_directive {
432                let trimmed = d.raw_line.trim();
433                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
434                    return rest
435                        .split(',')
436                        .map(|t| t.trim().to_string())
437                        .filter(|t| !t.is_empty())
438                        .collect();
439                }
440            }
441        }
442        Vec::new()
443    }
444
445    /// Extract provider-synced tags from purple:provider_tags comment.
446    pub fn provider_tags(&self) -> Vec<String> {
447        for d in &self.directives {
448            if d.is_non_directive {
449                let trimmed = d.raw_line.trim();
450                if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
451                    return rest
452                        .split(',')
453                        .map(|t| t.trim().to_string())
454                        .filter(|t| !t.is_empty())
455                        .collect();
456                }
457            }
458        }
459        Vec::new()
460    }
461
462    /// Check if a purple:provider_tags comment exists (even if empty).
463    /// Used to distinguish "never migrated" from "migrated with no tags".
464    pub fn has_provider_tags_comment(&self) -> bool {
465        self.directives.iter().any(|d| {
466            d.is_non_directive && {
467                let t = d.raw_line.trim();
468                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
469            }
470        })
471    }
472
473    /// Extract provider info from purple:provider comment in directives.
474    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
475    pub fn provider(&self) -> Option<(String, String)> {
476        for d in &self.directives {
477            if d.is_non_directive {
478                let trimmed = d.raw_line.trim();
479                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
480                    if let Some((name, id)) = rest.split_once(':') {
481                        return Some((name.trim().to_string(), id.trim().to_string()));
482                    }
483                }
484            }
485        }
486        None
487    }
488
489    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
490    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
491        let indent = self.detect_indent();
492        self.directives.retain(|d| {
493            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
494        });
495        let pos = self.content_end();
496        self.directives.insert(
497            pos,
498            Directive {
499                key: String::new(),
500                value: String::new(),
501                raw_line: format!(
502                    "{}# purple:provider {}:{}",
503                    indent, provider_name, server_id
504                ),
505                is_non_directive: true,
506            },
507        );
508    }
509
510    /// Extract askpass source from purple:askpass comment in directives.
511    pub fn askpass(&self) -> Option<String> {
512        for d in &self.directives {
513            if d.is_non_directive {
514                let trimmed = d.raw_line.trim();
515                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
516                    let val = rest.trim();
517                    if !val.is_empty() {
518                        return Some(val.to_string());
519                    }
520                }
521            }
522        }
523        None
524    }
525
526    /// Extract vault-ssh role from purple:vault-ssh comment.
527    pub fn vault_ssh(&self) -> Option<String> {
528        for d in &self.directives {
529            if d.is_non_directive {
530                let trimmed = d.raw_line.trim();
531                if let Some(rest) = trimmed.strip_prefix("# purple:vault-ssh ") {
532                    let val = rest.trim();
533                    if !val.is_empty() && crate::vault_ssh::is_valid_role(val) {
534                        return Some(val.to_string());
535                    }
536                }
537            }
538        }
539        None
540    }
541
542    /// Set vault-ssh role. Replaces existing comment or adds one. Empty string removes.
543    pub fn set_vault_ssh(&mut self, role: &str) {
544        let indent = self.detect_indent();
545        self.directives.retain(|d| {
546            !(d.is_non_directive && {
547                let t = d.raw_line.trim();
548                t == "# purple:vault-ssh" || t.starts_with("# purple:vault-ssh ")
549            })
550        });
551        if !role.is_empty() {
552            let pos = self.content_end();
553            self.directives.insert(
554                pos,
555                Directive {
556                    key: String::new(),
557                    value: String::new(),
558                    raw_line: format!("{}# purple:vault-ssh {}", indent, role),
559                    is_non_directive: true,
560                },
561            );
562        }
563    }
564
565    /// Extract the Vault SSH endpoint from a `# purple:vault-addr` comment.
566    /// Returns None when the comment is absent, blank or contains an invalid
567    /// URL value. Validation is intentionally minimal: we reject empty,
568    /// whitespace-containing and control-character values but otherwise let
569    /// the Vault CLI surface its own error on typos.
570    pub fn vault_addr(&self) -> Option<String> {
571        for d in &self.directives {
572            if d.is_non_directive {
573                let trimmed = d.raw_line.trim();
574                if let Some(rest) = trimmed.strip_prefix("# purple:vault-addr ") {
575                    let val = rest.trim();
576                    if !val.is_empty() && crate::vault_ssh::is_valid_vault_addr(val) {
577                        return Some(val.to_string());
578                    }
579                }
580            }
581        }
582        None
583    }
584
585    /// Set vault-addr endpoint. Replaces existing comment or adds one. Empty
586    /// string removes. Caller is expected to have validated the URL upstream
587    /// (e.g. via `is_valid_vault_addr`) — this function does not re-validate.
588    pub fn set_vault_addr(&mut self, url: &str) {
589        let indent = self.detect_indent();
590        self.directives.retain(|d| {
591            !(d.is_non_directive && {
592                let t = d.raw_line.trim();
593                t == "# purple:vault-addr" || t.starts_with("# purple:vault-addr ")
594            })
595        });
596        if !url.is_empty() {
597            let pos = self.content_end();
598            self.directives.insert(
599                pos,
600                Directive {
601                    key: String::new(),
602                    value: String::new(),
603                    raw_line: format!("{}# purple:vault-addr {}", indent, url),
604                    is_non_directive: true,
605                },
606            );
607        }
608    }
609
610    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
611    /// Pass an empty string to remove the comment.
612    pub fn set_askpass(&mut self, source: &str) {
613        let indent = self.detect_indent();
614        self.directives.retain(|d| {
615            !(d.is_non_directive && {
616                let t = d.raw_line.trim();
617                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
618            })
619        });
620        if !source.is_empty() {
621            let pos = self.content_end();
622            self.directives.insert(
623                pos,
624                Directive {
625                    key: String::new(),
626                    value: String::new(),
627                    raw_line: format!("{}# purple:askpass {}", indent, source),
628                    is_non_directive: true,
629                },
630            );
631        }
632    }
633
634    /// Extract provider metadata from purple:meta comment in directives.
635    /// Format: `# purple:meta key=value,key=value`
636    pub fn meta(&self) -> Vec<(String, String)> {
637        for d in &self.directives {
638            if d.is_non_directive {
639                let trimmed = d.raw_line.trim();
640                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
641                    return rest
642                        .split(',')
643                        .filter_map(|pair| {
644                            let (k, v) = pair.split_once('=')?;
645                            let k = k.trim();
646                            let v = v.trim();
647                            if k.is_empty() {
648                                None
649                            } else {
650                                Some((k.to_string(), v.to_string()))
651                            }
652                        })
653                        .collect();
654                }
655            }
656        }
657        Vec::new()
658    }
659
660    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
661    /// Pass an empty slice to remove the comment.
662    pub fn set_meta(&mut self, meta: &[(String, String)]) {
663        let indent = self.detect_indent();
664        self.directives.retain(|d| {
665            !(d.is_non_directive && {
666                let t = d.raw_line.trim();
667                t == "# purple:meta" || t.starts_with("# purple:meta ")
668            })
669        });
670        if !meta.is_empty() {
671            let encoded: Vec<String> = meta
672                .iter()
673                .map(|(k, v)| {
674                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
675                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
676                    format!("{}={}", clean_k, clean_v)
677                })
678                .collect();
679            let pos = self.content_end();
680            self.directives.insert(
681                pos,
682                Directive {
683                    key: String::new(),
684                    value: String::new(),
685                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
686                    is_non_directive: true,
687                },
688            );
689        }
690    }
691
692    /// Extract stale timestamp from purple:stale comment in directives.
693    /// Returns `None` if absent or malformed.
694    pub fn stale(&self) -> Option<u64> {
695        for d in &self.directives {
696            if d.is_non_directive {
697                let trimmed = d.raw_line.trim();
698                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
699                    return rest.trim().parse::<u64>().ok();
700                }
701            }
702        }
703        None
704    }
705
706    /// Mark a host block as stale with a unix timestamp.
707    /// Replaces existing purple:stale comment or adds one.
708    pub fn set_stale(&mut self, timestamp: u64) {
709        let indent = self.detect_indent();
710        self.clear_stale();
711        let pos = self.content_end();
712        self.directives.insert(
713            pos,
714            Directive {
715                key: String::new(),
716                value: String::new(),
717                raw_line: format!("{}# purple:stale {}", indent, timestamp),
718                is_non_directive: true,
719            },
720        );
721    }
722
723    /// Remove stale marking from a host block.
724    pub fn clear_stale(&mut self) {
725        self.directives.retain(|d| {
726            !(d.is_non_directive && {
727                let t = d.raw_line.trim();
728                t == "# purple:stale" || t.starts_with("# purple:stale ")
729            })
730        });
731    }
732
733    /// Sanitize a tag value: strip control characters, commas (delimiter),
734    /// and Unicode format/bidi override characters. Truncate to 128 chars.
735    fn sanitize_tag(tag: &str) -> String {
736        tag.chars()
737            .filter(|c| {
738                !c.is_control()
739                    && *c != ','
740                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
741                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
742                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
743                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
744            })
745            .take(128)
746            .collect()
747    }
748
749    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
750    pub fn set_tags(&mut self, tags: &[String]) {
751        let indent = self.detect_indent();
752        self.directives.retain(|d| {
753            !(d.is_non_directive && {
754                let t = d.raw_line.trim();
755                t == "# purple:tags" || t.starts_with("# purple:tags ")
756            })
757        });
758        let sanitized: Vec<String> = tags
759            .iter()
760            .map(|t| Self::sanitize_tag(t))
761            .filter(|t| !t.is_empty())
762            .collect();
763        if !sanitized.is_empty() {
764            let pos = self.content_end();
765            self.directives.insert(
766                pos,
767                Directive {
768                    key: String::new(),
769                    value: String::new(),
770                    raw_line: format!("{}# purple:tags {}", indent, sanitized.join(",")),
771                    is_non_directive: true,
772                },
773            );
774        }
775    }
776
777    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
778    /// Always writes the comment (even when empty) as a migration sentinel.
779    pub fn set_provider_tags(&mut self, tags: &[String]) {
780        let indent = self.detect_indent();
781        self.directives.retain(|d| {
782            !(d.is_non_directive && {
783                let t = d.raw_line.trim();
784                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
785            })
786        });
787        let sanitized: Vec<String> = tags
788            .iter()
789            .map(|t| Self::sanitize_tag(t))
790            .filter(|t| !t.is_empty())
791            .collect();
792        let raw = if sanitized.is_empty() {
793            format!("{}# purple:provider_tags", indent)
794        } else {
795            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
796        };
797        let pos = self.content_end();
798        self.directives.insert(
799            pos,
800            Directive {
801                key: String::new(),
802                value: String::new(),
803                raw_line: raw,
804                is_non_directive: true,
805            },
806        );
807    }
808
809    /// Extract a convenience HostEntry view from this block.
810    pub fn to_host_entry(&self) -> HostEntry {
811        let mut entry = HostEntry {
812            alias: self.host_pattern.clone(),
813            port: 22,
814            ..Default::default()
815        };
816        for d in &self.directives {
817            if d.is_non_directive {
818                continue;
819            }
820            if d.key.eq_ignore_ascii_case("hostname") {
821                entry.hostname = d.value.clone();
822            } else if d.key.eq_ignore_ascii_case("user") {
823                entry.user = d.value.clone();
824            } else if d.key.eq_ignore_ascii_case("port") {
825                entry.port = d.value.parse().unwrap_or(22);
826            } else if d.key.eq_ignore_ascii_case("identityfile") {
827                if entry.identity_file.is_empty() {
828                    entry.identity_file = d.value.clone();
829                }
830            } else if d.key.eq_ignore_ascii_case("proxyjump") {
831                entry.proxy_jump = d.value.clone();
832            } else if d.key.eq_ignore_ascii_case("certificatefile")
833                && entry.certificate_file.is_empty()
834            {
835                entry.certificate_file = d.value.clone();
836            }
837        }
838        entry.tags = self.tags();
839        entry.provider_tags = self.provider_tags();
840        entry.has_provider_tags = self.has_provider_tags_comment();
841        entry.provider = self.provider().map(|(name, _)| name);
842        entry.tunnel_count = self.tunnel_count();
843        entry.askpass = self.askpass();
844        entry.vault_ssh = self.vault_ssh();
845        entry.vault_addr = self.vault_addr();
846        entry.provider_meta = self.meta();
847        entry.stale = self.stale();
848        entry
849    }
850
851    /// Extract a convenience PatternEntry view from this block.
852    pub fn to_pattern_entry(&self) -> PatternEntry {
853        let mut entry = PatternEntry {
854            pattern: self.host_pattern.clone(),
855            hostname: String::new(),
856            user: String::new(),
857            port: 22,
858            identity_file: String::new(),
859            proxy_jump: String::new(),
860            tags: self.tags(),
861            askpass: self.askpass(),
862            source_file: None,
863            directives: Vec::new(),
864        };
865        for d in &self.directives {
866            if d.is_non_directive {
867                continue;
868            }
869            match d.key.to_ascii_lowercase().as_str() {
870                "hostname" => entry.hostname = d.value.clone(),
871                "user" => entry.user = d.value.clone(),
872                "port" => entry.port = d.value.parse().unwrap_or(22),
873                "identityfile" => {
874                    if entry.identity_file.is_empty() {
875                        entry.identity_file = d.value.clone();
876                    }
877                }
878                "proxyjump" => entry.proxy_jump = d.value.clone(),
879                _ => {}
880            }
881            entry.directives.push((d.key.clone(), d.value.clone()));
882        }
883        entry
884    }
885
886    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
887    pub fn tunnel_count(&self) -> u16 {
888        let count = self
889            .directives
890            .iter()
891            .filter(|d| {
892                !d.is_non_directive
893                    && (d.key.eq_ignore_ascii_case("localforward")
894                        || d.key.eq_ignore_ascii_case("remoteforward")
895                        || d.key.eq_ignore_ascii_case("dynamicforward"))
896            })
897            .count();
898        count.min(u16::MAX as usize) as u16
899    }
900
901    /// Check if this block has any tunnel forwarding directives.
902    #[allow(dead_code)]
903    pub fn has_tunnels(&self) -> bool {
904        self.directives.iter().any(|d| {
905            !d.is_non_directive
906                && (d.key.eq_ignore_ascii_case("localforward")
907                    || d.key.eq_ignore_ascii_case("remoteforward")
908                    || d.key.eq_ignore_ascii_case("dynamicforward"))
909        })
910    }
911
912    /// Extract tunnel rules from forwarding directives.
913    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
914        self.directives
915            .iter()
916            .filter(|d| !d.is_non_directive)
917            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
918            .collect()
919    }
920}
921
922impl SshConfigFile {
923    /// Get all host entries as convenience views (including from Include files).
924    /// Pattern-inherited directives (ProxyJump, User, IdentityFile) are merged
925    /// using SSH-faithful alias-only matching so indicators like ↗ reflect what
926    /// SSH will actually apply when connecting via `ssh <alias>`.
927    pub fn host_entries(&self) -> Vec<HostEntry> {
928        let mut entries = Vec::new();
929        Self::collect_host_entries(&self.elements, &mut entries);
930        self.apply_pattern_inheritance(&mut entries);
931        entries
932    }
933
934    /// Get a single host entry by alias without pattern inheritance applied.
935    /// Returns the raw directives from the host's own block only. Used by the
936    /// edit form so inherited values can be shown as dimmed placeholders.
937    pub fn raw_host_entry(&self, alias: &str) -> Option<HostEntry> {
938        Self::find_raw_host_entry(&self.elements, alias)
939    }
940
941    fn find_raw_host_entry(elements: &[ConfigElement], alias: &str) -> Option<HostEntry> {
942        for e in elements {
943            match e {
944                ConfigElement::HostBlock(block)
945                    if !is_host_pattern(&block.host_pattern) && block.host_pattern == alias =>
946                {
947                    return Some(block.to_host_entry());
948                }
949                ConfigElement::Include(inc) => {
950                    for file in &inc.resolved_files {
951                        if let Some(mut found) = Self::find_raw_host_entry(&file.elements, alias) {
952                            if found.source_file.is_none() {
953                                found.source_file = Some(file.path.clone());
954                            }
955                            return Some(found);
956                        }
957                    }
958                }
959                _ => {}
960            }
961        }
962        None
963    }
964
965    /// Apply SSH first-match-wins pattern inheritance to host entries.
966    /// Matches patterns against the alias only (SSH-faithful: `Host` patterns
967    /// match the token typed on the command line, not the resolved `Hostname`).
968    fn apply_pattern_inheritance(&self, entries: &mut [HostEntry]) {
969        // Patterns are pre-collected once. Host entries never contain pattern
970        // aliases — collect_host_entries skips is_host_pattern blocks.
971        let all_patterns = self.pattern_entries();
972        for entry in entries.iter_mut() {
973            if !entry.proxy_jump.is_empty()
974                && !entry.user.is_empty()
975                && !entry.identity_file.is_empty()
976            {
977                continue;
978            }
979            for p in &all_patterns {
980                if !host_pattern_matches(&p.pattern, &entry.alias) {
981                    continue;
982                }
983                apply_first_match_fields(
984                    &mut entry.proxy_jump,
985                    &mut entry.user,
986                    &mut entry.identity_file,
987                    p,
988                );
989                if !entry.proxy_jump.is_empty()
990                    && !entry.user.is_empty()
991                    && !entry.identity_file.is_empty()
992                {
993                    break;
994                }
995            }
996        }
997    }
998
999    /// Compute pattern-provided field hints for a host alias. Returns first-match
1000    /// values and their source patterns for ProxyJump, User and IdentityFile.
1001    /// These are returned regardless of whether the host has its own values for
1002    /// those fields. The caller (form rendering) decides visibility based on
1003    /// whether the field is empty. Matches by alias only (SSH-faithful).
1004    pub fn inherited_hints(&self, alias: &str) -> InheritedHints {
1005        let patterns = self.matching_patterns(alias);
1006        let mut hints = InheritedHints::default();
1007        for p in &patterns {
1008            if hints.proxy_jump.is_none() && !p.proxy_jump.is_empty() {
1009                hints.proxy_jump = Some((p.proxy_jump.clone(), p.pattern.clone()));
1010            }
1011            if hints.user.is_none() && !p.user.is_empty() {
1012                hints.user = Some((p.user.clone(), p.pattern.clone()));
1013            }
1014            if hints.identity_file.is_none() && !p.identity_file.is_empty() {
1015                hints.identity_file = Some((p.identity_file.clone(), p.pattern.clone()));
1016            }
1017            if hints.proxy_jump.is_some() && hints.user.is_some() && hints.identity_file.is_some() {
1018                break;
1019            }
1020        }
1021        hints
1022    }
1023
1024    /// Get all pattern entries as convenience views (including from Include files).
1025    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
1026        let mut entries = Vec::new();
1027        Self::collect_pattern_entries(&self.elements, &mut entries);
1028        entries
1029    }
1030
1031    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
1032        for e in elements {
1033            match e {
1034                ConfigElement::HostBlock(block) => {
1035                    if !is_host_pattern(&block.host_pattern) {
1036                        continue;
1037                    }
1038                    entries.push(block.to_pattern_entry());
1039                }
1040                ConfigElement::Include(include) => {
1041                    for file in &include.resolved_files {
1042                        let start = entries.len();
1043                        Self::collect_pattern_entries(&file.elements, entries);
1044                        for entry in &mut entries[start..] {
1045                            if entry.source_file.is_none() {
1046                                entry.source_file = Some(file.path.clone());
1047                            }
1048                        }
1049                    }
1050                }
1051                ConfigElement::GlobalLine(_) => {}
1052            }
1053        }
1054    }
1055
1056    /// Find all pattern blocks that match a given host alias and hostname.
1057    /// Returns entries in config order (first match first).
1058    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
1059        let mut matches = Vec::new();
1060        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
1061        matches
1062    }
1063
1064    fn collect_matching_patterns(
1065        elements: &[ConfigElement],
1066        alias: &str,
1067        matches: &mut Vec<PatternEntry>,
1068    ) {
1069        for e in elements {
1070            match e {
1071                ConfigElement::HostBlock(block) => {
1072                    if !is_host_pattern(&block.host_pattern) {
1073                        continue;
1074                    }
1075                    if host_pattern_matches(&block.host_pattern, alias) {
1076                        matches.push(block.to_pattern_entry());
1077                    }
1078                }
1079                ConfigElement::Include(include) => {
1080                    for file in &include.resolved_files {
1081                        let start = matches.len();
1082                        Self::collect_matching_patterns(&file.elements, alias, matches);
1083                        for entry in &mut matches[start..] {
1084                            if entry.source_file.is_none() {
1085                                entry.source_file = Some(file.path.clone());
1086                            }
1087                        }
1088                    }
1089                }
1090                ConfigElement::GlobalLine(_) => {}
1091            }
1092        }
1093    }
1094
1095    /// Collect all resolved Include file paths (recursively).
1096    pub fn include_paths(&self) -> Vec<PathBuf> {
1097        let mut paths = Vec::new();
1098        Self::collect_include_paths(&self.elements, &mut paths);
1099        paths
1100    }
1101
1102    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
1103        for e in elements {
1104            if let ConfigElement::Include(include) = e {
1105                for file in &include.resolved_files {
1106                    paths.push(file.path.clone());
1107                    Self::collect_include_paths(&file.elements, paths);
1108                }
1109            }
1110        }
1111    }
1112
1113    /// Collect parent directories of Include glob patterns.
1114    /// When a file is added/removed under a glob dir, the directory's mtime changes.
1115    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
1116        let config_dir = self.path.parent();
1117        let mut seen = std::collections::HashSet::new();
1118        let mut dirs = Vec::new();
1119        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
1120        dirs
1121    }
1122
1123    fn collect_include_glob_dirs(
1124        elements: &[ConfigElement],
1125        config_dir: Option<&std::path::Path>,
1126        seen: &mut std::collections::HashSet<PathBuf>,
1127        dirs: &mut Vec<PathBuf>,
1128    ) {
1129        for e in elements {
1130            if let ConfigElement::Include(include) = e {
1131                // Split respecting quoted paths (same as resolve_include does)
1132                for single in Self::split_include_patterns(&include.pattern) {
1133                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
1134                    let resolved = if expanded.starts_with('/') {
1135                        PathBuf::from(&expanded)
1136                    } else if let Some(dir) = config_dir {
1137                        dir.join(&expanded)
1138                    } else {
1139                        continue;
1140                    };
1141                    if let Some(parent) = resolved.parent() {
1142                        let parent = parent.to_path_buf();
1143                        if seen.insert(parent.clone()) {
1144                            dirs.push(parent);
1145                        }
1146                    }
1147                }
1148                // Recurse into resolved files
1149                for file in &include.resolved_files {
1150                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
1151                }
1152            }
1153        }
1154    }
1155
1156    /// Remove `# purple:group <Name>` headers that have no corresponding
1157    /// provider hosts. Returns the number of headers removed.
1158    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
1159        // Collect all provider display names that have at least one host.
1160        let active_providers: std::collections::HashSet<String> = self
1161            .elements
1162            .iter()
1163            .filter_map(|e| {
1164                if let ConfigElement::HostBlock(block) = e {
1165                    block
1166                        .provider()
1167                        .map(|(name, _)| provider_group_display_name(&name).to_string())
1168                } else {
1169                    None
1170                }
1171            })
1172            .collect();
1173
1174        let mut removed = 0;
1175        self.elements.retain(|e| {
1176            if let ConfigElement::GlobalLine(line) = e {
1177                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
1178                    if !active_providers.contains(rest.trim()) {
1179                        removed += 1;
1180                        return false;
1181                    }
1182                }
1183            }
1184            true
1185        });
1186        removed
1187    }
1188
1189    /// Repair configs where `# purple:group` comments were absorbed into the
1190    /// preceding host block's directives instead of being stored as GlobalLines.
1191    /// Returns the number of blocks that were repaired.
1192    pub fn repair_absorbed_group_comments(&mut self) -> usize {
1193        let mut repaired = 0;
1194        let mut idx = 0;
1195        while idx < self.elements.len() {
1196            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
1197                block
1198                    .directives
1199                    .iter()
1200                    .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
1201            } else {
1202                false
1203            };
1204
1205            if !needs_repair {
1206                idx += 1;
1207                continue;
1208            }
1209
1210            // Find the index of the first absorbed group comment in this block's directives.
1211            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
1212                block
1213            } else {
1214                unreachable!()
1215            };
1216
1217            let group_idx = block
1218                .directives
1219                .iter()
1220                .position(|d| {
1221                    d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
1222                })
1223                .unwrap();
1224
1225            // Find where trailing blanks before the group comment start.
1226            let mut keep_end = group_idx;
1227            while keep_end > 0
1228                && block.directives[keep_end - 1].is_non_directive
1229                && block.directives[keep_end - 1].raw_line.trim().is_empty()
1230            {
1231                keep_end -= 1;
1232            }
1233
1234            // Collect everything from keep_end onward as GlobalLines.
1235            let extracted: Vec<ConfigElement> = block
1236                .directives
1237                .drain(keep_end..)
1238                .map(|d| ConfigElement::GlobalLine(d.raw_line))
1239                .collect();
1240
1241            // Insert extracted GlobalLines right after this HostBlock.
1242            let insert_at = idx + 1;
1243            for (i, elem) in extracted.into_iter().enumerate() {
1244                self.elements.insert(insert_at + i, elem);
1245            }
1246
1247            repaired += 1;
1248            // Advance past the inserted elements.
1249            idx = insert_at;
1250            // Skip the inserted elements to continue scanning.
1251            while idx < self.elements.len() {
1252                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1253                    break;
1254                }
1255                idx += 1;
1256            }
1257        }
1258        repaired
1259    }
1260
1261    /// Recursively collect host entries from a list of elements.
1262    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1263        for e in elements {
1264            match e {
1265                ConfigElement::HostBlock(block) => {
1266                    if is_host_pattern(&block.host_pattern) {
1267                        continue;
1268                    }
1269                    entries.push(block.to_host_entry());
1270                }
1271                ConfigElement::Include(include) => {
1272                    for file in &include.resolved_files {
1273                        let start = entries.len();
1274                        Self::collect_host_entries(&file.elements, entries);
1275                        for entry in &mut entries[start..] {
1276                            if entry.source_file.is_none() {
1277                                entry.source_file = Some(file.path.clone());
1278                            }
1279                        }
1280                    }
1281                }
1282                ConfigElement::GlobalLine(_) => {}
1283            }
1284        }
1285    }
1286
1287    /// Check if a host alias already exists (including in Include files).
1288    /// Walks the element tree directly without building HostEntry structs.
1289    pub fn has_host(&self, alias: &str) -> bool {
1290        Self::has_host_in_elements(&self.elements, alias)
1291    }
1292
1293    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1294        for e in elements {
1295            match e {
1296                ConfigElement::HostBlock(block) => {
1297                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1298                        return true;
1299                    }
1300                }
1301                ConfigElement::Include(include) => {
1302                    for file in &include.resolved_files {
1303                        if Self::has_host_in_elements(&file.elements, alias) {
1304                            return true;
1305                        }
1306                    }
1307                }
1308                ConfigElement::GlobalLine(_) => {}
1309            }
1310        }
1311        false
1312    }
1313
1314    /// Check if a host block with exactly this host_pattern exists (top-level only).
1315    /// Unlike `has_host` which splits multi-host patterns and checks individual parts,
1316    /// this matches the full `Host` line pattern string (e.g. "web-* db-*").
1317    /// Does not search Include files (patterns from includes are read-only).
1318    pub fn has_host_block(&self, pattern: &str) -> bool {
1319        self.elements
1320            .iter()
1321            .any(|e| matches!(e, ConfigElement::HostBlock(block) if block.host_pattern == pattern))
1322    }
1323
1324    /// Check if a host alias is from an included file (read-only).
1325    /// Handles multi-pattern Host lines by splitting on whitespace.
1326    pub fn is_included_host(&self, alias: &str) -> bool {
1327        // Not in top-level elements → must be in an Include
1328        for e in &self.elements {
1329            match e {
1330                ConfigElement::HostBlock(block) => {
1331                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1332                        return false;
1333                    }
1334                }
1335                ConfigElement::Include(include) => {
1336                    for file in &include.resolved_files {
1337                        if Self::has_host_in_elements(&file.elements, alias) {
1338                            return true;
1339                        }
1340                    }
1341                }
1342                ConfigElement::GlobalLine(_) => {}
1343            }
1344        }
1345        false
1346    }
1347
1348    /// Add a new host entry to the config.
1349    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
1350    /// so that SSH "first match wins" semantics are preserved. If wildcards are
1351    /// only at the top of the file (acting as global defaults), appends at end.
1352    pub fn add_host(&mut self, entry: &HostEntry) {
1353        let block = Self::entry_to_block(entry);
1354        let insert_pos = self.find_trailing_pattern_start();
1355
1356        if let Some(pos) = insert_pos {
1357            // Insert before the trailing pattern group, with blank separators
1358            let needs_blank_before = pos > 0
1359                && !matches!(
1360                    self.elements.get(pos - 1),
1361                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1362                );
1363            let mut idx = pos;
1364            if needs_blank_before {
1365                self.elements
1366                    .insert(idx, ConfigElement::GlobalLine(String::new()));
1367                idx += 1;
1368            }
1369            self.elements.insert(idx, ConfigElement::HostBlock(block));
1370            // Ensure a blank separator after the new block (before the wildcard group)
1371            let after = idx + 1;
1372            if after < self.elements.len()
1373                && !matches!(
1374                    self.elements.get(after),
1375                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1376                )
1377            {
1378                self.elements
1379                    .insert(after, ConfigElement::GlobalLine(String::new()));
1380            }
1381        } else {
1382            // No trailing patterns: append at end
1383            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1384                self.elements.push(ConfigElement::GlobalLine(String::new()));
1385            }
1386            self.elements.push(ConfigElement::HostBlock(block));
1387        }
1388    }
1389
1390    /// Find the start of a trailing group of wildcard/pattern Host blocks.
1391    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
1392    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
1393    /// i.e. patterns start at position 0 — in that case we append at end).
1394    fn find_trailing_pattern_start(&self) -> Option<usize> {
1395        let mut first_pattern_pos = None;
1396        for i in (0..self.elements.len()).rev() {
1397            match &self.elements[i] {
1398                ConfigElement::HostBlock(block) => {
1399                    if is_host_pattern(&block.host_pattern) {
1400                        first_pattern_pos = Some(i);
1401                    } else {
1402                        // Found a concrete host: the trailing group starts after this
1403                        break;
1404                    }
1405                }
1406                ConfigElement::GlobalLine(_) => {
1407                    // Blank lines, comments, Match blocks between patterns: keep scanning
1408                    if first_pattern_pos.is_some() {
1409                        first_pattern_pos = Some(i);
1410                    }
1411                }
1412                ConfigElement::Include(_) => break,
1413            }
1414        }
1415        // Don't return position 0 — that means everything is patterns (or patterns at top)
1416        first_pattern_pos.filter(|&pos| pos > 0)
1417    }
1418
1419    /// Check if the last element already ends with a blank line.
1420    pub fn last_element_has_trailing_blank(&self) -> bool {
1421        match self.elements.last() {
1422            Some(ConfigElement::HostBlock(block)) => block
1423                .directives
1424                .last()
1425                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1426            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1427            _ => false,
1428        }
1429    }
1430
1431    /// Update an existing host entry by alias.
1432    /// Merges changes into the existing block, preserving unknown directives.
1433    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1434        for element in &mut self.elements {
1435            if let ConfigElement::HostBlock(block) = element {
1436                if block.host_pattern == old_alias {
1437                    // Update host pattern (preserve raw_host_line when alias unchanged)
1438                    if entry.alias != block.host_pattern {
1439                        block.host_pattern = entry.alias.clone();
1440                        block.raw_host_line = format!("Host {}", entry.alias);
1441                    }
1442
1443                    // Merge known directives (update existing, add missing, remove empty)
1444                    Self::upsert_directive(block, "HostName", &entry.hostname);
1445                    Self::upsert_directive(block, "User", &entry.user);
1446                    if entry.port != 22 {
1447                        Self::upsert_directive(block, "Port", &entry.port.to_string());
1448                    } else {
1449                        // Remove explicit Port 22 (it's the default)
1450                        block
1451                            .directives
1452                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1453                    }
1454                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1455                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1456                    return;
1457                }
1458            }
1459        }
1460    }
1461
1462    /// Update a directive in-place, add it if missing, or remove it if value is empty.
1463    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1464        if value.is_empty() {
1465            block
1466                .directives
1467                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1468            return;
1469        }
1470        let indent = block.detect_indent();
1471        for d in &mut block.directives {
1472            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1473                // Only rebuild raw_line when value actually changed (preserves inline comments)
1474                if d.value != value {
1475                    d.value = value.to_string();
1476                    // Detect separator style from original raw_line and preserve it.
1477                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
1478                    // Only considers '=' as separator if it appears before any
1479                    // non-whitespace content (avoids matching '=' inside values
1480                    // like "IdentityFile ~/.ssh/id=prod").
1481                    let trimmed = d.raw_line.trim_start();
1482                    let after_key = &trimmed[d.key.len()..];
1483                    let sep = if after_key.trim_start().starts_with('=') {
1484                        let eq_pos = after_key.find('=').unwrap();
1485                        let after_eq = &after_key[eq_pos + 1..];
1486                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1487                        after_key[..eq_pos + 1 + trailing_ws].to_string()
1488                    } else {
1489                        " ".to_string()
1490                    };
1491                    // Preserve inline comment from original raw_line (e.g. "# production")
1492                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1493                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1494                }
1495                return;
1496            }
1497        }
1498        // Not found — insert before trailing blanks
1499        let pos = block.content_end();
1500        block.directives.insert(
1501            pos,
1502            Directive {
1503                key: key.to_string(),
1504                value: value.to_string(),
1505                raw_line: format!("{}{} {}", indent, key, value),
1506                is_non_directive: false,
1507            },
1508        );
1509    }
1510
1511    /// Extract the inline comment suffix from a directive's raw line.
1512    /// Returns the trailing portion (e.g. " # production") or empty string.
1513    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
1514    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1515        let trimmed = raw_line.trim_start();
1516        if trimmed.len() <= key.len() {
1517            return String::new();
1518        }
1519        // Skip past key and separator to reach the value portion
1520        let after_key = &trimmed[key.len()..];
1521        let rest = after_key.trim_start();
1522        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1523        // Scan for inline comment (# preceded by whitespace, outside quotes)
1524        let bytes = rest.as_bytes();
1525        let mut in_quote = false;
1526        for i in 0..bytes.len() {
1527            if bytes[i] == b'"' {
1528                in_quote = !in_quote;
1529            } else if !in_quote
1530                && bytes[i] == b'#'
1531                && i > 0
1532                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1533            {
1534                // Found comment start. The clean value ends before the whitespace preceding #.
1535                let clean_end = rest[..i].trim_end().len();
1536                return rest[clean_end..].to_string();
1537            }
1538        }
1539        String::new()
1540    }
1541
1542    /// Set provider on a host block by alias.
1543    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1544        for element in &mut self.elements {
1545            if let ConfigElement::HostBlock(block) = element {
1546                if block.host_pattern == alias {
1547                    block.set_provider(provider_name, server_id);
1548                    return;
1549                }
1550            }
1551        }
1552    }
1553
1554    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
1555    /// Searches both top-level elements and Include files so that provider hosts
1556    /// in included configs are recognized during sync (prevents duplicate additions).
1557    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1558        let mut results = Vec::new();
1559        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1560        results
1561    }
1562
1563    fn collect_provider_hosts(
1564        elements: &[ConfigElement],
1565        provider_name: &str,
1566        results: &mut Vec<(String, String)>,
1567    ) {
1568        for element in elements {
1569            match element {
1570                ConfigElement::HostBlock(block) => {
1571                    if let Some((name, id)) = block.provider() {
1572                        if name == provider_name {
1573                            results.push((block.host_pattern.clone(), id));
1574                        }
1575                    }
1576                }
1577                ConfigElement::Include(include) => {
1578                    for file in &include.resolved_files {
1579                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1580                    }
1581                }
1582                ConfigElement::GlobalLine(_) => {}
1583            }
1584        }
1585    }
1586
1587    /// Compare two directive values with whitespace normalization.
1588    /// Handles hand-edited configs with tabs or multiple spaces.
1589    fn values_match(a: &str, b: &str) -> bool {
1590        a.split_whitespace().eq(b.split_whitespace())
1591    }
1592
1593    /// Add a forwarding directive to a host block.
1594    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1595    /// Uses split_whitespace matching for multi-pattern Host lines.
1596    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1597        for element in &mut self.elements {
1598            if let ConfigElement::HostBlock(block) = element {
1599                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1600                    let indent = block.detect_indent();
1601                    let pos = block.content_end();
1602                    block.directives.insert(
1603                        pos,
1604                        Directive {
1605                            key: directive_key.to_string(),
1606                            value: value.to_string(),
1607                            raw_line: format!("{}{} {}", indent, directive_key, value),
1608                            is_non_directive: false,
1609                        },
1610                    );
1611                    return;
1612                }
1613            }
1614        }
1615    }
1616
1617    /// Remove a specific forwarding directive from a host block.
1618    /// Matches key (case-insensitive) and value (whitespace-normalized).
1619    /// Uses split_whitespace matching for multi-pattern Host lines.
1620    /// Returns true if a directive was actually removed.
1621    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1622        for element in &mut self.elements {
1623            if let ConfigElement::HostBlock(block) = element {
1624                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1625                    if let Some(pos) = block.directives.iter().position(|d| {
1626                        !d.is_non_directive
1627                            && d.key.eq_ignore_ascii_case(directive_key)
1628                            && Self::values_match(&d.value, value)
1629                    }) {
1630                        block.directives.remove(pos);
1631                        return true;
1632                    }
1633                    return false;
1634                }
1635            }
1636        }
1637        false
1638    }
1639
1640    /// Check if a host block has a specific forwarding directive.
1641    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1642    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1643        for element in &self.elements {
1644            if let ConfigElement::HostBlock(block) = element {
1645                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1646                    return block.directives.iter().any(|d| {
1647                        !d.is_non_directive
1648                            && d.key.eq_ignore_ascii_case(directive_key)
1649                            && Self::values_match(&d.value, value)
1650                    });
1651                }
1652            }
1653        }
1654        false
1655    }
1656
1657    /// Find tunnel directives for a host alias, searching all elements including
1658    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1659    /// Host lines.
1660    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1661        Self::find_tunnel_directives_in(&self.elements, alias)
1662    }
1663
1664    fn find_tunnel_directives_in(
1665        elements: &[ConfigElement],
1666        alias: &str,
1667    ) -> Vec<crate::tunnel::TunnelRule> {
1668        for element in elements {
1669            match element {
1670                ConfigElement::HostBlock(block) => {
1671                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1672                        return block.tunnel_directives();
1673                    }
1674                }
1675                ConfigElement::Include(include) => {
1676                    for file in &include.resolved_files {
1677                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1678                        if !rules.is_empty() {
1679                            return rules;
1680                        }
1681                    }
1682                }
1683                ConfigElement::GlobalLine(_) => {}
1684            }
1685        }
1686        Vec::new()
1687    }
1688
1689    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1690    pub fn deduplicate_alias(&self, base: &str) -> String {
1691        self.deduplicate_alias_excluding(base, None)
1692    }
1693
1694    /// Generate a unique alias, optionally excluding one alias from collision detection.
1695    /// Used during rename so the host being renamed doesn't collide with itself.
1696    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1697        let is_taken = |alias: &str| {
1698            if exclude == Some(alias) {
1699                return false;
1700            }
1701            self.has_host(alias)
1702        };
1703        if !is_taken(base) {
1704            return base.to_string();
1705        }
1706        for n in 2..=9999 {
1707            let candidate = format!("{}-{}", base, n);
1708            if !is_taken(&candidate) {
1709                return candidate;
1710            }
1711        }
1712        // Practically unreachable: fall back to PID-based suffix
1713        format!("{}-{}", base, std::process::id())
1714    }
1715
1716    /// Set tags on a host block by alias.
1717    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1718        for element in &mut self.elements {
1719            if let ConfigElement::HostBlock(block) = element {
1720                if block.host_pattern == alias {
1721                    block.set_tags(tags);
1722                    return;
1723                }
1724            }
1725        }
1726    }
1727
1728    /// Set provider-synced tags on a host block by alias.
1729    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1730        for element in &mut self.elements {
1731            if let ConfigElement::HostBlock(block) = element {
1732                if block.host_pattern == alias {
1733                    block.set_provider_tags(tags);
1734                    return;
1735                }
1736            }
1737        }
1738    }
1739
1740    /// Set askpass source on a host block by alias.
1741    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1742        for element in &mut self.elements {
1743            if let ConfigElement::HostBlock(block) = element {
1744                if block.host_pattern == alias {
1745                    block.set_askpass(source);
1746                    return;
1747                }
1748            }
1749        }
1750    }
1751
1752    /// Set vault-ssh role on a host block by alias.
1753    pub fn set_host_vault_ssh(&mut self, alias: &str, role: &str) {
1754        for element in &mut self.elements {
1755            if let ConfigElement::HostBlock(block) = element {
1756                if block.host_pattern == alias {
1757                    block.set_vault_ssh(role);
1758                    return;
1759                }
1760            }
1761        }
1762    }
1763
1764    /// Set or remove the Vault SSH endpoint comment on a host block by alias.
1765    /// Empty `url` removes the comment.
1766    ///
1767    /// Mirrors the safety invariants of `set_host_certificate_file`: wildcard
1768    /// aliases are refused to avoid accidentally applying a vault address to
1769    /// every host resolved through a pattern, and Match blocks are not
1770    /// touched (they live as inert `GlobalLines`). Returns `true` on a
1771    /// successful mutation, `false` when the alias is invalid or the block
1772    /// is not found.
1773    ///
1774    /// Callers that run asynchronously (e.g. form submit handlers that
1775    /// resolve the alias before writing) MUST check the return value so a
1776    /// silent config mutation failure is surfaced instead of pretending the
1777    /// vault address was wired up.
1778    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1779    pub fn set_host_vault_addr(&mut self, alias: &str, url: &str) -> bool {
1780        // Same guard as `set_host_certificate_file`: refuse empty aliases
1781        // and any SSH pattern shape. `is_host_pattern` already covers
1782        // wildcards, negation and whitespace-separated multi-host forms.
1783        if alias.is_empty() || is_host_pattern(alias) {
1784            return false;
1785        }
1786        for element in &mut self.elements {
1787            if let ConfigElement::HostBlock(block) = element {
1788                if block.host_pattern == alias {
1789                    block.set_vault_addr(url);
1790                    return true;
1791                }
1792            }
1793        }
1794        false
1795    }
1796
1797    /// Set or remove the CertificateFile directive on a host block by alias.
1798    /// Empty path removes the directive.
1799    /// Set the `CertificateFile` directive on the host block that matches
1800    /// `alias` exactly. Returns `true` if a matching block was found and
1801    /// updated, `false` if no top-level `HostBlock` matched (alias was
1802    /// renamed, deleted or lives only inside an `Include`d file).
1803    ///
1804    /// Callers that run asynchronously (e.g. the Vault SSH bulk-sign worker)
1805    /// MUST check the return value so a silent config mutation failure is
1806    /// surfaced to the user instead of pretending the cert was wired up.
1807    #[must_use = "check the return value to detect silently-skipped mutations (renamed or deleted hosts)"]
1808    pub fn set_host_certificate_file(&mut self, alias: &str, path: &str) -> bool {
1809        // Defense in depth: refuse to mutate a host block when the requested
1810        // alias is empty or matches any SSH pattern shape (`*`, `?`, `[`,
1811        // leading `!`, or whitespace-separated multi-host form like
1812        // `Host web-* db-*`). Writing `CertificateFile` onto a pattern
1813        // block is almost never what a user intends and would affect every
1814        // host that resolves through that pattern. Reusing `is_host_pattern`
1815        // keeps this check in sync with the form-level pattern detection.
1816        if alias.is_empty() || is_host_pattern(alias) {
1817            return false;
1818        }
1819        for element in &mut self.elements {
1820            if let ConfigElement::HostBlock(block) = element {
1821                if block.host_pattern == alias {
1822                    Self::upsert_directive(block, "CertificateFile", path);
1823                    return true;
1824                }
1825            }
1826        }
1827        false
1828    }
1829
1830    /// Set provider metadata on a host block by alias.
1831    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1832        for element in &mut self.elements {
1833            if let ConfigElement::HostBlock(block) = element {
1834                if block.host_pattern == alias {
1835                    block.set_meta(meta);
1836                    return;
1837                }
1838            }
1839        }
1840    }
1841
1842    /// Mark a host as stale by alias.
1843    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1844        for element in &mut self.elements {
1845            if let ConfigElement::HostBlock(block) = element {
1846                if block.host_pattern == alias {
1847                    block.set_stale(timestamp);
1848                    return;
1849                }
1850            }
1851        }
1852    }
1853
1854    /// Clear stale marking from a host by alias.
1855    pub fn clear_host_stale(&mut self, alias: &str) {
1856        for element in &mut self.elements {
1857            if let ConfigElement::HostBlock(block) = element {
1858                if block.host_pattern == alias {
1859                    block.clear_stale();
1860                    return;
1861                }
1862            }
1863        }
1864    }
1865
1866    /// Collect all stale hosts with their timestamps.
1867    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1868        let mut result = Vec::new();
1869        for element in &self.elements {
1870            if let ConfigElement::HostBlock(block) = element {
1871                if let Some(ts) = block.stale() {
1872                    result.push((block.host_pattern.clone(), ts));
1873                }
1874            }
1875        }
1876        result
1877    }
1878
1879    /// Delete a host entry by alias.
1880    #[allow(dead_code)]
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    /// Find the position of the `# purple:group <DisplayName>` GlobalLine for a provider.
1927    #[allow(dead_code)]
1928    fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1929        let display = provider_group_display_name(provider_name);
1930        let header = format!("# purple:group {}", display);
1931        self.elements
1932            .iter()
1933            .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1934    }
1935
1936    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
1937    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
1938    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1939        if self.find_hosts_by_provider(provider_name).is_empty() {
1940            let display = provider_group_display_name(provider_name);
1941            let header = format!("# purple:group {}", display);
1942            self.elements
1943                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1944        }
1945    }
1946
1947    /// Insert a host block at a specific position (for undo).
1948    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1949        let pos = position.min(self.elements.len());
1950        self.elements.insert(pos, element);
1951    }
1952
1953    /// Find the position after the last HostBlock that belongs to a provider.
1954    /// Returns `None` if no hosts for this provider exist in the config.
1955    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1956    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1957        let mut last_pos = None;
1958        for (i, element) in self.elements.iter().enumerate() {
1959            if let ConfigElement::HostBlock(block) = element {
1960                if let Some((name, _)) = block.provider() {
1961                    if name == provider_name {
1962                        last_pos = Some(i);
1963                    }
1964                }
1965            }
1966        }
1967        // Return position after the last provider host
1968        last_pos.map(|p| p + 1)
1969    }
1970
1971    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1972    #[allow(dead_code)]
1973    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1974        let pos_a = self
1975            .elements
1976            .iter()
1977            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1978        let pos_b = self
1979            .elements
1980            .iter()
1981            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1982        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1983            if a == b {
1984                return false;
1985            }
1986            let (first, second) = (a.min(b), a.max(b));
1987
1988            // Strip trailing blanks from both blocks before swap
1989            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1990                block.pop_trailing_blanks();
1991            }
1992            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1993                block.pop_trailing_blanks();
1994            }
1995
1996            // Swap
1997            self.elements.swap(first, second);
1998
1999            // Add trailing blank to first block (separator between the two)
2000            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
2001                block.ensure_trailing_blank();
2002            }
2003
2004            // Add trailing blank to second only if not the last element
2005            if second < self.elements.len() - 1 {
2006                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
2007                    block.ensure_trailing_blank();
2008                }
2009            }
2010
2011            return true;
2012        }
2013        false
2014    }
2015
2016    /// Convert a HostEntry into a new HostBlock with clean formatting.
2017    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
2018        // Defense-in-depth: callers must validate before reaching here.
2019        // Newlines in values would inject extra SSH config directives.
2020        debug_assert!(
2021            !entry.alias.contains('\n') && !entry.alias.contains('\r'),
2022            "entry_to_block: alias contains newline"
2023        );
2024        debug_assert!(
2025            !entry.hostname.contains('\n') && !entry.hostname.contains('\r'),
2026            "entry_to_block: hostname contains newline"
2027        );
2028        debug_assert!(
2029            !entry.user.contains('\n') && !entry.user.contains('\r'),
2030            "entry_to_block: user contains newline"
2031        );
2032
2033        let mut directives = Vec::new();
2034
2035        if !entry.hostname.is_empty() {
2036            directives.push(Directive {
2037                key: "HostName".to_string(),
2038                value: entry.hostname.clone(),
2039                raw_line: format!("  HostName {}", entry.hostname),
2040                is_non_directive: false,
2041            });
2042        }
2043        if !entry.user.is_empty() {
2044            directives.push(Directive {
2045                key: "User".to_string(),
2046                value: entry.user.clone(),
2047                raw_line: format!("  User {}", entry.user),
2048                is_non_directive: false,
2049            });
2050        }
2051        if entry.port != 22 {
2052            directives.push(Directive {
2053                key: "Port".to_string(),
2054                value: entry.port.to_string(),
2055                raw_line: format!("  Port {}", entry.port),
2056                is_non_directive: false,
2057            });
2058        }
2059        if !entry.identity_file.is_empty() {
2060            directives.push(Directive {
2061                key: "IdentityFile".to_string(),
2062                value: entry.identity_file.clone(),
2063                raw_line: format!("  IdentityFile {}", entry.identity_file),
2064                is_non_directive: false,
2065            });
2066        }
2067        if !entry.proxy_jump.is_empty() {
2068            directives.push(Directive {
2069                key: "ProxyJump".to_string(),
2070                value: entry.proxy_jump.clone(),
2071                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
2072                is_non_directive: false,
2073            });
2074        }
2075
2076        HostBlock {
2077            host_pattern: entry.alias.clone(),
2078            raw_host_line: format!("Host {}", entry.alias),
2079            directives,
2080        }
2081    }
2082}
2083
2084#[cfg(test)]
2085mod tests {
2086    use super::*;
2087
2088    fn parse_str(content: &str) -> SshConfigFile {
2089        SshConfigFile {
2090            elements: SshConfigFile::parse_content(content),
2091            path: PathBuf::from("/tmp/test_config"),
2092            crlf: false,
2093            bom: false,
2094        }
2095    }
2096
2097    #[test]
2098    fn tunnel_directives_extracts_forwards() {
2099        let config = parse_str(
2100            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
2101        );
2102        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2103            let rules = block.tunnel_directives();
2104            assert_eq!(rules.len(), 3);
2105            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
2106            assert_eq!(rules[0].bind_port, 8080);
2107            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
2108            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
2109        } else {
2110            panic!("Expected HostBlock");
2111        }
2112    }
2113
2114    #[test]
2115    fn tunnel_count_counts_forwards() {
2116        let config = parse_str(
2117            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
2118        );
2119        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2120            assert_eq!(block.tunnel_count(), 2);
2121        } else {
2122            panic!("Expected HostBlock");
2123        }
2124    }
2125
2126    #[test]
2127    fn tunnel_count_zero_for_no_forwards() {
2128        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
2129        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2130            assert_eq!(block.tunnel_count(), 0);
2131            assert!(!block.has_tunnels());
2132        } else {
2133            panic!("Expected HostBlock");
2134        }
2135    }
2136
2137    #[test]
2138    fn has_tunnels_true_with_forward() {
2139        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
2140        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2141            assert!(block.has_tunnels());
2142        } else {
2143            panic!("Expected HostBlock");
2144        }
2145    }
2146
2147    #[test]
2148    fn add_forward_inserts_directive() {
2149        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
2150        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2151        let output = config.serialize();
2152        assert!(output.contains("LocalForward 8080 localhost:80"));
2153        // Existing directives preserved
2154        assert!(output.contains("HostName 10.0.0.1"));
2155        assert!(output.contains("User admin"));
2156    }
2157
2158    #[test]
2159    fn add_forward_preserves_indentation() {
2160        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2161        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2162        let output = config.serialize();
2163        assert!(output.contains("\tLocalForward 8080 localhost:80"));
2164    }
2165
2166    #[test]
2167    fn add_multiple_forwards_same_type() {
2168        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2169        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2170        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
2171        let output = config.serialize();
2172        assert!(output.contains("LocalForward 8080 localhost:80"));
2173        assert!(output.contains("LocalForward 9090 localhost:90"));
2174    }
2175
2176    #[test]
2177    fn remove_forward_removes_exact_match() {
2178        let mut config = parse_str(
2179            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
2180        );
2181        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2182        let output = config.serialize();
2183        assert!(!output.contains("8080 localhost:80"));
2184        assert!(output.contains("9090 localhost:90"));
2185    }
2186
2187    #[test]
2188    fn remove_forward_leaves_other_directives() {
2189        let mut config = parse_str(
2190            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
2191        );
2192        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
2193        let output = config.serialize();
2194        assert!(!output.contains("LocalForward"));
2195        assert!(output.contains("HostName 10.0.0.1"));
2196        assert!(output.contains("User admin"));
2197    }
2198
2199    #[test]
2200    fn remove_forward_no_match_is_noop() {
2201        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
2202        let mut config = parse_str(original);
2203        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
2204        assert_eq!(config.serialize(), original);
2205    }
2206
2207    #[test]
2208    fn host_entry_tunnel_count_populated() {
2209        let config = parse_str(
2210            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
2211        );
2212        let entries = config.host_entries();
2213        assert_eq!(entries.len(), 1);
2214        assert_eq!(entries[0].tunnel_count, 2);
2215    }
2216
2217    #[test]
2218    fn remove_forward_returns_true_on_match() {
2219        let mut config =
2220            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
2221        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2222    }
2223
2224    #[test]
2225    fn remove_forward_returns_false_on_no_match() {
2226        let mut config =
2227            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
2228        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
2229    }
2230
2231    #[test]
2232    fn remove_forward_returns_false_for_unknown_host() {
2233        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2234        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
2235    }
2236
2237    #[test]
2238    fn has_forward_finds_match() {
2239        let config =
2240            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
2241        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2242    }
2243
2244    #[test]
2245    fn has_forward_no_match() {
2246        let config =
2247            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
2248        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
2249        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2250    }
2251
2252    #[test]
2253    fn has_forward_case_insensitive_key() {
2254        let config =
2255            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
2256        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2257    }
2258
2259    #[test]
2260    fn add_forward_to_empty_block() {
2261        let mut config = parse_str("Host myserver\n");
2262        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
2263        let output = config.serialize();
2264        assert!(output.contains("LocalForward 8080 localhost:80"));
2265    }
2266
2267    #[test]
2268    fn remove_forward_case_insensitive_key_match() {
2269        let mut config =
2270            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
2271        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2272        assert!(!config.serialize().contains("localforward"));
2273    }
2274
2275    #[test]
2276    fn tunnel_count_case_insensitive() {
2277        let config = parse_str(
2278            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
2279        );
2280        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2281            assert_eq!(block.tunnel_count(), 3);
2282        } else {
2283            panic!("Expected HostBlock");
2284        }
2285    }
2286
2287    #[test]
2288    fn tunnel_directives_extracts_all_types() {
2289        let config = parse_str(
2290            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
2291        );
2292        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2293            let rules = block.tunnel_directives();
2294            assert_eq!(rules.len(), 3);
2295            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
2296            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
2297            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
2298        } else {
2299            panic!("Expected HostBlock");
2300        }
2301    }
2302
2303    #[test]
2304    fn tunnel_directives_skips_malformed() {
2305        let config = parse_str("Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n");
2306        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
2307            let rules = block.tunnel_directives();
2308            assert_eq!(rules.len(), 1);
2309            assert_eq!(rules[0].bind_port, 1080);
2310        } else {
2311            panic!("Expected HostBlock");
2312        }
2313    }
2314
2315    #[test]
2316    fn find_tunnel_directives_multi_pattern_host() {
2317        let config =
2318            parse_str("Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
2319        let rules = config.find_tunnel_directives("prod");
2320        assert_eq!(rules.len(), 1);
2321        assert_eq!(rules[0].bind_port, 8080);
2322        let rules2 = config.find_tunnel_directives("staging");
2323        assert_eq!(rules2.len(), 1);
2324    }
2325
2326    #[test]
2327    fn find_tunnel_directives_no_match() {
2328        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
2329        let rules = config.find_tunnel_directives("nohost");
2330        assert!(rules.is_empty());
2331    }
2332
2333    #[test]
2334    fn has_forward_exact_match() {
2335        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
2336        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2337        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
2338        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
2339        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
2340    }
2341
2342    #[test]
2343    fn has_forward_whitespace_normalized() {
2344        let config = parse_str("Host myserver\n  LocalForward 8080  localhost:80\n");
2345        // Extra space in config value vs single space in query — should still match
2346        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2347    }
2348
2349    #[test]
2350    fn has_forward_multi_pattern_host() {
2351        let config = parse_str("Host prod staging\n  LocalForward 8080 localhost:80\n");
2352        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2353        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2354    }
2355
2356    #[test]
2357    fn add_forward_multi_pattern_host() {
2358        let mut config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
2359        config.add_forward("prod", "LocalForward", "8080 localhost:80");
2360        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2361        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2362    }
2363
2364    #[test]
2365    fn remove_forward_multi_pattern_host() {
2366        let mut config = parse_str(
2367            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
2368        );
2369        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
2370        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2371        // Other forward should remain
2372        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
2373    }
2374
2375    #[test]
2376    fn edit_tunnel_detects_duplicate_after_remove() {
2377        // Simulates edit flow: remove old, then check if new value already exists
2378        let mut config = parse_str(
2379            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
2380        );
2381        // Edit rule A (8080) toward rule B (9090): remove A first
2382        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2383        // Now check if the target value already exists — should detect duplicate
2384        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
2385    }
2386
2387    #[test]
2388    fn has_forward_tab_whitespace_normalized() {
2389        let config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
2390        // Tab in config value vs space in query — should match via values_match
2391        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2392    }
2393
2394    #[test]
2395    fn remove_forward_tab_whitespace_normalized() {
2396        let mut config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
2397        // Remove with single space should match tab-separated value
2398        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2399        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2400    }
2401
2402    #[test]
2403    fn upsert_preserves_space_separator_when_value_contains_equals() {
2404        let mut config = parse_str("Host myserver\n  IdentityFile ~/.ssh/id=prod\n");
2405        let entry = HostEntry {
2406            alias: "myserver".to_string(),
2407            hostname: "10.0.0.1".to_string(),
2408            identity_file: "~/.ssh/id=staging".to_string(),
2409            port: 22,
2410            ..Default::default()
2411        };
2412        config.update_host("myserver", &entry);
2413        let output = config.serialize();
2414        // Separator should remain a space, not pick up the = from the value
2415        assert!(
2416            output.contains("  IdentityFile ~/.ssh/id=staging"),
2417            "got: {}",
2418            output
2419        );
2420        assert!(!output.contains("IdentityFile="), "got: {}", output);
2421    }
2422
2423    #[test]
2424    fn upsert_preserves_equals_separator() {
2425        let mut config = parse_str("Host myserver\n  IdentityFile=~/.ssh/id_rsa\n");
2426        let entry = HostEntry {
2427            alias: "myserver".to_string(),
2428            hostname: "10.0.0.1".to_string(),
2429            identity_file: "~/.ssh/id_ed25519".to_string(),
2430            port: 22,
2431            ..Default::default()
2432        };
2433        config.update_host("myserver", &entry);
2434        let output = config.serialize();
2435        assert!(
2436            output.contains("IdentityFile=~/.ssh/id_ed25519"),
2437            "got: {}",
2438            output
2439        );
2440    }
2441
2442    #[test]
2443    fn upsert_preserves_spaced_equals_separator() {
2444        let mut config = parse_str("Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n");
2445        let entry = HostEntry {
2446            alias: "myserver".to_string(),
2447            hostname: "10.0.0.1".to_string(),
2448            identity_file: "~/.ssh/id_ed25519".to_string(),
2449            port: 22,
2450            ..Default::default()
2451        };
2452        config.update_host("myserver", &entry);
2453        let output = config.serialize();
2454        assert!(
2455            output.contains("IdentityFile = ~/.ssh/id_ed25519"),
2456            "got: {}",
2457            output
2458        );
2459    }
2460
2461    #[test]
2462    fn is_included_host_false_for_main_config() {
2463        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2464        assert!(!config.is_included_host("myserver"));
2465    }
2466
2467    #[test]
2468    fn is_included_host_false_for_nonexistent() {
2469        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2470        assert!(!config.is_included_host("nohost"));
2471    }
2472
2473    #[test]
2474    fn is_included_host_multi_pattern_main_config() {
2475        let config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
2476        assert!(!config.is_included_host("prod"));
2477        assert!(!config.is_included_host("staging"));
2478    }
2479
2480    // =========================================================================
2481    // HostBlock::askpass() and set_askpass() tests
2482    // =========================================================================
2483
2484    fn first_block(config: &SshConfigFile) -> &HostBlock {
2485        match config.elements.first().unwrap() {
2486            ConfigElement::HostBlock(b) => b,
2487            _ => panic!("Expected HostBlock"),
2488        }
2489    }
2490
2491    fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
2492        match config.elements.first_mut().unwrap() {
2493            ConfigElement::HostBlock(b) => b,
2494            _ => panic!("Expected HostBlock"),
2495        }
2496    }
2497
2498    fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
2499        let mut count = 0;
2500        for el in &config.elements {
2501            if let ConfigElement::HostBlock(b) = el {
2502                if count == idx {
2503                    return b;
2504                }
2505                count += 1;
2506            }
2507        }
2508        panic!("No HostBlock at index {}", idx);
2509    }
2510
2511    #[test]
2512    fn askpass_returns_none_when_absent() {
2513        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2514        assert_eq!(first_block(&config).askpass(), None);
2515    }
2516
2517    #[test]
2518    fn askpass_returns_keychain() {
2519        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2520        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2521    }
2522
2523    #[test]
2524    fn askpass_returns_op_uri() {
2525        let config = parse_str(
2526            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/Item/field\n",
2527        );
2528        assert_eq!(
2529            first_block(&config).askpass(),
2530            Some("op://Vault/Item/field".to_string())
2531        );
2532    }
2533
2534    #[test]
2535    fn askpass_returns_vault_with_field() {
2536        let config = parse_str(
2537            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#password\n",
2538        );
2539        assert_eq!(
2540            first_block(&config).askpass(),
2541            Some("vault:secret/ssh#password".to_string())
2542        );
2543    }
2544
2545    #[test]
2546    fn askpass_returns_bw_source() {
2547        let config =
2548            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
2549        assert_eq!(
2550            first_block(&config).askpass(),
2551            Some("bw:my-item".to_string())
2552        );
2553    }
2554
2555    #[test]
2556    fn askpass_returns_pass_source() {
2557        let config =
2558            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
2559        assert_eq!(
2560            first_block(&config).askpass(),
2561            Some("pass:ssh/prod".to_string())
2562        );
2563    }
2564
2565    #[test]
2566    fn askpass_returns_custom_command() {
2567        let config =
2568            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass get-pass %a %h\n");
2569        assert_eq!(
2570            first_block(&config).askpass(),
2571            Some("get-pass %a %h".to_string())
2572        );
2573    }
2574
2575    #[test]
2576    fn askpass_ignores_empty_value() {
2577        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass \n");
2578        assert_eq!(first_block(&config).askpass(), None);
2579    }
2580
2581    #[test]
2582    fn askpass_ignores_non_askpass_purple_comments() {
2583        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod\n");
2584        assert_eq!(first_block(&config).askpass(), None);
2585    }
2586
2587    #[test]
2588    fn set_askpass_adds_comment() {
2589        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2590        config.set_host_askpass("myserver", "keychain");
2591        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2592    }
2593
2594    #[test]
2595    fn set_askpass_replaces_existing() {
2596        let mut config =
2597            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2598        config.set_host_askpass("myserver", "op://V/I/p");
2599        assert_eq!(
2600            first_block(&config).askpass(),
2601            Some("op://V/I/p".to_string())
2602        );
2603    }
2604
2605    #[test]
2606    fn set_askpass_empty_removes_comment() {
2607        let mut config =
2608            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2609        config.set_host_askpass("myserver", "");
2610        assert_eq!(first_block(&config).askpass(), None);
2611    }
2612
2613    #[test]
2614    fn set_askpass_preserves_other_directives() {
2615        let mut config =
2616            parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n  # purple:tags prod\n");
2617        config.set_host_askpass("myserver", "vault:secret/ssh");
2618        assert_eq!(
2619            first_block(&config).askpass(),
2620            Some("vault:secret/ssh".to_string())
2621        );
2622        let entry = first_block(&config).to_host_entry();
2623        assert_eq!(entry.user, "admin");
2624        assert!(entry.tags.contains(&"prod".to_string()));
2625    }
2626
2627    #[test]
2628    fn set_askpass_preserves_indent() {
2629        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
2630        config.set_host_askpass("myserver", "keychain");
2631        let raw = first_block(&config)
2632            .directives
2633            .iter()
2634            .find(|d| d.raw_line.contains("purple:askpass"))
2635            .unwrap();
2636        assert!(
2637            raw.raw_line.starts_with("    "),
2638            "Expected 4-space indent, got: {:?}",
2639            raw.raw_line
2640        );
2641    }
2642
2643    #[test]
2644    fn set_askpass_on_nonexistent_host() {
2645        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2646        config.set_host_askpass("nohost", "keychain");
2647        assert_eq!(first_block(&config).askpass(), None);
2648    }
2649
2650    #[test]
2651    fn to_entry_includes_askpass() {
2652        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
2653        let entries = config.host_entries();
2654        assert_eq!(entries.len(), 1);
2655        assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2656    }
2657
2658    #[test]
2659    fn to_entry_askpass_none_when_absent() {
2660        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2661        let entries = config.host_entries();
2662        assert_eq!(entries.len(), 1);
2663        assert_eq!(entries[0].askpass, None);
2664    }
2665
2666    #[test]
2667    fn set_askpass_vault_with_hash_field() {
2668        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2669        config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2670        assert_eq!(
2671            first_block(&config).askpass(),
2672            Some("vault:secret/data/team#api_key".to_string())
2673        );
2674    }
2675
2676    #[test]
2677    fn set_askpass_custom_command_with_percent() {
2678        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2679        config.set_host_askpass("myserver", "get-pass %a %h");
2680        assert_eq!(
2681            first_block(&config).askpass(),
2682            Some("get-pass %a %h".to_string())
2683        );
2684    }
2685
2686    #[test]
2687    fn multiple_hosts_independent_askpass() {
2688        let mut config = parse_str("Host alpha\n  HostName a.com\n\nHost beta\n  HostName b.com\n");
2689        config.set_host_askpass("alpha", "keychain");
2690        config.set_host_askpass("beta", "vault:secret/ssh");
2691        assert_eq!(
2692            block_by_index(&config, 0).askpass(),
2693            Some("keychain".to_string())
2694        );
2695        assert_eq!(
2696            block_by_index(&config, 1).askpass(),
2697            Some("vault:secret/ssh".to_string())
2698        );
2699    }
2700
2701    #[test]
2702    fn set_askpass_then_clear_then_set_again() {
2703        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2704        config.set_host_askpass("myserver", "keychain");
2705        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2706        config.set_host_askpass("myserver", "");
2707        assert_eq!(first_block(&config).askpass(), None);
2708        config.set_host_askpass("myserver", "op://V/I/p");
2709        assert_eq!(
2710            first_block(&config).askpass(),
2711            Some("op://V/I/p".to_string())
2712        );
2713    }
2714
2715    #[test]
2716    fn askpass_tab_indent_preserved() {
2717        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2718        config.set_host_askpass("myserver", "pass:ssh/prod");
2719        let raw = first_block(&config)
2720            .directives
2721            .iter()
2722            .find(|d| d.raw_line.contains("purple:askpass"))
2723            .unwrap();
2724        assert!(
2725            raw.raw_line.starts_with("\t"),
2726            "Expected tab indent, got: {:?}",
2727            raw.raw_line
2728        );
2729    }
2730
2731    #[test]
2732    fn askpass_coexists_with_provider_comment() {
2733        let config = parse_str(
2734            "Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:123\n  # purple:askpass keychain\n",
2735        );
2736        let block = first_block(&config);
2737        assert_eq!(block.askpass(), Some("keychain".to_string()));
2738        assert!(block.provider().is_some());
2739    }
2740
2741    #[test]
2742    fn set_askpass_does_not_remove_tags() {
2743        let mut config =
2744            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod,staging\n");
2745        config.set_host_askpass("myserver", "keychain");
2746        let entry = first_block(&config).to_host_entry();
2747        assert_eq!(entry.askpass, Some("keychain".to_string()));
2748        assert!(entry.tags.contains(&"prod".to_string()));
2749        assert!(entry.tags.contains(&"staging".to_string()));
2750    }
2751
2752    #[test]
2753    fn askpass_idempotent_set_same_value() {
2754        let mut config =
2755            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2756        config.set_host_askpass("myserver", "keychain");
2757        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2758        let serialized = config.serialize();
2759        assert_eq!(
2760            serialized.matches("purple:askpass").count(),
2761            1,
2762            "Should have exactly one askpass comment"
2763        );
2764    }
2765
2766    #[test]
2767    fn askpass_with_value_containing_equals() {
2768        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2769        config.set_host_askpass("myserver", "cmd --opt=val %h");
2770        assert_eq!(
2771            first_block(&config).askpass(),
2772            Some("cmd --opt=val %h".to_string())
2773        );
2774    }
2775
2776    #[test]
2777    fn askpass_with_value_containing_hash() {
2778        let config =
2779            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:a/b#c\n");
2780        assert_eq!(
2781            first_block(&config).askpass(),
2782            Some("vault:a/b#c".to_string())
2783        );
2784    }
2785
2786    #[test]
2787    fn askpass_with_long_op_uri() {
2788        let uri = "op://My Personal Vault/SSH Production Server/password";
2789        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2790        config.set_host_askpass("myserver", uri);
2791        assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2792    }
2793
2794    #[test]
2795    fn askpass_does_not_interfere_with_host_matching() {
2796        // askpass is stored as a non-directive comment; it shouldn't affect SSH matching
2797        let config = parse_str(
2798            "Host myserver\n  HostName 10.0.0.1\n  User root\n  # purple:askpass keychain\n",
2799        );
2800        let entry = first_block(&config).to_host_entry();
2801        assert_eq!(entry.user, "root");
2802        assert_eq!(entry.hostname, "10.0.0.1");
2803        assert_eq!(entry.askpass, Some("keychain".to_string()));
2804    }
2805
2806    #[test]
2807    fn set_askpass_on_host_with_many_directives() {
2808        let config_str = "\
2809Host myserver
2810  HostName 10.0.0.1
2811  User admin
2812  Port 2222
2813  IdentityFile ~/.ssh/id_ed25519
2814  ProxyJump bastion
2815  # purple:tags prod,us-east
2816";
2817        let mut config = parse_str(config_str);
2818        config.set_host_askpass("myserver", "pass:ssh/prod");
2819        let entry = first_block(&config).to_host_entry();
2820        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2821        assert_eq!(entry.user, "admin");
2822        assert_eq!(entry.port, 2222);
2823        assert!(entry.tags.contains(&"prod".to_string()));
2824    }
2825
2826    #[test]
2827    fn askpass_with_crlf_line_endings() {
2828        let config =
2829            parse_str("Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n");
2830        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2831    }
2832
2833    #[test]
2834    fn askpass_only_on_first_matching_host() {
2835        // If two Host blocks have the same alias (unusual), askpass comes from first
2836        let config = parse_str(
2837            "Host dup\n  HostName a.com\n  # purple:askpass keychain\n\nHost dup\n  HostName b.com\n  # purple:askpass vault:x\n",
2838        );
2839        let entries = config.host_entries();
2840        // First match
2841        assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2842    }
2843
2844    #[test]
2845    fn set_askpass_preserves_other_non_directive_comments() {
2846        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # This is a user comment\n  # purple:askpass old\n  # Another comment\n";
2847        let mut config = parse_str(config_str);
2848        config.set_host_askpass("myserver", "new-source");
2849        let serialized = config.serialize();
2850        assert!(serialized.contains("# This is a user comment"));
2851        assert!(serialized.contains("# Another comment"));
2852        assert!(serialized.contains("# purple:askpass new-source"));
2853        assert!(!serialized.contains("# purple:askpass old"));
2854    }
2855
2856    #[test]
2857    fn askpass_mixed_with_tunnel_directives() {
2858        let config_str = "\
2859Host myserver
2860  HostName 10.0.0.1
2861  LocalForward 8080 localhost:80
2862  # purple:askpass bw:item
2863  RemoteForward 9090 localhost:9090
2864";
2865        let config = parse_str(config_str);
2866        let entry = first_block(&config).to_host_entry();
2867        assert_eq!(entry.askpass, Some("bw:item".to_string()));
2868        assert_eq!(entry.tunnel_count, 2);
2869    }
2870
2871    // =========================================================================
2872    // askpass: set_askpass idempotent (same value)
2873    // =========================================================================
2874
2875    #[test]
2876    fn set_askpass_idempotent_same_value() {
2877        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n";
2878        let mut config = parse_str(config_str);
2879        config.set_host_askpass("myserver", "keychain");
2880        let output = config.serialize();
2881        // Should still have exactly one askpass comment
2882        assert_eq!(output.matches("purple:askpass").count(), 1);
2883        assert!(output.contains("# purple:askpass keychain"));
2884    }
2885
2886    #[test]
2887    fn set_askpass_with_equals_in_value() {
2888        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2889        config.set_host_askpass("myserver", "cmd --opt=val");
2890        let entries = config.host_entries();
2891        assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2892    }
2893
2894    #[test]
2895    fn set_askpass_with_hash_in_value() {
2896        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2897        config.set_host_askpass("myserver", "vault:secret/data#field");
2898        let entries = config.host_entries();
2899        assert_eq!(
2900            entries[0].askpass,
2901            Some("vault:secret/data#field".to_string())
2902        );
2903    }
2904
2905    #[test]
2906    fn set_askpass_long_op_uri() {
2907        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2908        let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2909        config.set_host_askpass("myserver", long_uri);
2910        assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2911    }
2912
2913    #[test]
2914    fn askpass_host_with_multi_pattern_is_skipped() {
2915        // Multi-pattern host blocks ("Host prod staging") are treated as patterns
2916        // and are not included in host_entries(), so set_askpass is a no-op
2917        let config_str = "Host prod staging\n  HostName 10.0.0.1\n";
2918        let mut config = parse_str(config_str);
2919        config.set_host_askpass("prod", "keychain");
2920        // No entries because multi-pattern hosts are pattern hosts
2921        assert!(config.host_entries().is_empty());
2922    }
2923
2924    #[test]
2925    fn askpass_survives_directive_reorder() {
2926        // askpass should survive even when directives are in unusual order
2927        let config_str = "\
2928Host myserver
2929  # purple:askpass op://V/I/p
2930  HostName 10.0.0.1
2931  User root
2932";
2933        let config = parse_str(config_str);
2934        let entry = first_block(&config).to_host_entry();
2935        assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2936        assert_eq!(entry.hostname, "10.0.0.1");
2937    }
2938
2939    #[test]
2940    fn askpass_among_many_purple_comments() {
2941        let config_str = "\
2942Host myserver
2943  HostName 10.0.0.1
2944  # purple:tags prod,us-east
2945  # purple:provider do:12345
2946  # purple:askpass pass:ssh/prod
2947";
2948        let config = parse_str(config_str);
2949        let entry = first_block(&config).to_host_entry();
2950        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2951        assert!(entry.tags.contains(&"prod".to_string()));
2952    }
2953
2954    #[test]
2955    fn meta_empty_when_no_comment() {
2956        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2957        let config = parse_str(config_str);
2958        let meta = first_block(&config).meta();
2959        assert!(meta.is_empty());
2960    }
2961
2962    #[test]
2963    fn meta_parses_key_value_pairs() {
2964        let config_str = "\
2965Host myhost
2966  HostName 1.2.3.4
2967  # purple:meta region=nyc3,plan=s-1vcpu-1gb
2968";
2969        let config = parse_str(config_str);
2970        let meta = first_block(&config).meta();
2971        assert_eq!(meta.len(), 2);
2972        assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2973        assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2974    }
2975
2976    #[test]
2977    fn meta_round_trip() {
2978        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2979        let mut config = parse_str(config_str);
2980        let meta = vec![
2981            ("region".to_string(), "fra1".to_string()),
2982            ("plan".to_string(), "cx11".to_string()),
2983        ];
2984        config.set_host_meta("myhost", &meta);
2985        let output = config.serialize();
2986        assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2987
2988        let config2 = parse_str(&output);
2989        let parsed = first_block(&config2).meta();
2990        assert_eq!(parsed, meta);
2991    }
2992
2993    #[test]
2994    fn meta_replaces_existing() {
2995        let config_str = "\
2996Host myhost
2997  HostName 1.2.3.4
2998  # purple:meta region=old
2999";
3000        let mut config = parse_str(config_str);
3001        config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
3002        let output = config.serialize();
3003        assert!(!output.contains("region=old"));
3004        assert!(output.contains("region=new"));
3005    }
3006
3007    #[test]
3008    fn meta_removed_when_empty() {
3009        let config_str = "\
3010Host myhost
3011  HostName 1.2.3.4
3012  # purple:meta region=nyc3
3013";
3014        let mut config = parse_str(config_str);
3015        config.set_host_meta("myhost", &[]);
3016        let output = config.serialize();
3017        assert!(!output.contains("purple:meta"));
3018    }
3019
3020    #[test]
3021    fn meta_sanitizes_commas_in_values() {
3022        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
3023        let mut config = parse_str(config_str);
3024        let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
3025        config.set_host_meta("myhost", &meta);
3026        let output = config.serialize();
3027        // Comma stripped to prevent parse corruption
3028        assert!(output.contains("plan=s-1vcpu1gb"));
3029
3030        let config2 = parse_str(&output);
3031        let parsed = first_block(&config2).meta();
3032        assert_eq!(parsed[0].1, "s-1vcpu1gb");
3033    }
3034
3035    #[test]
3036    fn meta_in_host_entry() {
3037        let config_str = "\
3038Host myhost
3039  HostName 1.2.3.4
3040  # purple:meta region=nyc3,plan=s-1vcpu-1gb
3041";
3042        let config = parse_str(config_str);
3043        let entry = first_block(&config).to_host_entry();
3044        assert_eq!(entry.provider_meta.len(), 2);
3045        assert_eq!(entry.provider_meta[0].0, "region");
3046        assert_eq!(entry.provider_meta[1].0, "plan");
3047    }
3048
3049    #[test]
3050    fn repair_absorbed_group_comment() {
3051        // Simulate the bug: group comment absorbed into preceding block's directives.
3052        let mut config = SshConfigFile {
3053            elements: vec![ConfigElement::HostBlock(HostBlock {
3054                host_pattern: "myserver".to_string(),
3055                raw_host_line: "Host myserver".to_string(),
3056                directives: vec![
3057                    Directive {
3058                        key: "HostName".to_string(),
3059                        value: "10.0.0.1".to_string(),
3060                        raw_line: "  HostName 10.0.0.1".to_string(),
3061                        is_non_directive: false,
3062                    },
3063                    Directive {
3064                        key: String::new(),
3065                        value: String::new(),
3066                        raw_line: "# purple:group Production".to_string(),
3067                        is_non_directive: true,
3068                    },
3069                ],
3070            })],
3071            path: PathBuf::from("/tmp/test_config"),
3072            crlf: false,
3073            bom: false,
3074        };
3075        let count = config.repair_absorbed_group_comments();
3076        assert_eq!(count, 1);
3077        assert_eq!(config.elements.len(), 2);
3078        // Block should only have the HostName directive.
3079        if let ConfigElement::HostBlock(block) = &config.elements[0] {
3080            assert_eq!(block.directives.len(), 1);
3081            assert_eq!(block.directives[0].key, "HostName");
3082        } else {
3083            panic!("Expected HostBlock");
3084        }
3085        // Group comment should be a GlobalLine.
3086        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
3087            assert_eq!(line, "# purple:group Production");
3088        } else {
3089            panic!("Expected GlobalLine for group comment");
3090        }
3091    }
3092
3093    #[test]
3094    fn repair_strips_trailing_blanks_before_group() {
3095        let mut config = SshConfigFile {
3096            elements: vec![ConfigElement::HostBlock(HostBlock {
3097                host_pattern: "myserver".to_string(),
3098                raw_host_line: "Host myserver".to_string(),
3099                directives: vec![
3100                    Directive {
3101                        key: "HostName".to_string(),
3102                        value: "10.0.0.1".to_string(),
3103                        raw_line: "  HostName 10.0.0.1".to_string(),
3104                        is_non_directive: false,
3105                    },
3106                    Directive {
3107                        key: String::new(),
3108                        value: String::new(),
3109                        raw_line: "".to_string(),
3110                        is_non_directive: true,
3111                    },
3112                    Directive {
3113                        key: String::new(),
3114                        value: String::new(),
3115                        raw_line: "# purple:group Staging".to_string(),
3116                        is_non_directive: true,
3117                    },
3118                ],
3119            })],
3120            path: PathBuf::from("/tmp/test_config"),
3121            crlf: false,
3122            bom: false,
3123        };
3124        let count = config.repair_absorbed_group_comments();
3125        assert_eq!(count, 1);
3126        // Block keeps only HostName.
3127        if let ConfigElement::HostBlock(block) = &config.elements[0] {
3128            assert_eq!(block.directives.len(), 1);
3129        } else {
3130            panic!("Expected HostBlock");
3131        }
3132        // Blank line and group comment are now GlobalLines.
3133        assert_eq!(config.elements.len(), 3);
3134        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
3135            assert!(line.trim().is_empty());
3136        } else {
3137            panic!("Expected blank GlobalLine");
3138        }
3139        if let ConfigElement::GlobalLine(line) = &config.elements[2] {
3140            assert!(line.starts_with("# purple:group"));
3141        } else {
3142            panic!("Expected group GlobalLine");
3143        }
3144    }
3145
3146    #[test]
3147    fn repair_clean_config_returns_zero() {
3148        let mut config =
3149            parse_str("# purple:group Production\nHost myserver\n  HostName 10.0.0.1\n");
3150        let count = config.repair_absorbed_group_comments();
3151        assert_eq!(count, 0);
3152    }
3153
3154    #[test]
3155    fn repair_roundtrip_serializes_correctly() {
3156        // Build a corrupted config manually.
3157        let mut config = SshConfigFile {
3158            elements: vec![
3159                ConfigElement::HostBlock(HostBlock {
3160                    host_pattern: "server1".to_string(),
3161                    raw_host_line: "Host server1".to_string(),
3162                    directives: vec![
3163                        Directive {
3164                            key: "HostName".to_string(),
3165                            value: "10.0.0.1".to_string(),
3166                            raw_line: "  HostName 10.0.0.1".to_string(),
3167                            is_non_directive: false,
3168                        },
3169                        Directive {
3170                            key: String::new(),
3171                            value: String::new(),
3172                            raw_line: "".to_string(),
3173                            is_non_directive: true,
3174                        },
3175                        Directive {
3176                            key: String::new(),
3177                            value: String::new(),
3178                            raw_line: "# purple:group Staging".to_string(),
3179                            is_non_directive: true,
3180                        },
3181                    ],
3182                }),
3183                ConfigElement::HostBlock(HostBlock {
3184                    host_pattern: "server2".to_string(),
3185                    raw_host_line: "Host server2".to_string(),
3186                    directives: vec![Directive {
3187                        key: "HostName".to_string(),
3188                        value: "10.0.0.2".to_string(),
3189                        raw_line: "  HostName 10.0.0.2".to_string(),
3190                        is_non_directive: false,
3191                    }],
3192                }),
3193            ],
3194            path: PathBuf::from("/tmp/test_config"),
3195            crlf: false,
3196            bom: false,
3197        };
3198        let count = config.repair_absorbed_group_comments();
3199        assert_eq!(count, 1);
3200        let output = config.serialize();
3201        // The group comment should appear between the two host blocks.
3202        let expected = "\
3203Host server1
3204  HostName 10.0.0.1
3205
3206# purple:group Staging
3207Host server2
3208  HostName 10.0.0.2
3209";
3210        assert_eq!(output, expected);
3211    }
3212
3213    // =========================================================================
3214    // delete_host: orphaned group header cleanup
3215    // =========================================================================
3216
3217    #[test]
3218    fn delete_last_provider_host_removes_group_header() {
3219        let config_str = "\
3220# purple:group DigitalOcean
3221Host do-web
3222  HostName 1.2.3.4
3223  # purple:provider digitalocean:123
3224";
3225        let mut config = parse_str(config_str);
3226        config.delete_host("do-web");
3227        let has_header = config
3228            .elements
3229            .iter()
3230            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3231        assert!(
3232            !has_header,
3233            "Group header should be removed when last provider host is deleted"
3234        );
3235    }
3236
3237    #[test]
3238    fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
3239        let config_str = "\
3240# purple:group DigitalOcean
3241Host do-web
3242  HostName 1.2.3.4
3243  # purple:provider digitalocean:123
3244
3245Host do-db
3246  HostName 5.6.7.8
3247  # purple:provider digitalocean:456
3248";
3249        let mut config = parse_str(config_str);
3250        config.delete_host("do-web");
3251        let has_header = config.elements.iter().any(|e| {
3252            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3253        });
3254        assert!(
3255            has_header,
3256            "Group header should be preserved when other provider hosts remain"
3257        );
3258        assert_eq!(config.host_entries().len(), 1);
3259    }
3260
3261    #[test]
3262    fn delete_non_provider_host_leaves_group_headers() {
3263        let config_str = "\
3264Host personal
3265  HostName 10.0.0.1
3266
3267# purple:group DigitalOcean
3268Host do-web
3269  HostName 1.2.3.4
3270  # purple:provider digitalocean:123
3271";
3272        let mut config = parse_str(config_str);
3273        config.delete_host("personal");
3274        let has_header = config.elements.iter().any(|e| {
3275            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
3276        });
3277        assert!(
3278            has_header,
3279            "Group header should not be affected by deleting a non-provider host"
3280        );
3281        assert_eq!(config.host_entries().len(), 1);
3282    }
3283
3284    #[test]
3285    fn delete_host_undoable_keeps_group_header_for_undo() {
3286        // delete_host_undoable does NOT remove orphaned group headers so that
3287        // undo (insert_host_at) can restore the config to its original state.
3288        // Orphaned headers are cleaned up at startup instead.
3289        let config_str = "\
3290# purple:group Vultr
3291Host vultr-web
3292  HostName 2.3.4.5
3293  # purple:provider vultr:789
3294";
3295        let mut config = parse_str(config_str);
3296        let result = config.delete_host_undoable("vultr-web");
3297        assert!(result.is_some());
3298        let has_header = config
3299            .elements
3300            .iter()
3301            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
3302        assert!(has_header, "Group header should be kept for undo");
3303    }
3304
3305    #[test]
3306    fn delete_host_undoable_preserves_header_when_others_remain() {
3307        let config_str = "\
3308# purple:group AWS EC2
3309Host aws-web
3310  HostName 3.4.5.6
3311  # purple:provider aws:i-111
3312
3313Host aws-db
3314  HostName 7.8.9.0
3315  # purple:provider aws:i-222
3316";
3317        let mut config = parse_str(config_str);
3318        let result = config.delete_host_undoable("aws-web");
3319        assert!(result.is_some());
3320        let has_header = config.elements.iter().any(
3321            |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
3322        );
3323        assert!(
3324            has_header,
3325            "Group header preserved when other provider hosts remain (undoable)"
3326        );
3327    }
3328
3329    #[test]
3330    fn delete_host_undoable_returns_original_position_for_undo() {
3331        // Group header at index 0, host at index 1. Undo re-inserts at index 1
3332        // which correctly restores the host after the group header.
3333        let config_str = "\
3334# purple:group Vultr
3335Host vultr-web
3336  HostName 2.3.4.5
3337  # purple:provider vultr:789
3338
3339Host manual
3340  HostName 10.0.0.1
3341";
3342        let mut config = parse_str(config_str);
3343        let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
3344        // Position is the original index (1), not adjusted, since no header was removed
3345        assert_eq!(pos, 1, "Position should be the original host index");
3346        // Undo: re-insert at the original position
3347        config.insert_host_at(element, pos);
3348        // The host should be back, group header intact, manual host accessible
3349        let output = config.serialize();
3350        assert!(
3351            output.contains("# purple:group Vultr"),
3352            "Group header should be present"
3353        );
3354        assert!(output.contains("Host vultr-web"), "Host should be restored");
3355        assert!(output.contains("Host manual"), "Manual host should survive");
3356        assert_eq!(config_str, output);
3357    }
3358
3359    // =========================================================================
3360    // add_host: wildcard ordering
3361    // =========================================================================
3362
3363    #[test]
3364    fn add_host_inserts_before_trailing_wildcard() {
3365        let config_str = "\
3366Host existing
3367  HostName 10.0.0.1
3368
3369Host *
3370  ServerAliveInterval 60
3371";
3372        let mut config = parse_str(config_str);
3373        let entry = HostEntry {
3374            alias: "newhost".to_string(),
3375            hostname: "10.0.0.2".to_string(),
3376            port: 22,
3377            ..Default::default()
3378        };
3379        config.add_host(&entry);
3380        let output = config.serialize();
3381        let new_pos = output.find("Host newhost").unwrap();
3382        let wildcard_pos = output.find("Host *").unwrap();
3383        assert!(
3384            new_pos < wildcard_pos,
3385            "New host should appear before Host *: {}",
3386            output
3387        );
3388        let existing_pos = output.find("Host existing").unwrap();
3389        assert!(existing_pos < new_pos);
3390    }
3391
3392    #[test]
3393    fn add_host_appends_when_no_wildcards() {
3394        let config_str = "\
3395Host existing
3396  HostName 10.0.0.1
3397";
3398        let mut config = parse_str(config_str);
3399        let entry = HostEntry {
3400            alias: "newhost".to_string(),
3401            hostname: "10.0.0.2".to_string(),
3402            port: 22,
3403            ..Default::default()
3404        };
3405        config.add_host(&entry);
3406        let output = config.serialize();
3407        let existing_pos = output.find("Host existing").unwrap();
3408        let new_pos = output.find("Host newhost").unwrap();
3409        assert!(existing_pos < new_pos, "New host should be appended at end");
3410    }
3411
3412    #[test]
3413    fn add_host_appends_when_wildcard_at_beginning() {
3414        // Host * at the top acts as global defaults. New hosts go after it.
3415        let config_str = "\
3416Host *
3417  ServerAliveInterval 60
3418
3419Host existing
3420  HostName 10.0.0.1
3421";
3422        let mut config = parse_str(config_str);
3423        let entry = HostEntry {
3424            alias: "newhost".to_string(),
3425            hostname: "10.0.0.2".to_string(),
3426            port: 22,
3427            ..Default::default()
3428        };
3429        config.add_host(&entry);
3430        let output = config.serialize();
3431        let existing_pos = output.find("Host existing").unwrap();
3432        let new_pos = output.find("Host newhost").unwrap();
3433        assert!(
3434            existing_pos < new_pos,
3435            "New host should be appended at end when wildcard is at top: {}",
3436            output
3437        );
3438    }
3439
3440    #[test]
3441    fn add_host_inserts_before_trailing_pattern_host() {
3442        let config_str = "\
3443Host existing
3444  HostName 10.0.0.1
3445
3446Host *.example.com
3447  ProxyJump bastion
3448";
3449        let mut config = parse_str(config_str);
3450        let entry = HostEntry {
3451            alias: "newhost".to_string(),
3452            hostname: "10.0.0.2".to_string(),
3453            port: 22,
3454            ..Default::default()
3455        };
3456        config.add_host(&entry);
3457        let output = config.serialize();
3458        let new_pos = output.find("Host newhost").unwrap();
3459        let pattern_pos = output.find("Host *.example.com").unwrap();
3460        assert!(
3461            new_pos < pattern_pos,
3462            "New host should appear before pattern host: {}",
3463            output
3464        );
3465    }
3466
3467    #[test]
3468    fn add_host_no_triple_blank_lines() {
3469        let config_str = "\
3470Host existing
3471  HostName 10.0.0.1
3472
3473Host *
3474  ServerAliveInterval 60
3475";
3476        let mut config = parse_str(config_str);
3477        let entry = HostEntry {
3478            alias: "newhost".to_string(),
3479            hostname: "10.0.0.2".to_string(),
3480            port: 22,
3481            ..Default::default()
3482        };
3483        config.add_host(&entry);
3484        let output = config.serialize();
3485        assert!(
3486            !output.contains("\n\n\n"),
3487            "Should not have triple blank lines: {}",
3488            output
3489        );
3490    }
3491
3492    #[test]
3493    fn provider_group_display_name_matches_providers_mod() {
3494        // Ensure the duplicated display name function in model.rs stays in sync
3495        // with providers::provider_display_name(). If these diverge, group header
3496        // cleanup (remove_orphaned_group_header) will fail to match headers
3497        // written by the sync engine.
3498        let providers = [
3499            "digitalocean",
3500            "vultr",
3501            "linode",
3502            "hetzner",
3503            "upcloud",
3504            "proxmox",
3505            "aws",
3506            "scaleway",
3507            "gcp",
3508            "azure",
3509            "tailscale",
3510            "oracle",
3511        ];
3512        for name in &providers {
3513            assert_eq!(
3514                provider_group_display_name(name),
3515                crate::providers::provider_display_name(name),
3516                "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
3517                name,
3518                provider_group_display_name(name),
3519                crate::providers::provider_display_name(name),
3520            );
3521        }
3522    }
3523
3524    #[test]
3525    fn test_sanitize_tag_strips_control_chars() {
3526        assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
3527        assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
3528        assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
3529        assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
3530    }
3531
3532    #[test]
3533    fn test_sanitize_tag_strips_commas() {
3534        assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
3535        assert_eq!(HostBlock::sanitize_tag(",,,"), "");
3536    }
3537
3538    #[test]
3539    fn test_sanitize_tag_strips_bidi() {
3540        assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
3541        assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
3542    }
3543
3544    #[test]
3545    fn test_sanitize_tag_truncates_long() {
3546        let long = "a".repeat(200);
3547        assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
3548    }
3549
3550    #[test]
3551    fn test_sanitize_tag_preserves_unicode() {
3552        assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
3553        assert_eq!(HostBlock::sanitize_tag("café"), "café");
3554    }
3555
3556    // =========================================================================
3557    // provider_tags parsing and has_provider_tags_comment tests
3558    // =========================================================================
3559
3560    #[test]
3561    fn test_provider_tags_parsing() {
3562        let config =
3563            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags a,b,c\n");
3564        let entry = first_block(&config).to_host_entry();
3565        assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
3566    }
3567
3568    #[test]
3569    fn test_provider_tags_empty() {
3570        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3571        let entry = first_block(&config).to_host_entry();
3572        assert!(entry.provider_tags.is_empty());
3573    }
3574
3575    #[test]
3576    fn test_has_provider_tags_comment_present() {
3577        let config =
3578            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags prod\n");
3579        assert!(first_block(&config).has_provider_tags_comment());
3580        assert!(first_block(&config).to_host_entry().has_provider_tags);
3581    }
3582
3583    #[test]
3584    fn test_has_provider_tags_comment_sentinel() {
3585        // Bare sentinel (no tags) still counts as "has provider_tags"
3586        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags\n");
3587        assert!(first_block(&config).has_provider_tags_comment());
3588        assert!(first_block(&config).to_host_entry().has_provider_tags);
3589        assert!(
3590            first_block(&config)
3591                .to_host_entry()
3592                .provider_tags
3593                .is_empty()
3594        );
3595    }
3596
3597    #[test]
3598    fn test_has_provider_tags_comment_absent() {
3599        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3600        assert!(!first_block(&config).has_provider_tags_comment());
3601        assert!(!first_block(&config).to_host_entry().has_provider_tags);
3602    }
3603
3604    #[test]
3605    fn test_set_tags_does_not_delete_provider_tags() {
3606        let mut config = parse_str(
3607            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1\n  # purple:provider_tags cloud1,cloud2\n",
3608        );
3609        config.set_host_tags("myserver", &["newuser".to_string()]);
3610        let entry = first_block(&config).to_host_entry();
3611        assert_eq!(entry.tags, vec!["newuser"]);
3612        assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3613    }
3614
3615    #[test]
3616    fn test_set_provider_tags_does_not_delete_user_tags() {
3617        let mut config = parse_str(
3618            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1,user2\n  # purple:provider_tags old\n",
3619        );
3620        config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3621        let entry = first_block(&config).to_host_entry();
3622        assert_eq!(entry.tags, vec!["user1", "user2"]);
3623        assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3624    }
3625
3626    #[test]
3627    fn test_set_askpass_does_not_delete_similar_comments() {
3628        // A hypothetical "# purple:askpass_backup test" should NOT be deleted by set_askpass
3629        let mut config = parse_str(
3630            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n  # purple:askpass_backup test\n",
3631        );
3632        config.set_host_askpass("myserver", "op://vault/item/pass");
3633        let entry = first_block(&config).to_host_entry();
3634        assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3635        // The similar-but-different comment survives
3636        let serialized = config.serialize();
3637        assert!(serialized.contains("purple:askpass_backup test"));
3638    }
3639
3640    #[test]
3641    fn test_set_meta_does_not_delete_similar_comments() {
3642        // A hypothetical "# purple:metadata foo" should NOT be deleted by set_meta
3643        let mut config = parse_str(
3644            "Host myserver\n  HostName 10.0.0.1\n  # purple:meta region=us-east\n  # purple:metadata foo\n",
3645        );
3646        config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3647        let serialized = config.serialize();
3648        assert!(serialized.contains("purple:meta region=eu-west"));
3649        assert!(serialized.contains("purple:metadata foo"));
3650    }
3651
3652    #[test]
3653    fn test_set_meta_sanitizes_control_chars() {
3654        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3655        config.set_host_meta(
3656            "myserver",
3657            &[
3658                ("region".to_string(), "us\x00east".to_string()),
3659                ("zone".to_string(), "a\u{202E}b".to_string()),
3660            ],
3661        );
3662        let serialized = config.serialize();
3663        // Control chars and bidi should be stripped from values
3664        assert!(serialized.contains("region=useast"));
3665        assert!(serialized.contains("zone=ab"));
3666        assert!(!serialized.contains('\x00'));
3667        assert!(!serialized.contains('\u{202E}'));
3668    }
3669
3670    // ── stale tests ──────────────────────────────────────────────────────
3671
3672    #[test]
3673    fn stale_returns_timestamp() {
3674        let config_str = "\
3675Host web
3676  HostName 1.2.3.4
3677  # purple:stale 1711900000
3678";
3679        let config = parse_str(config_str);
3680        assert_eq!(first_block(&config).stale(), Some(1711900000));
3681    }
3682
3683    #[test]
3684    fn stale_returns_none_when_absent() {
3685        let config_str = "Host web\n  HostName 1.2.3.4\n";
3686        let config = parse_str(config_str);
3687        assert_eq!(first_block(&config).stale(), None);
3688    }
3689
3690    #[test]
3691    fn stale_returns_none_for_malformed() {
3692        for bad in &[
3693            "Host w\n  HostName 1.2.3.4\n  # purple:stale abc\n",
3694            "Host w\n  HostName 1.2.3.4\n  # purple:stale\n",
3695            "Host w\n  HostName 1.2.3.4\n  # purple:stale -1\n",
3696        ] {
3697            let config = parse_str(bad);
3698            assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3699        }
3700    }
3701
3702    #[test]
3703    fn set_stale_adds_comment() {
3704        let config_str = "Host web\n  HostName 1.2.3.4\n";
3705        let mut config = parse_str(config_str);
3706        first_block_mut(&mut config).set_stale(1711900000);
3707        assert_eq!(first_block(&config).stale(), Some(1711900000));
3708        assert!(config.serialize().contains("# purple:stale 1711900000"));
3709    }
3710
3711    #[test]
3712    fn set_stale_replaces_existing() {
3713        let config_str = "\
3714Host web
3715  HostName 1.2.3.4
3716  # purple:stale 1000
3717";
3718        let mut config = parse_str(config_str);
3719        first_block_mut(&mut config).set_stale(2000);
3720        assert_eq!(first_block(&config).stale(), Some(2000));
3721        let output = config.serialize();
3722        assert!(!output.contains("1000"));
3723        assert!(output.contains("# purple:stale 2000"));
3724    }
3725
3726    #[test]
3727    fn clear_stale_removes_comment() {
3728        let config_str = "\
3729Host web
3730  HostName 1.2.3.4
3731  # purple:stale 1711900000
3732";
3733        let mut config = parse_str(config_str);
3734        first_block_mut(&mut config).clear_stale();
3735        assert_eq!(first_block(&config).stale(), None);
3736        assert!(!config.serialize().contains("purple:stale"));
3737    }
3738
3739    #[test]
3740    fn clear_stale_when_absent_is_noop() {
3741        let config_str = "Host web\n  HostName 1.2.3.4\n";
3742        let mut config = parse_str(config_str);
3743        let before = config.serialize();
3744        first_block_mut(&mut config).clear_stale();
3745        assert_eq!(config.serialize(), before);
3746    }
3747
3748    #[test]
3749    fn stale_roundtrip() {
3750        let config_str = "\
3751Host web
3752  HostName 1.2.3.4
3753  # purple:stale 1711900000
3754";
3755        let config = parse_str(config_str);
3756        let output = config.serialize();
3757        let config2 = parse_str(&output);
3758        assert_eq!(first_block(&config2).stale(), Some(1711900000));
3759    }
3760
3761    #[test]
3762    fn stale_in_host_entry() {
3763        let config_str = "\
3764Host web
3765  HostName 1.2.3.4
3766  # purple:stale 1711900000
3767";
3768        let config = parse_str(config_str);
3769        let entry = first_block(&config).to_host_entry();
3770        assert_eq!(entry.stale, Some(1711900000));
3771    }
3772
3773    #[test]
3774    fn stale_coexists_with_other_annotations() {
3775        let config_str = "\
3776Host web
3777  HostName 1.2.3.4
3778  # purple:tags prod
3779  # purple:provider do:12345
3780  # purple:askpass keychain
3781  # purple:meta region=nyc3
3782  # purple:stale 1711900000
3783";
3784        let config = parse_str(config_str);
3785        let entry = first_block(&config).to_host_entry();
3786        assert_eq!(entry.stale, Some(1711900000));
3787        assert!(entry.tags.contains(&"prod".to_string()));
3788        assert_eq!(entry.provider, Some("do".to_string()));
3789        assert_eq!(entry.askpass, Some("keychain".to_string()));
3790        assert_eq!(entry.provider_meta[0].0, "region");
3791    }
3792
3793    #[test]
3794    fn set_host_stale_delegates() {
3795        let config_str = "\
3796Host web
3797  HostName 1.2.3.4
3798
3799Host db
3800  HostName 5.6.7.8
3801";
3802        let mut config = parse_str(config_str);
3803        config.set_host_stale("db", 1234567890);
3804        assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3805        assert_eq!(config.host_entries()[0].stale, None);
3806    }
3807
3808    #[test]
3809    fn clear_host_stale_delegates() {
3810        let config_str = "\
3811Host web
3812  HostName 1.2.3.4
3813  # purple:stale 1711900000
3814";
3815        let mut config = parse_str(config_str);
3816        config.clear_host_stale("web");
3817        assert_eq!(first_block(&config).stale(), None);
3818    }
3819
3820    #[test]
3821    fn stale_hosts_collects_all() {
3822        let config_str = "\
3823Host web
3824  HostName 1.2.3.4
3825  # purple:stale 1000
3826
3827Host db
3828  HostName 5.6.7.8
3829
3830Host app
3831  HostName 9.10.11.12
3832  # purple:stale 2000
3833";
3834        let config = parse_str(config_str);
3835        let stale = config.stale_hosts();
3836        assert_eq!(stale.len(), 2);
3837        assert_eq!(stale[0], ("web".to_string(), 1000));
3838        assert_eq!(stale[1], ("app".to_string(), 2000));
3839    }
3840
3841    #[test]
3842    fn set_stale_preserves_indent() {
3843        let config_str = "Host web\n\tHostName 1.2.3.4\n";
3844        let mut config = parse_str(config_str);
3845        first_block_mut(&mut config).set_stale(1711900000);
3846        assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3847    }
3848
3849    #[test]
3850    fn stale_does_not_match_similar_comments() {
3851        let config_str = "\
3852Host web
3853  HostName 1.2.3.4
3854  # purple:stale_backup 999
3855";
3856        let config = parse_str(config_str);
3857        assert_eq!(first_block(&config).stale(), None);
3858    }
3859
3860    #[test]
3861    fn stale_with_whitespace_in_timestamp() {
3862        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale  1711900000 \n";
3863        let config = parse_str(config_str);
3864        assert_eq!(first_block(&config).stale(), Some(1711900000));
3865    }
3866
3867    #[test]
3868    fn stale_with_u64_max() {
3869        let ts = u64::MAX;
3870        let config_str = format!("Host w\n  HostName 1.2.3.4\n  # purple:stale {}\n", ts);
3871        let config = parse_str(&config_str);
3872        assert_eq!(first_block(&config).stale(), Some(ts));
3873        // Round-trip
3874        let output = config.serialize();
3875        let config2 = parse_str(&output);
3876        assert_eq!(first_block(&config2).stale(), Some(ts));
3877    }
3878
3879    #[test]
3880    fn stale_with_u64_overflow() {
3881        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 18446744073709551616\n";
3882        let config = parse_str(config_str);
3883        assert_eq!(first_block(&config).stale(), None);
3884    }
3885
3886    #[test]
3887    fn stale_timestamp_zero() {
3888        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 0\n";
3889        let config = parse_str(config_str);
3890        assert_eq!(first_block(&config).stale(), Some(0));
3891    }
3892
3893    #[test]
3894    fn set_host_stale_nonexistent_alias_is_noop() {
3895        let config_str = "Host web\n  HostName 1.2.3.4\n";
3896        let mut config = parse_str(config_str);
3897        let before = config.serialize();
3898        config.set_host_stale("nonexistent", 12345);
3899        assert_eq!(config.serialize(), before);
3900    }
3901
3902    #[test]
3903    fn clear_host_stale_nonexistent_alias_is_noop() {
3904        let config_str = "Host web\n  HostName 1.2.3.4\n";
3905        let mut config = parse_str(config_str);
3906        let before = config.serialize();
3907        config.clear_host_stale("nonexistent");
3908        assert_eq!(config.serialize(), before);
3909    }
3910
3911    #[test]
3912    fn stale_hosts_empty_config() {
3913        let config_str = "";
3914        let config = parse_str(config_str);
3915        assert!(config.stale_hosts().is_empty());
3916    }
3917
3918    #[test]
3919    fn stale_hosts_no_stale() {
3920        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
3921        let config = parse_str(config_str);
3922        assert!(config.stale_hosts().is_empty());
3923    }
3924
3925    #[test]
3926    fn clear_stale_preserves_other_purple_comments() {
3927        let config_str = "\
3928Host web
3929  HostName 1.2.3.4
3930  # purple:tags prod
3931  # purple:provider do:123
3932  # purple:askpass keychain
3933  # purple:meta region=nyc3
3934  # purple:stale 1711900000
3935";
3936        let mut config = parse_str(config_str);
3937        config.clear_host_stale("web");
3938        let entry = first_block(&config).to_host_entry();
3939        assert_eq!(entry.stale, None);
3940        assert!(entry.tags.contains(&"prod".to_string()));
3941        assert_eq!(entry.provider, Some("do".to_string()));
3942        assert_eq!(entry.askpass, Some("keychain".to_string()));
3943        assert_eq!(entry.provider_meta[0].0, "region");
3944    }
3945
3946    #[test]
3947    fn set_stale_preserves_other_purple_comments() {
3948        let config_str = "\
3949Host web
3950  HostName 1.2.3.4
3951  # purple:tags prod
3952  # purple:provider do:123
3953  # purple:askpass keychain
3954  # purple:meta region=nyc3
3955";
3956        let mut config = parse_str(config_str);
3957        config.set_host_stale("web", 1711900000);
3958        let entry = first_block(&config).to_host_entry();
3959        assert_eq!(entry.stale, Some(1711900000));
3960        assert!(entry.tags.contains(&"prod".to_string()));
3961        assert_eq!(entry.provider, Some("do".to_string()));
3962        assert_eq!(entry.askpass, Some("keychain".to_string()));
3963        assert_eq!(entry.provider_meta[0].0, "region");
3964    }
3965
3966    #[test]
3967    fn stale_multiple_comments_first_wins() {
3968        let config_str = "\
3969Host web
3970  HostName 1.2.3.4
3971  # purple:stale 1000
3972  # purple:stale 2000
3973";
3974        let config = parse_str(config_str);
3975        assert_eq!(first_block(&config).stale(), Some(1000));
3976    }
3977
3978    #[test]
3979    fn set_stale_removes_multiple_stale_comments() {
3980        let config_str = "\
3981Host web
3982  HostName 1.2.3.4
3983  # purple:stale 1000
3984  # purple:stale 2000
3985";
3986        let mut config = parse_str(config_str);
3987        first_block_mut(&mut config).set_stale(3000);
3988        assert_eq!(first_block(&config).stale(), Some(3000));
3989        let output = config.serialize();
3990        assert_eq!(output.matches("purple:stale").count(), 1);
3991    }
3992
3993    #[test]
3994    fn stale_absent_in_host_entry() {
3995        let config_str = "Host web\n  HostName 1.2.3.4\n";
3996        let config = parse_str(config_str);
3997        assert_eq!(first_block(&config).to_host_entry().stale, None);
3998    }
3999
4000    #[test]
4001    fn set_stale_four_space_indent() {
4002        let config_str = "Host web\n    HostName 1.2.3.4\n";
4003        let mut config = parse_str(config_str);
4004        first_block_mut(&mut config).set_stale(1711900000);
4005        assert!(config.serialize().contains("    # purple:stale 1711900000"));
4006    }
4007
4008    #[test]
4009    fn clear_stale_removes_bare_comment() {
4010        let config_str = "Host web\n  HostName 1.2.3.4\n  # purple:stale\n";
4011        let mut config = parse_str(config_str);
4012        first_block_mut(&mut config).clear_stale();
4013        assert!(!config.serialize().contains("purple:stale"));
4014    }
4015
4016    // ── SSH config integrity tests for stale operations ──────────────
4017
4018    #[test]
4019    fn stale_preserves_blank_line_between_hosts() {
4020        let config_str = "\
4021Host web
4022  HostName 1.2.3.4
4023
4024Host db
4025  HostName 5.6.7.8
4026";
4027        let mut config = parse_str(config_str);
4028        config.set_host_stale("web", 1711900000);
4029        let output = config.serialize();
4030        // There must still be a blank line between hosts
4031        assert!(
4032            output.contains("# purple:stale 1711900000\n\nHost db"),
4033            "blank line between hosts lost after set_stale:\n{}",
4034            output
4035        );
4036    }
4037
4038    #[test]
4039    fn stale_preserves_blank_line_before_group_header() {
4040        let config_str = "\
4041Host do-web
4042  HostName 1.2.3.4
4043  # purple:provider digitalocean:111
4044
4045# purple:group Hetzner
4046
4047Host hz-cache
4048  HostName 9.10.11.12
4049  # purple:provider hetzner:333
4050";
4051        let mut config = parse_str(config_str);
4052        config.set_host_stale("do-web", 1711900000);
4053        let output = config.serialize();
4054        // There must still be a blank line before the Hetzner group header
4055        assert!(
4056            output.contains("\n\n# purple:group Hetzner"),
4057            "blank line before group header lost after set_stale:\n{}",
4058            output
4059        );
4060    }
4061
4062    #[test]
4063    fn stale_set_and_clear_is_byte_identical() {
4064        let config_str = "\
4065Host manual
4066  HostName 10.0.0.1
4067  User admin
4068
4069# purple:group DigitalOcean
4070
4071Host do-web
4072  HostName 1.2.3.4
4073  User root
4074  # purple:provider digitalocean:111
4075  # purple:tags prod
4076
4077Host do-db
4078  HostName 5.6.7.8
4079  User root
4080  # purple:provider digitalocean:222
4081  # purple:meta region=nyc3
4082
4083# purple:group Hetzner
4084
4085Host hz-cache
4086  HostName 9.10.11.12
4087  User root
4088  # purple:provider hetzner:333
4089";
4090        let original = config_str.to_string();
4091        let mut config = parse_str(config_str);
4092
4093        // Mark stale
4094        config.set_host_stale("do-db", 1711900000);
4095        let after_stale = config.serialize();
4096        assert_ne!(after_stale, original, "stale should change the config");
4097
4098        // Clear stale
4099        config.clear_host_stale("do-db");
4100        let after_clear = config.serialize();
4101        assert_eq!(
4102            after_clear, original,
4103            "clearing stale must restore byte-identical config"
4104        );
4105    }
4106
4107    #[test]
4108    fn stale_does_not_accumulate_blank_lines() {
4109        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
4110        let mut config = parse_str(config_str);
4111
4112        // Set and clear stale 10 times
4113        for _ in 0..10 {
4114            config.set_host_stale("web", 1711900000);
4115            config.clear_host_stale("web");
4116        }
4117
4118        let output = config.serialize();
4119        assert_eq!(
4120            output, config_str,
4121            "repeated set/clear must not accumulate blank lines"
4122        );
4123    }
4124
4125    #[test]
4126    fn stale_preserves_all_directives_and_comments() {
4127        let config_str = "\
4128Host complex
4129  HostName 1.2.3.4
4130  User deploy
4131  Port 2222
4132  IdentityFile ~/.ssh/id_ed25519
4133  ProxyJump bastion
4134  LocalForward 8080 localhost:80
4135  # purple:provider digitalocean:999
4136  # purple:tags prod,us-east
4137  # purple:provider_tags web-tier
4138  # purple:askpass keychain
4139  # purple:meta region=nyc3,plan=s-1vcpu-1gb
4140  # This is a user comment
4141";
4142        let mut config = parse_str(config_str);
4143        let entry_before = first_block(&config).to_host_entry();
4144
4145        config.set_host_stale("complex", 1711900000);
4146        let entry_after = first_block(&config).to_host_entry();
4147
4148        // Every field must survive stale marking
4149        assert_eq!(entry_after.hostname, entry_before.hostname);
4150        assert_eq!(entry_after.user, entry_before.user);
4151        assert_eq!(entry_after.port, entry_before.port);
4152        assert_eq!(entry_after.identity_file, entry_before.identity_file);
4153        assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
4154        assert_eq!(entry_after.tags, entry_before.tags);
4155        assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
4156        assert_eq!(entry_after.provider, entry_before.provider);
4157        assert_eq!(entry_after.askpass, entry_before.askpass);
4158        assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
4159        assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
4160        assert_eq!(entry_after.stale, Some(1711900000));
4161
4162        // Clear stale and verify everything still intact
4163        config.clear_host_stale("complex");
4164        let entry_cleared = first_block(&config).to_host_entry();
4165        assert_eq!(entry_cleared.stale, None);
4166        assert_eq!(entry_cleared.hostname, entry_before.hostname);
4167        assert_eq!(entry_cleared.tags, entry_before.tags);
4168        assert_eq!(entry_cleared.provider, entry_before.provider);
4169        assert_eq!(entry_cleared.askpass, entry_before.askpass);
4170        assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
4171
4172        // User comment must survive
4173        assert!(config.serialize().contains("# This is a user comment"));
4174    }
4175
4176    #[test]
4177    fn stale_on_last_host_preserves_trailing_newline() {
4178        let config_str = "Host web\n  HostName 1.2.3.4\n";
4179        let mut config = parse_str(config_str);
4180        config.set_host_stale("web", 1711900000);
4181        let output = config.serialize();
4182        assert!(output.ends_with('\n'), "config must end with newline");
4183
4184        config.clear_host_stale("web");
4185        let output2 = config.serialize();
4186        assert_eq!(output2, config_str);
4187    }
4188
4189    #[test]
4190    fn stale_with_crlf_preserves_line_endings() {
4191        let config_str = "Host web\r\n  HostName 1.2.3.4\r\n";
4192        let config = SshConfigFile {
4193            elements: SshConfigFile::parse_content(config_str),
4194            path: std::path::PathBuf::from("/tmp/test"),
4195            crlf: true,
4196            bom: false,
4197        };
4198        let mut config = config;
4199        config.set_host_stale("web", 1711900000);
4200        let output = config.serialize();
4201        // All lines must use CRLF
4202        for line in output.split('\n') {
4203            if !line.is_empty() {
4204                assert!(
4205                    line.ends_with('\r'),
4206                    "CRLF lost after set_stale. Line: {:?}",
4207                    line
4208                );
4209            }
4210        }
4211
4212        config.clear_host_stale("web");
4213        assert_eq!(config.serialize(), config_str);
4214    }
4215
4216    #[test]
4217    fn pattern_match_star_wildcard() {
4218        assert!(ssh_pattern_match("*", "anything"));
4219        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
4220        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
4221        assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
4222        assert!(ssh_pattern_match("*.example.com", "web.example.com"));
4223        assert!(!ssh_pattern_match("*.example.com", "example.com"));
4224        assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
4225        assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
4226    }
4227
4228    #[test]
4229    fn pattern_match_question_mark() {
4230        assert!(ssh_pattern_match("server-?", "server-1"));
4231        assert!(ssh_pattern_match("server-?", "server-a"));
4232        assert!(!ssh_pattern_match("server-?", "server-10"));
4233        assert!(!ssh_pattern_match("server-?", "server-"));
4234    }
4235
4236    #[test]
4237    fn pattern_match_character_class() {
4238        assert!(ssh_pattern_match("server-[abc]", "server-a"));
4239        assert!(ssh_pattern_match("server-[abc]", "server-c"));
4240        assert!(!ssh_pattern_match("server-[abc]", "server-d"));
4241        assert!(ssh_pattern_match("server-[0-9]", "server-5"));
4242        assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
4243        assert!(ssh_pattern_match("server-[!abc]", "server-d"));
4244        assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
4245        assert!(ssh_pattern_match("server-[^abc]", "server-d"));
4246        assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
4247    }
4248
4249    #[test]
4250    fn pattern_match_negation() {
4251        assert!(!ssh_pattern_match("!prod-*", "prod-web"));
4252        assert!(ssh_pattern_match("!prod-*", "staging-web"));
4253    }
4254
4255    #[test]
4256    fn pattern_match_exact() {
4257        assert!(ssh_pattern_match("myserver", "myserver"));
4258        assert!(!ssh_pattern_match("myserver", "myserver2"));
4259        assert!(!ssh_pattern_match("myserver", "other"));
4260    }
4261
4262    #[test]
4263    fn pattern_match_empty() {
4264        assert!(!ssh_pattern_match("", "anything"));
4265        assert!(!ssh_pattern_match("*", ""));
4266        assert!(ssh_pattern_match("", ""));
4267    }
4268
4269    #[test]
4270    fn host_pattern_matches_multi_pattern() {
4271        assert!(host_pattern_matches("prod staging", "prod"));
4272        assert!(host_pattern_matches("prod staging", "staging"));
4273        assert!(!host_pattern_matches("prod staging", "dev"));
4274    }
4275
4276    #[test]
4277    fn host_pattern_matches_with_negation() {
4278        assert!(host_pattern_matches(
4279            "*.example.com !internal.example.com",
4280            "web.example.com",
4281        ));
4282        assert!(!host_pattern_matches(
4283            "*.example.com !internal.example.com",
4284            "internal.example.com",
4285        ));
4286    }
4287
4288    #[test]
4289    fn host_pattern_matches_alias_only() {
4290        // OpenSSH Host keyword matches only against alias, not HostName
4291        assert!(!host_pattern_matches("10.30.0.*", "production"));
4292        assert!(host_pattern_matches("prod*", "production"));
4293        assert!(!host_pattern_matches("staging*", "production"));
4294    }
4295
4296    #[test]
4297    fn pattern_entries_collects_wildcards() {
4298        let config = parse_str(
4299            "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n  ProxyJump bastion\n\nHost *\n  ServerAliveInterval 60\n",
4300        );
4301        let patterns = config.pattern_entries();
4302        assert_eq!(patterns.len(), 2);
4303        assert_eq!(patterns[0].pattern, "10.30.0.*");
4304        assert_eq!(patterns[0].user, "debian");
4305        assert_eq!(patterns[0].proxy_jump, "bastion");
4306        assert_eq!(patterns[1].pattern, "*");
4307        assert!(
4308            patterns[1]
4309                .directives
4310                .iter()
4311                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4312        );
4313    }
4314
4315    #[test]
4316    fn pattern_entries_empty_when_no_patterns() {
4317        let config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
4318        let patterns = config.pattern_entries();
4319        assert!(patterns.is_empty());
4320    }
4321
4322    #[test]
4323    fn matching_patterns_returns_in_config_order() {
4324        let config = parse_str(
4325            "Host 10.30.0.*\n  User debian\n\nHost myserver\n  Hostname 10.30.0.5\n\nHost *\n  ServerAliveInterval 60\n",
4326        );
4327        // "myserver" matches "*" but not "10.30.0.*" (alias-only matching)
4328        let matches = config.matching_patterns("myserver");
4329        assert_eq!(matches.len(), 1);
4330        assert_eq!(matches[0].pattern, "*");
4331    }
4332
4333    #[test]
4334    fn matching_patterns_negation_excludes() {
4335        let config = parse_str(
4336            "Host * !bastion\n  ServerAliveInterval 60\n\nHost bastion\n  Hostname 10.0.0.1\n",
4337        );
4338        let matches = config.matching_patterns("bastion");
4339        assert!(matches.is_empty());
4340    }
4341
4342    #[test]
4343    fn pattern_entries_and_host_entries_are_disjoint() {
4344        let config = parse_str(
4345            "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n",
4346        );
4347        let hosts = config.host_entries();
4348        let patterns = config.pattern_entries();
4349        assert_eq!(hosts.len(), 1);
4350        assert_eq!(hosts[0].alias, "myserver");
4351        assert_eq!(patterns.len(), 2);
4352        assert_eq!(patterns[0].pattern, "10.30.0.*");
4353        assert_eq!(patterns[1].pattern, "*");
4354    }
4355
4356    #[test]
4357    fn pattern_crud_round_trip() {
4358        let mut config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
4359        // Add a pattern via HostEntry (the form uses HostEntry for submission)
4360        let entry = HostEntry {
4361            alias: "10.30.0.*".to_string(),
4362            user: "debian".to_string(),
4363            ..Default::default()
4364        };
4365        config.add_host(&entry);
4366        let output = config.serialize();
4367        assert!(output.contains("Host 10.30.0.*"));
4368        assert!(output.contains("User debian"));
4369        // Verify it appears in pattern_entries, not host_entries
4370        let reparsed = parse_str(&output);
4371        assert_eq!(reparsed.host_entries().len(), 1);
4372        assert_eq!(reparsed.pattern_entries().len(), 1);
4373        assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
4374    }
4375
4376    #[test]
4377    fn host_entries_inherit_proxy_jump_from_wildcard_pattern() {
4378        // Host "web-*" defines ProxyJump bastion. Host "web-prod" should inherit it.
4379        let config =
4380            parse_str("Host web-*\n  ProxyJump bastion\n\nHost web-prod\n  Hostname 10.0.0.1\n");
4381        let hosts = config.host_entries();
4382        assert_eq!(hosts.len(), 1);
4383        assert_eq!(hosts[0].alias, "web-prod");
4384        assert_eq!(hosts[0].proxy_jump, "bastion");
4385    }
4386
4387    #[test]
4388    fn host_entries_inherit_proxy_jump_from_star_pattern() {
4389        // Host "*" defines ProxyJump bastion. All hosts without their own ProxyJump inherit it.
4390        let config = parse_str(
4391            "Host myserver\n  Hostname 10.0.0.1\n\nHost *\n  ProxyJump gateway\n  User admin\n",
4392        );
4393        let hosts = config.host_entries();
4394        assert_eq!(hosts.len(), 1);
4395        assert_eq!(hosts[0].proxy_jump, "gateway");
4396        assert_eq!(hosts[0].user, "admin");
4397    }
4398
4399    #[test]
4400    fn host_entries_own_proxy_jump_takes_precedence() {
4401        // Host's own ProxyJump should not be overridden by pattern.
4402        let config = parse_str(
4403            "Host web-*\n  ProxyJump gateway\n\nHost web-prod\n  Hostname 10.0.0.1\n  ProxyJump bastion\n",
4404        );
4405        let hosts = config.host_entries();
4406        assert_eq!(hosts.len(), 1);
4407        assert_eq!(hosts[0].proxy_jump, "bastion"); // own value, not gateway
4408    }
4409
4410    #[test]
4411    fn host_entries_hostname_pattern_does_not_match_by_hostname() {
4412        // SSH Host patterns match alias only, not Hostname. Pattern "10.30.0.*"
4413        // should NOT match alias "myserver" even though Hostname is 10.30.0.5.
4414        let config = parse_str(
4415            "Host 10.30.0.*\n  ProxyJump bastion\n  User debian\n\nHost myserver\n  Hostname 10.30.0.5\n",
4416        );
4417        let hosts = config.host_entries();
4418        assert_eq!(hosts.len(), 1);
4419        assert_eq!(hosts[0].alias, "myserver");
4420        assert_eq!(hosts[0].proxy_jump, ""); // no match — alias doesn't match pattern
4421        assert_eq!(hosts[0].user, ""); // no match
4422    }
4423
4424    #[test]
4425    fn host_entries_first_match_wins() {
4426        // Two patterns match: first one's value should win.
4427        let config = parse_str(
4428            "Host web-*\n  User team\n\nHost *\n  User fallback\n\nHost web-prod\n  Hostname 10.0.0.1\n",
4429        );
4430        let hosts = config.host_entries();
4431        assert_eq!(hosts.len(), 1);
4432        assert_eq!(hosts[0].user, "team"); // web-* matches first
4433    }
4434
4435    #[test]
4436    fn host_entries_no_inheritance_when_all_set() {
4437        // Host has all inheritable fields set. No pattern should override.
4438        let config = parse_str(
4439            "Host *\n  User fallback\n  ProxyJump gw\n  IdentityFile ~/.ssh/other\n\n\
4440             Host myserver\n  Hostname 10.0.0.1\n  User root\n  ProxyJump bastion\n  IdentityFile ~/.ssh/mine\n",
4441        );
4442        let hosts = config.host_entries();
4443        assert_eq!(hosts.len(), 1);
4444        assert_eq!(hosts[0].user, "root");
4445        assert_eq!(hosts[0].proxy_jump, "bastion");
4446        assert_eq!(hosts[0].identity_file, "~/.ssh/mine");
4447    }
4448
4449    #[test]
4450    fn host_entries_negation_excludes_from_inheritance() {
4451        // "Host * !bastion" should NOT apply to bastion.
4452        let config = parse_str(
4453            "Host * !bastion\n  ProxyJump gateway\n\nHost bastion\n  Hostname 10.0.0.1\n",
4454        );
4455        let hosts = config.host_entries();
4456        assert_eq!(hosts.len(), 1);
4457        assert_eq!(hosts[0].alias, "bastion");
4458        assert_eq!(hosts[0].proxy_jump, ""); // excluded by negation
4459    }
4460
4461    #[test]
4462    fn host_entries_inherit_identity_file_from_pattern() {
4463        // Positive test: IdentityFile inherited when host block lacks it.
4464        let config = parse_str(
4465            "Host *\n  IdentityFile ~/.ssh/default_key\n\nHost myserver\n  Hostname 10.0.0.1\n",
4466        );
4467        let hosts = config.host_entries();
4468        assert_eq!(hosts.len(), 1);
4469        assert_eq!(hosts[0].identity_file, "~/.ssh/default_key");
4470    }
4471
4472    #[test]
4473    fn host_entries_multiple_hosts_mixed_inheritance() {
4474        // Three hosts: one inherits ProxyJump, one has its own, one is the bastion.
4475        let config = parse_str(
4476            "Host web-*\n  ProxyJump bastion\n\n\
4477             Host web-prod\n  Hostname 10.0.0.1\n\n\
4478             Host web-staging\n  Hostname 10.0.0.2\n  ProxyJump gateway\n\n\
4479             Host bastion\n  Hostname 10.0.0.99\n",
4480        );
4481        let hosts = config.host_entries();
4482        assert_eq!(hosts.len(), 3);
4483        let prod = hosts.iter().find(|h| h.alias == "web-prod").unwrap();
4484        let staging = hosts.iter().find(|h| h.alias == "web-staging").unwrap();
4485        let bastion = hosts.iter().find(|h| h.alias == "bastion").unwrap();
4486        assert_eq!(prod.proxy_jump, "bastion"); // inherited
4487        assert_eq!(staging.proxy_jump, "gateway"); // own value
4488        assert_eq!(bastion.proxy_jump, ""); // no match
4489    }
4490
4491    #[test]
4492    fn host_entries_partial_inheritance() {
4493        // Host has ProxyJump and User set, but no IdentityFile. Only IdentityFile inherited.
4494        let config = parse_str(
4495            "Host *\n  User fallback\n  ProxyJump gw\n  IdentityFile ~/.ssh/default\n\n\
4496             Host myserver\n  Hostname 10.0.0.1\n  User root\n  ProxyJump bastion\n",
4497        );
4498        let hosts = config.host_entries();
4499        assert_eq!(hosts.len(), 1);
4500        assert_eq!(hosts[0].user, "root"); // own
4501        assert_eq!(hosts[0].proxy_jump, "bastion"); // own
4502        assert_eq!(hosts[0].identity_file, "~/.ssh/default"); // inherited
4503    }
4504
4505    #[test]
4506    fn host_entries_alias_is_ip_matches_ip_pattern() {
4507        // When alias itself is an IP, it matches IP-based patterns directly.
4508        let config =
4509            parse_str("Host 10.0.0.*\n  ProxyJump bastion\n\nHost 10.0.0.5\n  User root\n");
4510        let hosts = config.host_entries();
4511        assert_eq!(hosts.len(), 1);
4512        assert_eq!(hosts[0].alias, "10.0.0.5");
4513        assert_eq!(hosts[0].proxy_jump, "bastion");
4514    }
4515
4516    #[test]
4517    fn host_entries_no_hostname_still_inherits_by_alias() {
4518        // Host without Hostname directive still inherits via alias matching.
4519        let config = parse_str("Host *\n  User admin\n\nHost myserver\n  Port 2222\n");
4520        let hosts = config.host_entries();
4521        assert_eq!(hosts.len(), 1);
4522        assert_eq!(hosts[0].user, "admin"); // inherited via alias match on "*"
4523        assert!(hosts[0].hostname.is_empty()); // no hostname set
4524    }
4525
4526    #[test]
4527    fn host_entries_self_referencing_proxy_jump_assigned() {
4528        // Self-referencing ProxyJump IS assigned (SSH would do the same).
4529        // The UI detects and warns via proxy_jump_contains_self.
4530        let config = parse_str(
4531            "Host *\n  ProxyJump gateway\n\n\
4532             Host gateway\n  Hostname 10.0.0.1\n\n\
4533             Host backend\n  Hostname 10.0.0.2\n",
4534        );
4535        let hosts = config.host_entries();
4536        let gateway = hosts.iter().find(|h| h.alias == "gateway").unwrap();
4537        let backend = hosts.iter().find(|h| h.alias == "backend").unwrap();
4538        assert_eq!(gateway.proxy_jump, "gateway"); // self-ref assigned
4539        assert_eq!(backend.proxy_jump, "gateway");
4540        // Detection helper identifies the loop.
4541        assert!(proxy_jump_contains_self(
4542            &gateway.proxy_jump,
4543            &gateway.alias
4544        ));
4545        assert!(!proxy_jump_contains_self(
4546            &backend.proxy_jump,
4547            &backend.alias
4548        ));
4549    }
4550
4551    #[test]
4552    fn proxy_jump_contains_self_comma_separated() {
4553        assert!(proxy_jump_contains_self("hop1,gateway", "gateway"));
4554        assert!(proxy_jump_contains_self("gateway,hop2", "gateway"));
4555        assert!(proxy_jump_contains_self("hop1, gateway", "gateway"));
4556        assert!(proxy_jump_contains_self("gateway", "gateway"));
4557        assert!(!proxy_jump_contains_self("hop1,hop2", "gateway"));
4558        assert!(!proxy_jump_contains_self("", "gateway"));
4559        assert!(!proxy_jump_contains_self("gateway-2", "gateway"));
4560        // user@host and host:port forms
4561        assert!(proxy_jump_contains_self("admin@gateway", "gateway"));
4562        assert!(proxy_jump_contains_self("gateway:2222", "gateway"));
4563        assert!(proxy_jump_contains_self("admin@gateway:2222", "gateway"));
4564        assert!(proxy_jump_contains_self(
4565            "hop1,admin@gateway:2222",
4566            "gateway"
4567        ));
4568        assert!(!proxy_jump_contains_self("admin@gateway-2", "gateway"));
4569        assert!(!proxy_jump_contains_self("admin@other:2222", "gateway"));
4570        // IPv6 bracket notation
4571        assert!(proxy_jump_contains_self("[::1]:2222", "::1"));
4572        assert!(proxy_jump_contains_self("user@[::1]:2222", "::1"));
4573        assert!(!proxy_jump_contains_self("[::2]:2222", "::1"));
4574        assert!(proxy_jump_contains_self("hop1,[::1]:2222", "::1"));
4575    }
4576
4577    // =========================================================================
4578    // raw_host_entry tests
4579    // =========================================================================
4580
4581    #[test]
4582    fn raw_host_entry_returns_without_inheritance() {
4583        let config = parse_str(
4584            "Host *\n  ProxyJump gw\n  User admin\n\nHost myserver\n  Hostname 10.0.0.1\n",
4585        );
4586        let raw = config.raw_host_entry("myserver").unwrap();
4587        assert_eq!(raw.alias, "myserver");
4588        assert_eq!(raw.hostname, "10.0.0.1");
4589        assert_eq!(raw.proxy_jump, ""); // not inherited
4590        assert_eq!(raw.user, ""); // not inherited
4591        // Contrast with host_entries which applies inheritance:
4592        let enriched = config.host_entries();
4593        assert_eq!(enriched[0].proxy_jump, "gw");
4594        assert_eq!(enriched[0].user, "admin");
4595    }
4596
4597    #[test]
4598    fn raw_host_entry_preserves_own_values() {
4599        let config = parse_str(
4600            "Host *\n  ProxyJump gw\n\nHost myserver\n  Hostname 10.0.0.1\n  ProxyJump bastion\n",
4601        );
4602        let raw = config.raw_host_entry("myserver").unwrap();
4603        assert_eq!(raw.proxy_jump, "bastion"); // own value preserved
4604    }
4605
4606    #[test]
4607    fn raw_host_entry_returns_none_for_missing() {
4608        let config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
4609        assert!(config.raw_host_entry("nonexistent").is_none());
4610    }
4611
4612    #[test]
4613    fn raw_host_entry_returns_none_for_pattern() {
4614        let config = parse_str("Host 10.30.0.*\n  ProxyJump bastion\n");
4615        assert!(config.raw_host_entry("10.30.0.*").is_none());
4616    }
4617
4618    // =========================================================================
4619    // inherited_hints tests
4620    // =========================================================================
4621
4622    #[test]
4623    fn inherited_hints_returns_value_and_source() {
4624        let config = parse_str(
4625            "Host web-*\n  ProxyJump bastion\n  User team\n\nHost web-prod\n  Hostname 10.0.0.1\n",
4626        );
4627        let hints = config.inherited_hints("web-prod");
4628        let (val, src) = hints.proxy_jump.unwrap();
4629        assert_eq!(val, "bastion");
4630        assert_eq!(src, "web-*");
4631        let (val, src) = hints.user.unwrap();
4632        assert_eq!(val, "team");
4633        assert_eq!(src, "web-*");
4634        assert!(hints.identity_file.is_none());
4635    }
4636
4637    #[test]
4638    fn inherited_hints_first_match_wins_with_source() {
4639        let config = parse_str(
4640            "Host web-*\n  User team\n\nHost *\n  User fallback\n  ProxyJump gw\n\nHost web-prod\n  Hostname 10.0.0.1\n",
4641        );
4642        let hints = config.inherited_hints("web-prod");
4643        // User comes from web-* (first match), not * (second match).
4644        let (val, src) = hints.user.unwrap();
4645        assert_eq!(val, "team");
4646        assert_eq!(src, "web-*");
4647        // ProxyJump comes from * (only source).
4648        let (val, src) = hints.proxy_jump.unwrap();
4649        assert_eq!(val, "gw");
4650        assert_eq!(src, "*");
4651    }
4652
4653    #[test]
4654    fn inherited_hints_no_match_returns_default() {
4655        let config =
4656            parse_str("Host web-*\n  ProxyJump bastion\n\nHost myserver\n  Hostname 10.0.0.1\n");
4657        let hints = config.inherited_hints("myserver");
4658        // "myserver" does not match "web-*"
4659        assert!(hints.proxy_jump.is_none());
4660        assert!(hints.user.is_none());
4661        assert!(hints.identity_file.is_none());
4662    }
4663
4664    #[test]
4665    fn inherited_hints_partial_fields_from_different_patterns() {
4666        let config = parse_str(
4667            "Host web-*\n  ProxyJump bastion\n\nHost *\n  IdentityFile ~/.ssh/default\n\nHost web-prod\n  Hostname 10.0.0.1\n",
4668        );
4669        let hints = config.inherited_hints("web-prod");
4670        let (val, src) = hints.proxy_jump.unwrap();
4671        assert_eq!(val, "bastion");
4672        assert_eq!(src, "web-*");
4673        let (val, src) = hints.identity_file.unwrap();
4674        assert_eq!(val, "~/.ssh/default");
4675        assert_eq!(src, "*");
4676        assert!(hints.user.is_none());
4677    }
4678
4679    #[test]
4680    fn inherited_hints_negation_excludes() {
4681        // "Host * !bastion" should NOT produce hints for "bastion".
4682        let config = parse_str(
4683            "Host * !bastion\n  ProxyJump gateway\n  User admin\n\n\
4684             Host bastion\n  Hostname 10.0.0.1\n",
4685        );
4686        let hints = config.inherited_hints("bastion");
4687        assert!(hints.proxy_jump.is_none());
4688        assert!(hints.user.is_none());
4689    }
4690
4691    #[test]
4692    fn inherited_hints_returned_even_when_host_has_own_values() {
4693        // inherited_hints is independent of the host's own values — it reports
4694        // what patterns provide. The form decides visibility via value.is_empty().
4695        let config = parse_str(
4696            "Host *\n  ProxyJump gateway\n  User admin\n\n\
4697             Host myserver\n  Hostname 10.0.0.1\n  ProxyJump bastion\n  User root\n",
4698        );
4699        let hints = config.inherited_hints("myserver");
4700        // Hints are returned even though host has own ProxyJump and User.
4701        let (val, _) = hints.proxy_jump.unwrap();
4702        assert_eq!(val, "gateway");
4703        let (val, _) = hints.user.unwrap();
4704        assert_eq!(val, "admin");
4705    }
4706
4707    #[test]
4708    fn inheritance_across_include_boundary() {
4709        // Pattern in an included file applies to a host in the main config.
4710        let included_elements =
4711            SshConfigFile::parse_content("Host web-*\n  ProxyJump bastion\n  User team\n");
4712        let main_elements = vec![
4713            ConfigElement::Include(IncludeDirective {
4714                raw_line: "Include conf.d/*".to_string(),
4715                pattern: "conf.d/*".to_string(),
4716                resolved_files: vec![IncludedFile {
4717                    path: PathBuf::from("/etc/ssh/conf.d/patterns.conf"),
4718                    elements: included_elements,
4719                }],
4720            }),
4721            // Host in main config, after the include.
4722            ConfigElement::HostBlock(HostBlock {
4723                host_pattern: "web-prod".to_string(),
4724                raw_host_line: "Host web-prod".to_string(),
4725                directives: vec![Directive {
4726                    key: "HostName".to_string(),
4727                    value: "10.0.0.1".to_string(),
4728                    raw_line: "  HostName 10.0.0.1".to_string(),
4729                    is_non_directive: false,
4730                }],
4731            }),
4732        ];
4733        let config = SshConfigFile {
4734            elements: main_elements,
4735            path: PathBuf::from("/tmp/test_config"),
4736            crlf: false,
4737            bom: false,
4738        };
4739        // host_entries should inherit from the included pattern.
4740        let hosts = config.host_entries();
4741        assert_eq!(hosts.len(), 1);
4742        assert_eq!(hosts[0].alias, "web-prod");
4743        assert_eq!(hosts[0].proxy_jump, "bastion");
4744        assert_eq!(hosts[0].user, "team");
4745        // inherited_hints should also find the included pattern.
4746        let hints = config.inherited_hints("web-prod");
4747        let (val, src) = hints.proxy_jump.unwrap();
4748        assert_eq!(val, "bastion");
4749        assert_eq!(src, "web-*");
4750    }
4751
4752    #[test]
4753    fn inheritance_host_in_include_pattern_in_main() {
4754        // Host in an included file, pattern in main config.
4755        let included_elements =
4756            SshConfigFile::parse_content("Host web-prod\n  HostName 10.0.0.1\n");
4757        let mut main_elements = SshConfigFile::parse_content("Host web-*\n  ProxyJump bastion\n");
4758        main_elements.push(ConfigElement::Include(IncludeDirective {
4759            raw_line: "Include conf.d/*".to_string(),
4760            pattern: "conf.d/*".to_string(),
4761            resolved_files: vec![IncludedFile {
4762                path: PathBuf::from("/etc/ssh/conf.d/hosts.conf"),
4763                elements: included_elements,
4764            }],
4765        }));
4766        let config = SshConfigFile {
4767            elements: main_elements,
4768            path: PathBuf::from("/tmp/test_config"),
4769            crlf: false,
4770            bom: false,
4771        };
4772        let hosts = config.host_entries();
4773        assert_eq!(hosts.len(), 1);
4774        assert_eq!(hosts[0].alias, "web-prod");
4775        assert_eq!(hosts[0].proxy_jump, "bastion");
4776    }
4777
4778    #[test]
4779    fn matching_patterns_full_ssh_semantics() {
4780        let config = parse_str(
4781            "Host 10.30.0.*\n  User debian\n  IdentityFile ~/.ssh/id_bootstrap\n  ProxyJump bastion\n\n\
4782             Host *.internal !secret.internal\n  ForwardAgent yes\n\n\
4783             Host myserver\n  Hostname 10.30.0.5\n\n\
4784             Host *\n  ServerAliveInterval 60\n",
4785        );
4786        // "myserver" only matches "*" (alias-only, not hostname)
4787        let matches = config.matching_patterns("myserver");
4788        assert_eq!(matches.len(), 1);
4789        assert_eq!(matches[0].pattern, "*");
4790        assert!(
4791            matches[0]
4792                .directives
4793                .iter()
4794                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4795        );
4796    }
4797
4798    #[test]
4799    fn pattern_entries_preserve_all_directives() {
4800        let config = parse_str(
4801            "Host *.example.com\n  User admin\n  Port 2222\n  IdentityFile ~/.ssh/id_example\n  ProxyJump gateway\n  ServerAliveInterval 30\n  ForwardAgent yes\n",
4802        );
4803        let patterns = config.pattern_entries();
4804        assert_eq!(patterns.len(), 1);
4805        let p = &patterns[0];
4806        assert_eq!(p.pattern, "*.example.com");
4807        assert_eq!(p.user, "admin");
4808        assert_eq!(p.port, 2222);
4809        assert_eq!(p.identity_file, "~/.ssh/id_example");
4810        assert_eq!(p.proxy_jump, "gateway");
4811        // All directives should be in the directives vec
4812        assert_eq!(p.directives.len(), 6);
4813        assert!(
4814            p.directives
4815                .iter()
4816                .any(|(k, v)| k == "ForwardAgent" && v == "yes")
4817        );
4818        assert!(
4819            p.directives
4820                .iter()
4821                .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
4822        );
4823    }
4824
4825    // --- Pattern visibility tests ---
4826
4827    #[test]
4828    fn roundtrip_pattern_blocks_preserved() {
4829        let input = "Host myserver\n  Hostname 10.0.0.1\n  User root\n\nHost 10.30.0.*\n  User debian\n  IdentityFile ~/.ssh/id_bootstrap\n  ProxyJump bastion\n\nHost *\n  ServerAliveInterval 60\n  AddKeysToAgent yes\n";
4830        let config = parse_str(input);
4831        let output = config.serialize();
4832        assert_eq!(
4833            input, output,
4834            "Pattern blocks must survive round-trip exactly"
4835        );
4836    }
4837
4838    #[test]
4839    fn add_pattern_preserves_existing_config() {
4840        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost otherserver\n  Hostname 10.0.0.2\n\nHost *\n  ServerAliveInterval 60\n";
4841        let mut config = parse_str(input);
4842        let entry = HostEntry {
4843            alias: "10.30.0.*".to_string(),
4844            user: "debian".to_string(),
4845            ..Default::default()
4846        };
4847        config.add_host(&entry);
4848        let output = config.serialize();
4849        // Original hosts must still be there
4850        assert!(output.contains("Host myserver"));
4851        assert!(output.contains("Hostname 10.0.0.1"));
4852        assert!(output.contains("Host otherserver"));
4853        assert!(output.contains("Hostname 10.0.0.2"));
4854        // New pattern must be present
4855        assert!(output.contains("Host 10.30.0.*"));
4856        assert!(output.contains("User debian"));
4857        // Host * must still be at the end
4858        assert!(output.contains("Host *"));
4859        // New pattern must be BEFORE Host * (SSH first-match-wins)
4860        let new_pos = output.find("Host 10.30.0.*").unwrap();
4861        let star_pos = output.find("Host *").unwrap();
4862        assert!(new_pos < star_pos, "New pattern must be before Host *");
4863        // Reparse and verify counts
4864        let reparsed = parse_str(&output);
4865        assert_eq!(reparsed.host_entries().len(), 2);
4866        assert_eq!(reparsed.pattern_entries().len(), 2); // 10.30.0.* and *
4867    }
4868
4869    #[test]
4870    fn update_pattern_preserves_other_blocks() {
4871        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
4872        let mut config = parse_str(input);
4873        let updated = HostEntry {
4874            alias: "10.30.0.*".to_string(),
4875            user: "admin".to_string(),
4876            ..Default::default()
4877        };
4878        config.update_host("10.30.0.*", &updated);
4879        let output = config.serialize();
4880        // Pattern updated
4881        assert!(output.contains("User admin"));
4882        assert!(!output.contains("User debian"));
4883        // Other blocks unchanged
4884        assert!(output.contains("Host myserver"));
4885        assert!(output.contains("Hostname 10.0.0.1"));
4886        assert!(output.contains("Host *"));
4887        assert!(output.contains("ServerAliveInterval 60"));
4888    }
4889
4890    #[test]
4891    fn delete_pattern_preserves_other_blocks() {
4892        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
4893        let mut config = parse_str(input);
4894        config.delete_host("10.30.0.*");
4895        let output = config.serialize();
4896        assert!(!output.contains("Host 10.30.0.*"));
4897        assert!(!output.contains("User debian"));
4898        assert!(output.contains("Host myserver"));
4899        assert!(output.contains("Hostname 10.0.0.1"));
4900        assert!(output.contains("Host *"));
4901        assert!(output.contains("ServerAliveInterval 60"));
4902        let reparsed = parse_str(&output);
4903        assert_eq!(reparsed.host_entries().len(), 1);
4904        assert_eq!(reparsed.pattern_entries().len(), 1); // only Host *
4905    }
4906
4907    #[test]
4908    fn update_pattern_rename() {
4909        let input = "Host *.example.com\n  User admin\n\nHost myserver\n  Hostname 10.0.0.1\n";
4910        let mut config = parse_str(input);
4911        let renamed = HostEntry {
4912            alias: "*.prod.example.com".to_string(),
4913            user: "admin".to_string(),
4914            ..Default::default()
4915        };
4916        config.update_host("*.example.com", &renamed);
4917        let output = config.serialize();
4918        assert!(
4919            !output.contains("Host *.example.com\n"),
4920            "Old pattern removed"
4921        );
4922        assert!(
4923            output.contains("Host *.prod.example.com"),
4924            "New pattern present"
4925        );
4926        assert!(output.contains("Host myserver"), "Other host preserved");
4927    }
4928
4929    #[test]
4930    fn config_with_only_patterns() {
4931        let input = "Host *.example.com\n  User admin\n\nHost *\n  ServerAliveInterval 60\n";
4932        let config = parse_str(input);
4933        assert!(config.host_entries().is_empty());
4934        assert_eq!(config.pattern_entries().len(), 2);
4935        // Round-trip
4936        let output = config.serialize();
4937        assert_eq!(input, output);
4938    }
4939
4940    #[test]
4941    fn host_pattern_matches_all_negative_returns_false() {
4942        assert!(!host_pattern_matches("!prod !staging", "anything"));
4943        assert!(!host_pattern_matches("!prod !staging", "dev"));
4944    }
4945
4946    #[test]
4947    fn host_pattern_matches_negation_only_checks_alias() {
4948        // Negation matches against alias only
4949        assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
4950        assert!(!host_pattern_matches("* !myserver", "myserver"));
4951    }
4952
4953    #[test]
4954    fn pattern_match_malformed_char_class() {
4955        // Unmatched bracket: should not panic, treat as no-match
4956        assert!(!ssh_pattern_match("[abc", "a"));
4957        assert!(!ssh_pattern_match("[", "a"));
4958        // Empty class body before ]
4959        assert!(!ssh_pattern_match("[]", "a"));
4960    }
4961
4962    #[test]
4963    fn host_pattern_matches_whitespace_edge_cases() {
4964        assert!(host_pattern_matches("prod  staging", "prod"));
4965        assert!(host_pattern_matches("  prod  ", "prod"));
4966        assert!(host_pattern_matches("prod\tstaging", "prod"));
4967        assert!(!host_pattern_matches("   ", "anything"));
4968        assert!(!host_pattern_matches("", "anything"));
4969    }
4970
4971    #[test]
4972    fn pattern_with_metadata_roundtrip() {
4973        let input = "Host 10.30.0.*\n  User debian\n  # purple:tags internal,vpn\n  # purple:askpass keychain\n\nHost myserver\n  Hostname 10.0.0.1\n";
4974        let config = parse_str(input);
4975        let patterns = config.pattern_entries();
4976        assert_eq!(patterns.len(), 1);
4977        assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
4978        assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
4979        // Round-trip
4980        let output = config.serialize();
4981        assert_eq!(input, output);
4982    }
4983
4984    #[test]
4985    fn matching_patterns_multiple_in_config_order() {
4986        // Use alias-based patterns that match the alias "my-10-server"
4987        let input = "Host my-*\n  User fallback\n\nHost my-10*\n  User team\n\nHost my-10-*\n  User specific\n\nHost other\n  Hostname 10.30.0.5\n\nHost *\n  ServerAliveInterval 60\n";
4988        let config = parse_str(input);
4989        let matches = config.matching_patterns("my-10-server");
4990        assert_eq!(matches.len(), 4);
4991        assert_eq!(matches[0].pattern, "my-*");
4992        assert_eq!(matches[1].pattern, "my-10*");
4993        assert_eq!(matches[2].pattern, "my-10-*");
4994        assert_eq!(matches[3].pattern, "*");
4995    }
4996
4997    #[test]
4998    fn add_pattern_to_empty_config() {
4999        let mut config = parse_str("");
5000        let entry = HostEntry {
5001            alias: "*.example.com".to_string(),
5002            user: "admin".to_string(),
5003            ..Default::default()
5004        };
5005        config.add_host(&entry);
5006        let output = config.serialize();
5007        assert!(output.contains("Host *.example.com"));
5008        assert!(output.contains("User admin"));
5009        let reparsed = parse_str(&output);
5010        assert!(reparsed.host_entries().is_empty());
5011        assert_eq!(reparsed.pattern_entries().len(), 1);
5012    }
5013
5014    #[test]
5015    fn vault_ssh_parsed_from_comment() {
5016        let config = parse_str(
5017            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/engineer\n",
5018        );
5019        let entries = config.host_entries();
5020        assert_eq!(entries[0].vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5021    }
5022
5023    // ---- vault_addr parse + set tests ----
5024
5025    #[test]
5026    fn vault_addr_parsed_from_comment() {
5027        let config = parse_str(
5028            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr http://127.0.0.1:8200\n",
5029        );
5030        let entries = config.host_entries();
5031        assert_eq!(
5032            entries[0].vault_addr.as_deref(),
5033            Some("http://127.0.0.1:8200")
5034        );
5035    }
5036
5037    #[test]
5038    fn vault_addr_none_when_absent() {
5039        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5040        assert!(config.host_entries()[0].vault_addr.is_none());
5041    }
5042
5043    #[test]
5044    fn vault_addr_empty_comment_ignored() {
5045        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr \n");
5046        assert!(config.host_entries()[0].vault_addr.is_none());
5047    }
5048
5049    #[test]
5050    fn vault_addr_with_whitespace_value_rejected() {
5051        let config = parse_str(
5052            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr http://a b:8200\n",
5053        );
5054        // The parser splits on the single space after `vault-addr`, so a
5055        // value that itself contains whitespace gets truncated at the first
5056        // space. `is_valid_vault_addr` additionally rejects any remaining
5057        // whitespace, so such a value never makes it past parse.
5058        assert!(
5059            config.host_entries()[0]
5060                .vault_addr
5061                .as_deref()
5062                .is_none_or(|v| !v.contains(' '))
5063        );
5064    }
5065
5066    #[test]
5067    fn vault_addr_round_trip_preserved() {
5068        let input = "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr https://vault.example:8200\n";
5069        let config = parse_str(input);
5070        assert_eq!(config.serialize(), input);
5071    }
5072
5073    #[test]
5074    fn set_vault_addr_adds_comment() {
5075        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5076        assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5077        assert_eq!(
5078            first_block(&config).vault_addr(),
5079            Some("http://127.0.0.1:8200".to_string())
5080        );
5081    }
5082
5083    #[test]
5084    fn set_vault_addr_replaces_existing() {
5085        let mut config = parse_str(
5086            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr http://old:8200\n",
5087        );
5088        assert!(config.set_host_vault_addr("myserver", "https://new:8200"));
5089        assert_eq!(
5090            first_block(&config).vault_addr(),
5091            Some("https://new:8200".to_string())
5092        );
5093        assert_eq!(
5094            config.serialize().matches("purple:vault-addr").count(),
5095            1,
5096            "Should have exactly one vault-addr comment after replace"
5097        );
5098    }
5099
5100    #[test]
5101    fn set_vault_addr_empty_removes() {
5102        let mut config = parse_str(
5103            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr http://127.0.0.1:8200\n",
5104        );
5105        assert!(config.set_host_vault_addr("myserver", ""));
5106        assert!(first_block(&config).vault_addr().is_none());
5107        assert!(!config.serialize().contains("vault-addr"));
5108    }
5109
5110    #[test]
5111    fn set_vault_addr_preserves_other_comments() {
5112        let mut config = parse_str(
5113            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags a,b\n  # purple:vault-ssh ssh/sign/engineer\n",
5114        );
5115        assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5116        let entry = config.host_entries().into_iter().next().unwrap();
5117        assert_eq!(entry.vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5118        assert_eq!(entry.tags, vec!["a".to_string(), "b".to_string()]);
5119        assert_eq!(entry.vault_addr.as_deref(), Some("http://127.0.0.1:8200"));
5120    }
5121
5122    #[test]
5123    fn set_vault_addr_preserves_indent() {
5124        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
5125        assert!(config.set_host_vault_addr("myserver", "http://127.0.0.1:8200"));
5126        let serialized = config.serialize();
5127        assert!(
5128            serialized.contains("    # purple:vault-addr http://127.0.0.1:8200"),
5129            "indent not preserved: {}",
5130            serialized
5131        );
5132    }
5133
5134    #[test]
5135    fn set_vault_addr_twice_replaces_not_appends() {
5136        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5137        assert!(config.set_host_vault_addr("myserver", "http://one:8200"));
5138        assert!(config.set_host_vault_addr("myserver", "http://two:8200"));
5139        let serialized = config.serialize();
5140        assert_eq!(
5141            serialized.matches("purple:vault-addr").count(),
5142            1,
5143            "Should have exactly one vault-addr comment"
5144        );
5145        assert!(serialized.contains("purple:vault-addr http://two:8200"));
5146    }
5147
5148    #[test]
5149    fn set_vault_addr_removes_duplicate_comments() {
5150        let mut config = parse_str(
5151            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-addr http://a:8200\n  # purple:vault-addr http://b:8200\n",
5152        );
5153        assert!(config.set_host_vault_addr("myserver", "http://c:8200"));
5154        assert_eq!(
5155            config.serialize().matches("purple:vault-addr").count(),
5156            1,
5157            "duplicate comments must collapse on rewrite"
5158        );
5159        assert_eq!(
5160            first_block(&config).vault_addr(),
5161            Some("http://c:8200".to_string())
5162        );
5163    }
5164
5165    #[test]
5166    fn set_host_vault_addr_returns_false_when_alias_missing() {
5167        let mut config = parse_str("Host alpha\n  HostName 10.0.0.1\n");
5168        assert!(!config.set_host_vault_addr("ghost", "http://127.0.0.1:8200"));
5169        // Config unchanged
5170        assert_eq!(config.serialize(), "Host alpha\n  HostName 10.0.0.1\n");
5171    }
5172
5173    #[test]
5174    fn set_host_vault_addr_refuses_wildcard_alias() {
5175        let mut config = parse_str("Host *\n  HostName 10.0.0.1\n");
5176        assert!(!config.set_host_vault_addr("*", "http://127.0.0.1:8200"));
5177        assert!(!config.set_host_vault_addr("", "http://127.0.0.1:8200"));
5178        assert!(!config.set_host_vault_addr("a?b", "http://127.0.0.1:8200"));
5179        assert!(!config.set_host_vault_addr("a[bc]", "http://127.0.0.1:8200"));
5180        assert!(!config.set_host_vault_addr("!a", "http://127.0.0.1:8200"));
5181        // Multi-host patterns use whitespace separators. Refuse those too
5182        // so a caller cannot accidentally target a multi-host block.
5183        assert!(!config.set_host_vault_addr("web-* db-*", "http://127.0.0.1:8200"));
5184        assert!(!config.set_host_vault_addr("a b", "http://127.0.0.1:8200"));
5185        assert!(!config.set_host_vault_addr("a\tb", "http://127.0.0.1:8200"));
5186    }
5187
5188    // ---- end vault_addr tests ----
5189
5190    #[test]
5191    fn vault_ssh_none_when_absent() {
5192        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5193        assert!(config.host_entries()[0].vault_ssh.is_none());
5194    }
5195
5196    #[test]
5197    fn vault_ssh_empty_comment_ignored() {
5198        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh \n");
5199        assert!(config.host_entries()[0].vault_ssh.is_none());
5200    }
5201
5202    #[test]
5203    fn vault_ssh_round_trip_preserved() {
5204        let input = "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/engineer\n";
5205        let config = parse_str(input);
5206        assert_eq!(config.serialize(), input);
5207    }
5208
5209    #[test]
5210    fn set_vault_ssh_adds_comment() {
5211        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5212        config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5213        assert_eq!(
5214            first_block(&config).vault_ssh(),
5215            Some("ssh/sign/engineer".to_string())
5216        );
5217    }
5218
5219    #[test]
5220    fn set_vault_ssh_replaces_existing() {
5221        let mut config =
5222            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/old\n");
5223        config.set_host_vault_ssh("myserver", "ssh/sign/new");
5224        assert_eq!(
5225            first_block(&config).vault_ssh(),
5226            Some("ssh/sign/new".to_string())
5227        );
5228        assert_eq!(
5229            config.serialize().matches("purple:vault-ssh").count(),
5230            1,
5231            "Should have exactly one vault-ssh comment"
5232        );
5233    }
5234
5235    #[test]
5236    fn set_vault_ssh_empty_removes() {
5237        let mut config =
5238            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/old\n");
5239        config.set_host_vault_ssh("myserver", "");
5240        assert!(first_block(&config).vault_ssh().is_none());
5241        assert!(!config.serialize().contains("vault-ssh"));
5242    }
5243
5244    #[test]
5245    fn set_vault_ssh_preserves_other_comments() {
5246        let mut config = parse_str(
5247            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n  # purple:tags prod\n",
5248        );
5249        config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5250        let entry = first_block(&config).to_host_entry();
5251        assert_eq!(entry.askpass, Some("keychain".to_string()));
5252        assert!(entry.tags.contains(&"prod".to_string()));
5253        assert_eq!(entry.vault_ssh.as_deref(), Some("ssh/sign/engineer"));
5254    }
5255
5256    #[test]
5257    fn set_vault_ssh_preserves_indent() {
5258        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
5259        config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5260        let raw = first_block(&config)
5261            .directives
5262            .iter()
5263            .find(|d| d.raw_line.contains("purple:vault-ssh"))
5264            .unwrap();
5265        assert!(
5266            raw.raw_line.starts_with("    "),
5267            "Expected 4-space indent, got: {:?}",
5268            raw.raw_line
5269        );
5270    }
5271
5272    #[test]
5273    fn certificate_file_parsed_from_directive() {
5274        let config =
5275            parse_str("Host myserver\n  HostName 10.0.0.1\n  CertificateFile ~/.ssh/my-cert.pub\n");
5276        let entries = config.host_entries();
5277        assert_eq!(entries[0].certificate_file, "~/.ssh/my-cert.pub");
5278    }
5279
5280    #[test]
5281    fn certificate_file_empty_when_absent() {
5282        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5283        let entries = config.host_entries();
5284        assert!(entries[0].certificate_file.is_empty());
5285    }
5286
5287    #[test]
5288    fn set_host_certificate_file_adds_and_removes() {
5289        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5290        assert!(config.set_host_certificate_file("myserver", "~/.purple/certs/myserver-cert.pub"));
5291        assert!(
5292            config
5293                .serialize()
5294                .contains("CertificateFile ~/.purple/certs/myserver-cert.pub")
5295        );
5296        assert!(config.set_host_certificate_file("myserver", ""));
5297        assert!(!config.serialize().contains("CertificateFile"));
5298    }
5299
5300    #[test]
5301    fn set_host_certificate_file_removes_when_empty() {
5302        let mut config = parse_str(
5303            "Host myserver\n  HostName 10.0.0.1\n  CertificateFile ~/.purple/certs/myserver-cert.pub\n",
5304        );
5305        assert!(config.set_host_certificate_file("myserver", ""));
5306        assert!(!config.serialize().contains("CertificateFile"));
5307    }
5308
5309    #[test]
5310    fn set_host_certificate_file_returns_false_when_alias_missing() {
5311        let mut config = parse_str("Host alpha\n  HostName 10.0.0.1\n");
5312        assert!(!config.set_host_certificate_file("ghost", "/tmp/cert.pub"));
5313        // Config unchanged
5314        assert_eq!(config.serialize(), "Host alpha\n  HostName 10.0.0.1\n");
5315    }
5316
5317    #[test]
5318    fn set_host_certificate_file_ignores_match_blocks() {
5319        // Match blocks are stored as GlobalLines; a `CertificateFile` directive
5320        // inside a Match block is never the target of set_host_certificate_file,
5321        // even if the pattern would "match" the alias.
5322        let input = "\
5323Host alpha
5324  HostName 10.0.0.1
5325
5326Match host alpha
5327  CertificateFile /user/set/match-cert.pub
5328";
5329        let mut config = parse_str(input);
5330        assert!(config.set_host_certificate_file("alpha", "/purple/managed.pub"));
5331        let out = config.serialize();
5332        // Top-level alpha block got the directive
5333        assert!(
5334            out.contains("Host alpha\n  HostName 10.0.0.1\n  CertificateFile /purple/managed.pub")
5335        );
5336        // Match block's own CertificateFile is untouched
5337        assert!(out.contains("Match host alpha\n  CertificateFile /user/set/match-cert.pub"));
5338    }
5339
5340    #[test]
5341    fn set_vault_ssh_twice_replaces_not_appends() {
5342        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
5343        config.set_host_vault_ssh("myserver", "ssh/sign/one");
5344        config.set_host_vault_ssh("myserver", "ssh/sign/two");
5345        let serialized = config.serialize();
5346        assert_eq!(
5347            serialized.matches("purple:vault-ssh").count(),
5348            1,
5349            "expected a single comment after two calls, got: {}",
5350            serialized
5351        );
5352        assert!(serialized.contains("purple:vault-ssh ssh/sign/two"));
5353    }
5354
5355    #[test]
5356    fn vault_ssh_indentation_preserved_with_other_purple_comments() {
5357        let input = "Host myserver\n    HostName 10.0.0.1\n    # purple:tags prod,web\n";
5358        let mut config = parse_str(input);
5359        config.set_host_vault_ssh("myserver", "ssh/sign/engineer");
5360        let serialized = config.serialize();
5361        assert!(
5362            serialized.contains("    # purple:vault-ssh ssh/sign/engineer"),
5363            "indent preserved: {}",
5364            serialized
5365        );
5366        assert!(serialized.contains("    # purple:tags prod,web"));
5367    }
5368
5369    #[test]
5370    fn clear_vault_ssh_removes_comment_line() {
5371        let mut config =
5372            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/old\n");
5373        config.set_host_vault_ssh("myserver", "");
5374        let serialized = config.serialize();
5375        assert!(
5376            !serialized.contains("vault-ssh"),
5377            "comment should be gone: {}",
5378            serialized
5379        );
5380        assert!(first_block(&config).vault_ssh().is_none());
5381    }
5382
5383    #[test]
5384    fn set_vault_ssh_removes_duplicate_comments() {
5385        let mut config = parse_str(
5386            "Host myserver\n  HostName 10.0.0.1\n  # purple:vault-ssh ssh/sign/old1\n  # purple:vault-ssh ssh/sign/old2\n",
5387        );
5388        config.set_host_vault_ssh("myserver", "ssh/sign/new");
5389        assert_eq!(
5390            config.serialize().matches("purple:vault-ssh").count(),
5391            1,
5392            "Should have exactly one vault-ssh comment after set"
5393        );
5394        assert_eq!(
5395            first_block(&config).vault_ssh(),
5396            Some("ssh/sign/new".to_string())
5397        );
5398    }
5399}