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    /// Provider metadata from purple:meta comment (region, plan, etc.).
110    pub provider_meta: Vec<(String, String)>,
111    /// Unix timestamp when the host was marked stale (disappeared from provider sync).
112    pub stale: Option<u64>,
113}
114
115impl Default for HostEntry {
116    fn default() -> Self {
117        Self {
118            alias: String::new(),
119            hostname: String::new(),
120            user: String::new(),
121            port: 22,
122            identity_file: String::new(),
123            proxy_jump: String::new(),
124            source_file: None,
125            tags: Vec::new(),
126            provider_tags: Vec::new(),
127            has_provider_tags: false,
128            provider: None,
129            tunnel_count: 0,
130            askpass: None,
131            provider_meta: Vec::new(),
132            stale: None,
133        }
134    }
135}
136
137impl HostEntry {
138    /// Build the SSH command string for this host.
139    /// Includes `-F <config_path>` when the config is non-default so the alias
140    /// resolves correctly when pasted into a terminal.
141    /// Shell-quotes both the config path and alias to prevent injection.
142    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
143        let escaped = self.alias.replace('\'', "'\\''");
144        let default = dirs::home_dir()
145            .map(|h| h.join(".ssh/config"))
146            .unwrap_or_default();
147        if config_path == default {
148            format!("ssh -- '{}'", escaped)
149        } else {
150            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
151            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
152        }
153    }
154}
155
156/// Convenience view for pattern Host blocks in the TUI.
157#[derive(Debug, Clone, Default)]
158pub struct PatternEntry {
159    pub pattern: String,
160    pub hostname: String,
161    pub user: String,
162    pub port: u16,
163    pub identity_file: String,
164    pub proxy_jump: String,
165    pub tags: Vec<String>,
166    pub askpass: Option<String>,
167    pub source_file: Option<PathBuf>,
168    /// All non-comment directives as key-value pairs for display.
169    pub directives: Vec<(String, String)>,
170}
171
172/// Returns true if the host pattern contains wildcards, character classes,
173/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
174/// These are SSH match patterns, not concrete hosts.
175pub fn is_host_pattern(pattern: &str) -> bool {
176    pattern.contains('*')
177        || pattern.contains('?')
178        || pattern.contains('[')
179        || pattern.starts_with('!')
180        || pattern.contains(' ')
181        || pattern.contains('\t')
182}
183
184/// Match a text string against an SSH host pattern.
185/// Supports `*` (any sequence), `?` (single char), `[charset]` (character class),
186/// `[!charset]`/`[^charset]` (negated class), `[a-z]` (ranges) and `!pattern` (negation).
187pub fn ssh_pattern_match(pattern: &str, text: &str) -> bool {
188    if let Some(rest) = pattern.strip_prefix('!') {
189        return !match_glob(rest, text);
190    }
191    match_glob(pattern, text)
192}
193
194/// Core glob matcher without negation prefix handling.
195/// Empty text only matches empty pattern.
196fn match_glob(pattern: &str, text: &str) -> bool {
197    if text.is_empty() {
198        return pattern.is_empty();
199    }
200    if pattern.is_empty() {
201        return false;
202    }
203    let pat: Vec<char> = pattern.chars().collect();
204    let txt: Vec<char> = text.chars().collect();
205    glob_match(&pat, &txt)
206}
207
208/// Iterative glob matching with star-backtracking.
209fn glob_match(pat: &[char], txt: &[char]) -> bool {
210    let mut pi = 0;
211    let mut ti = 0;
212    let mut star: Option<(usize, usize)> = None; // (pattern_pos, text_pos)
213
214    while ti < txt.len() {
215        if pi < pat.len() && pat[pi] == '?' {
216            pi += 1;
217            ti += 1;
218        } else if pi < pat.len() && pat[pi] == '*' {
219            star = Some((pi + 1, ti));
220            pi += 1;
221        } else if pi < pat.len() && pat[pi] == '[' {
222            if let Some((matches, end)) = match_char_class(pat, pi, txt[ti]) {
223                if matches {
224                    pi = end;
225                    ti += 1;
226                } else if let Some((spi, sti)) = star {
227                    let sti = sti + 1;
228                    star = Some((spi, sti));
229                    pi = spi;
230                    ti = sti;
231                } else {
232                    return false;
233                }
234            } else if let Some((spi, sti)) = star {
235                // Malformed class: backtrack
236                let sti = sti + 1;
237                star = Some((spi, sti));
238                pi = spi;
239                ti = sti;
240            } else {
241                return false;
242            }
243        } else if pi < pat.len() && pat[pi] == txt[ti] {
244            pi += 1;
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    }
255
256    while pi < pat.len() && pat[pi] == '*' {
257        pi += 1;
258    }
259    pi == pat.len()
260}
261
262/// Parse and match a `[...]` character class starting at `pat[start]`.
263/// Returns `Some((matched, end_index))` where `end_index` is past `]`.
264/// Returns `None` if no closing `]` is found.
265fn match_char_class(pat: &[char], start: usize, ch: char) -> Option<(bool, usize)> {
266    let mut i = start + 1;
267    if i >= pat.len() {
268        return None;
269    }
270
271    let negate = pat[i] == '!' || pat[i] == '^';
272    if negate {
273        i += 1;
274    }
275
276    let mut matched = false;
277    while i < pat.len() && pat[i] != ']' {
278        if i + 2 < pat.len() && pat[i + 1] == '-' && pat[i + 2] != ']' {
279            let lo = pat[i];
280            let hi = pat[i + 2];
281            if ch >= lo && ch <= hi {
282                matched = true;
283            }
284            i += 3;
285        } else {
286            matched |= pat[i] == ch;
287            i += 1;
288        }
289    }
290
291    if i >= pat.len() {
292        return None;
293    }
294
295    let result = if negate { !matched } else { matched };
296    Some((result, i + 1))
297}
298
299/// Check whether a `Host` pattern matches a given alias.
300/// OpenSSH `Host` keyword matches only against the target alias typed on the
301/// command line, never against the resolved HostName.
302pub fn host_pattern_matches(host_pattern: &str, alias: &str) -> bool {
303    let patterns: Vec<&str> = host_pattern.split_whitespace().collect();
304    if patterns.is_empty() {
305        return false;
306    }
307
308    let mut any_positive_match = false;
309    for pat in &patterns {
310        if let Some(neg) = pat.strip_prefix('!') {
311            if match_glob(neg, alias) {
312                return false;
313            }
314        } else if ssh_pattern_match(pat, alias) {
315            any_positive_match = true;
316        }
317    }
318
319    any_positive_match
320}
321
322impl HostBlock {
323    /// Index of the first trailing blank line (for inserting content before separators).
324    fn content_end(&self) -> usize {
325        let mut pos = self.directives.len();
326        while pos > 0 {
327            if self.directives[pos - 1].is_non_directive
328                && self.directives[pos - 1].raw_line.trim().is_empty()
329            {
330                pos -= 1;
331            } else {
332                break;
333            }
334        }
335        pos
336    }
337
338    /// Remove and return trailing blank lines.
339    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
340        let end = self.content_end();
341        self.directives.drain(end..).collect()
342    }
343
344    /// Ensure exactly one trailing blank line.
345    fn ensure_trailing_blank(&mut self) {
346        self.pop_trailing_blanks();
347        self.directives.push(Directive {
348            key: String::new(),
349            value: String::new(),
350            raw_line: String::new(),
351            is_non_directive: true,
352        });
353    }
354
355    /// Detect indentation used by existing directives (falls back to "  ").
356    fn detect_indent(&self) -> String {
357        for d in &self.directives {
358            if !d.is_non_directive && !d.raw_line.is_empty() {
359                let trimmed = d.raw_line.trim_start();
360                let indent_len = d.raw_line.len() - trimmed.len();
361                if indent_len > 0 {
362                    return d.raw_line[..indent_len].to_string();
363                }
364            }
365        }
366        "  ".to_string()
367    }
368
369    /// Extract tags from purple:tags comment in directives.
370    pub fn tags(&self) -> Vec<String> {
371        for d in &self.directives {
372            if d.is_non_directive {
373                let trimmed = d.raw_line.trim();
374                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
375                    return rest
376                        .split(',')
377                        .map(|t| t.trim().to_string())
378                        .filter(|t| !t.is_empty())
379                        .collect();
380                }
381            }
382        }
383        Vec::new()
384    }
385
386    /// Extract provider-synced tags from purple:provider_tags comment.
387    pub fn provider_tags(&self) -> Vec<String> {
388        for d in &self.directives {
389            if d.is_non_directive {
390                let trimmed = d.raw_line.trim();
391                if let Some(rest) = trimmed.strip_prefix("# purple:provider_tags ") {
392                    return rest
393                        .split(',')
394                        .map(|t| t.trim().to_string())
395                        .filter(|t| !t.is_empty())
396                        .collect();
397                }
398            }
399        }
400        Vec::new()
401    }
402
403    /// Check if a purple:provider_tags comment exists (even if empty).
404    /// Used to distinguish "never migrated" from "migrated with no tags".
405    pub fn has_provider_tags_comment(&self) -> bool {
406        self.directives.iter().any(|d| {
407            d.is_non_directive && {
408                let t = d.raw_line.trim();
409                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
410            }
411        })
412    }
413
414    /// Extract provider info from purple:provider comment in directives.
415    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
416    pub fn provider(&self) -> Option<(String, String)> {
417        for d in &self.directives {
418            if d.is_non_directive {
419                let trimmed = d.raw_line.trim();
420                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
421                    if let Some((name, id)) = rest.split_once(':') {
422                        return Some((name.trim().to_string(), id.trim().to_string()));
423                    }
424                }
425            }
426        }
427        None
428    }
429
430    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
431    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
432        let indent = self.detect_indent();
433        self.directives.retain(|d| {
434            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider "))
435        });
436        let pos = self.content_end();
437        self.directives.insert(
438            pos,
439            Directive {
440                key: String::new(),
441                value: String::new(),
442                raw_line: format!(
443                    "{}# purple:provider {}:{}",
444                    indent, provider_name, server_id
445                ),
446                is_non_directive: true,
447            },
448        );
449    }
450
451    /// Extract askpass source from purple:askpass comment in directives.
452    pub fn askpass(&self) -> Option<String> {
453        for d in &self.directives {
454            if d.is_non_directive {
455                let trimmed = d.raw_line.trim();
456                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
457                    let val = rest.trim();
458                    if !val.is_empty() {
459                        return Some(val.to_string());
460                    }
461                }
462            }
463        }
464        None
465    }
466
467    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
468    /// Pass an empty string to remove the comment.
469    pub fn set_askpass(&mut self, source: &str) {
470        let indent = self.detect_indent();
471        self.directives.retain(|d| {
472            !(d.is_non_directive && {
473                let t = d.raw_line.trim();
474                t == "# purple:askpass" || t.starts_with("# purple:askpass ")
475            })
476        });
477        if !source.is_empty() {
478            let pos = self.content_end();
479            self.directives.insert(
480                pos,
481                Directive {
482                    key: String::new(),
483                    value: String::new(),
484                    raw_line: format!("{}# purple:askpass {}", indent, source),
485                    is_non_directive: true,
486                },
487            );
488        }
489    }
490
491    /// Extract provider metadata from purple:meta comment in directives.
492    /// Format: `# purple:meta key=value,key=value`
493    pub fn meta(&self) -> Vec<(String, String)> {
494        for d in &self.directives {
495            if d.is_non_directive {
496                let trimmed = d.raw_line.trim();
497                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
498                    return rest
499                        .split(',')
500                        .filter_map(|pair| {
501                            let (k, v) = pair.split_once('=')?;
502                            let k = k.trim();
503                            let v = v.trim();
504                            if k.is_empty() {
505                                None
506                            } else {
507                                Some((k.to_string(), v.to_string()))
508                            }
509                        })
510                        .collect();
511                }
512            }
513        }
514        Vec::new()
515    }
516
517    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
518    /// Pass an empty slice to remove the comment.
519    pub fn set_meta(&mut self, meta: &[(String, String)]) {
520        let indent = self.detect_indent();
521        self.directives.retain(|d| {
522            !(d.is_non_directive && {
523                let t = d.raw_line.trim();
524                t == "# purple:meta" || t.starts_with("# purple:meta ")
525            })
526        });
527        if !meta.is_empty() {
528            let encoded: Vec<String> = meta
529                .iter()
530                .map(|(k, v)| {
531                    let clean_k = Self::sanitize_tag(&k.replace([',', '='], ""));
532                    let clean_v = Self::sanitize_tag(&v.replace(',', ""));
533                    format!("{}={}", clean_k, clean_v)
534                })
535                .collect();
536            let pos = self.content_end();
537            self.directives.insert(
538                pos,
539                Directive {
540                    key: String::new(),
541                    value: String::new(),
542                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
543                    is_non_directive: true,
544                },
545            );
546        }
547    }
548
549    /// Extract stale timestamp from purple:stale comment in directives.
550    /// Returns `None` if absent or malformed.
551    pub fn stale(&self) -> Option<u64> {
552        for d in &self.directives {
553            if d.is_non_directive {
554                let trimmed = d.raw_line.trim();
555                if let Some(rest) = trimmed.strip_prefix("# purple:stale ") {
556                    return rest.trim().parse::<u64>().ok();
557                }
558            }
559        }
560        None
561    }
562
563    /// Mark a host block as stale with a unix timestamp.
564    /// Replaces existing purple:stale comment or adds one.
565    pub fn set_stale(&mut self, timestamp: u64) {
566        let indent = self.detect_indent();
567        self.clear_stale();
568        let pos = self.content_end();
569        self.directives.insert(
570            pos,
571            Directive {
572                key: String::new(),
573                value: String::new(),
574                raw_line: format!("{}# purple:stale {}", indent, timestamp),
575                is_non_directive: true,
576            },
577        );
578    }
579
580    /// Remove stale marking from a host block.
581    pub fn clear_stale(&mut self) {
582        self.directives.retain(|d| {
583            !(d.is_non_directive && {
584                let t = d.raw_line.trim();
585                t == "# purple:stale" || t.starts_with("# purple:stale ")
586            })
587        });
588    }
589
590    /// Sanitize a tag value: strip control characters, commas (delimiter),
591    /// and Unicode format/bidi override characters. Truncate to 128 chars.
592    fn sanitize_tag(tag: &str) -> String {
593        tag.chars()
594            .filter(|c| {
595                !c.is_control()
596                    && *c != ','
597                    && !('\u{200B}'..='\u{200F}').contains(c) // zero-width, bidi marks
598                    && !('\u{202A}'..='\u{202E}').contains(c) // bidi embedding/override
599                    && !('\u{2066}'..='\u{2069}').contains(c) // bidi isolate
600                    && *c != '\u{FEFF}' // BOM/zero-width no-break space
601            })
602            .take(128)
603            .collect()
604    }
605
606    /// Set user tags on a host block. Replaces existing purple:tags comment or adds one.
607    pub fn set_tags(&mut self, tags: &[String]) {
608        let indent = self.detect_indent();
609        self.directives.retain(|d| {
610            !(d.is_non_directive && {
611                let t = d.raw_line.trim();
612                t == "# purple:tags" || t.starts_with("# purple:tags ")
613            })
614        });
615        let sanitized: Vec<String> = tags
616            .iter()
617            .map(|t| Self::sanitize_tag(t))
618            .filter(|t| !t.is_empty())
619            .collect();
620        if !sanitized.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:tags {}", indent, sanitized.join(",")),
628                    is_non_directive: true,
629                },
630            );
631        }
632    }
633
634    /// Set provider-synced tags. Replaces existing purple:provider_tags comment.
635    /// Always writes the comment (even when empty) as a migration sentinel.
636    pub fn set_provider_tags(&mut self, tags: &[String]) {
637        let indent = self.detect_indent();
638        self.directives.retain(|d| {
639            !(d.is_non_directive && {
640                let t = d.raw_line.trim();
641                t == "# purple:provider_tags" || t.starts_with("# purple:provider_tags ")
642            })
643        });
644        let sanitized: Vec<String> = tags
645            .iter()
646            .map(|t| Self::sanitize_tag(t))
647            .filter(|t| !t.is_empty())
648            .collect();
649        let raw = if sanitized.is_empty() {
650            format!("{}# purple:provider_tags", indent)
651        } else {
652            format!("{}# purple:provider_tags {}", indent, sanitized.join(","))
653        };
654        let pos = self.content_end();
655        self.directives.insert(
656            pos,
657            Directive {
658                key: String::new(),
659                value: String::new(),
660                raw_line: raw,
661                is_non_directive: true,
662            },
663        );
664    }
665
666    /// Extract a convenience HostEntry view from this block.
667    pub fn to_host_entry(&self) -> HostEntry {
668        let mut entry = HostEntry {
669            alias: self.host_pattern.clone(),
670            port: 22,
671            ..Default::default()
672        };
673        for d in &self.directives {
674            if d.is_non_directive {
675                continue;
676            }
677            if d.key.eq_ignore_ascii_case("hostname") {
678                entry.hostname = d.value.clone();
679            } else if d.key.eq_ignore_ascii_case("user") {
680                entry.user = d.value.clone();
681            } else if d.key.eq_ignore_ascii_case("port") {
682                entry.port = d.value.parse().unwrap_or(22);
683            } else if d.key.eq_ignore_ascii_case("identityfile") {
684                if entry.identity_file.is_empty() {
685                    entry.identity_file = d.value.clone();
686                }
687            } else if d.key.eq_ignore_ascii_case("proxyjump") {
688                entry.proxy_jump = d.value.clone();
689            }
690        }
691        entry.tags = self.tags();
692        entry.provider_tags = self.provider_tags();
693        entry.has_provider_tags = self.has_provider_tags_comment();
694        entry.provider = self.provider().map(|(name, _)| name);
695        entry.tunnel_count = self.tunnel_count();
696        entry.askpass = self.askpass();
697        entry.provider_meta = self.meta();
698        entry.stale = self.stale();
699        entry
700    }
701
702    /// Extract a convenience PatternEntry view from this block.
703    pub fn to_pattern_entry(&self) -> PatternEntry {
704        let mut entry = PatternEntry {
705            pattern: self.host_pattern.clone(),
706            hostname: String::new(),
707            user: String::new(),
708            port: 22,
709            identity_file: String::new(),
710            proxy_jump: String::new(),
711            tags: self.tags(),
712            askpass: self.askpass(),
713            source_file: None,
714            directives: Vec::new(),
715        };
716        for d in &self.directives {
717            if d.is_non_directive {
718                continue;
719            }
720            match d.key.to_ascii_lowercase().as_str() {
721                "hostname" => entry.hostname = d.value.clone(),
722                "user" => entry.user = d.value.clone(),
723                "port" => entry.port = d.value.parse().unwrap_or(22),
724                "identityfile" => {
725                    if entry.identity_file.is_empty() {
726                        entry.identity_file = d.value.clone();
727                    }
728                }
729                "proxyjump" => entry.proxy_jump = d.value.clone(),
730                _ => {}
731            }
732            entry.directives.push((d.key.clone(), d.value.clone()));
733        }
734        entry
735    }
736
737    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
738    pub fn tunnel_count(&self) -> u16 {
739        let count = self
740            .directives
741            .iter()
742            .filter(|d| {
743                !d.is_non_directive
744                    && (d.key.eq_ignore_ascii_case("localforward")
745                        || d.key.eq_ignore_ascii_case("remoteforward")
746                        || d.key.eq_ignore_ascii_case("dynamicforward"))
747            })
748            .count();
749        count.min(u16::MAX as usize) as u16
750    }
751
752    /// Check if this block has any tunnel forwarding directives.
753    #[allow(dead_code)]
754    pub fn has_tunnels(&self) -> bool {
755        self.directives.iter().any(|d| {
756            !d.is_non_directive
757                && (d.key.eq_ignore_ascii_case("localforward")
758                    || d.key.eq_ignore_ascii_case("remoteforward")
759                    || d.key.eq_ignore_ascii_case("dynamicforward"))
760        })
761    }
762
763    /// Extract tunnel rules from forwarding directives.
764    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
765        self.directives
766            .iter()
767            .filter(|d| !d.is_non_directive)
768            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
769            .collect()
770    }
771}
772
773impl SshConfigFile {
774    /// Get all host entries as convenience views (including from Include files).
775    pub fn host_entries(&self) -> Vec<HostEntry> {
776        let mut entries = Vec::new();
777        Self::collect_host_entries(&self.elements, &mut entries);
778        entries
779    }
780
781    /// Get all pattern entries as convenience views (including from Include files).
782    pub fn pattern_entries(&self) -> Vec<PatternEntry> {
783        let mut entries = Vec::new();
784        Self::collect_pattern_entries(&self.elements, &mut entries);
785        entries
786    }
787
788    fn collect_pattern_entries(elements: &[ConfigElement], entries: &mut Vec<PatternEntry>) {
789        for e in elements {
790            match e {
791                ConfigElement::HostBlock(block) => {
792                    if !is_host_pattern(&block.host_pattern) {
793                        continue;
794                    }
795                    entries.push(block.to_pattern_entry());
796                }
797                ConfigElement::Include(include) => {
798                    for file in &include.resolved_files {
799                        let start = entries.len();
800                        Self::collect_pattern_entries(&file.elements, entries);
801                        for entry in &mut entries[start..] {
802                            if entry.source_file.is_none() {
803                                entry.source_file = Some(file.path.clone());
804                            }
805                        }
806                    }
807                }
808                ConfigElement::GlobalLine(_) => {}
809            }
810        }
811    }
812
813    /// Find all pattern blocks that match a given host alias and hostname.
814    /// Returns entries in config order (first match first).
815    pub fn matching_patterns(&self, alias: &str) -> Vec<PatternEntry> {
816        let mut matches = Vec::new();
817        Self::collect_matching_patterns(&self.elements, alias, &mut matches);
818        matches
819    }
820
821    fn collect_matching_patterns(
822        elements: &[ConfigElement],
823        alias: &str,
824        matches: &mut Vec<PatternEntry>,
825    ) {
826        for e in elements {
827            match e {
828                ConfigElement::HostBlock(block) => {
829                    if !is_host_pattern(&block.host_pattern) {
830                        continue;
831                    }
832                    if host_pattern_matches(&block.host_pattern, alias) {
833                        matches.push(block.to_pattern_entry());
834                    }
835                }
836                ConfigElement::Include(include) => {
837                    for file in &include.resolved_files {
838                        let start = matches.len();
839                        Self::collect_matching_patterns(&file.elements, alias, matches);
840                        for entry in &mut matches[start..] {
841                            if entry.source_file.is_none() {
842                                entry.source_file = Some(file.path.clone());
843                            }
844                        }
845                    }
846                }
847                ConfigElement::GlobalLine(_) => {}
848            }
849        }
850    }
851
852    /// Collect all resolved Include file paths (recursively).
853    pub fn include_paths(&self) -> Vec<PathBuf> {
854        let mut paths = Vec::new();
855        Self::collect_include_paths(&self.elements, &mut paths);
856        paths
857    }
858
859    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
860        for e in elements {
861            if let ConfigElement::Include(include) = e {
862                for file in &include.resolved_files {
863                    paths.push(file.path.clone());
864                    Self::collect_include_paths(&file.elements, paths);
865                }
866            }
867        }
868    }
869
870    /// Collect parent directories of Include glob patterns.
871    /// When a file is added/removed under a glob dir, the directory's mtime changes.
872    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
873        let config_dir = self.path.parent();
874        let mut seen = std::collections::HashSet::new();
875        let mut dirs = Vec::new();
876        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
877        dirs
878    }
879
880    fn collect_include_glob_dirs(
881        elements: &[ConfigElement],
882        config_dir: Option<&std::path::Path>,
883        seen: &mut std::collections::HashSet<PathBuf>,
884        dirs: &mut Vec<PathBuf>,
885    ) {
886        for e in elements {
887            if let ConfigElement::Include(include) = e {
888                // Split respecting quoted paths (same as resolve_include does)
889                for single in Self::split_include_patterns(&include.pattern) {
890                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
891                    let resolved = if expanded.starts_with('/') {
892                        PathBuf::from(&expanded)
893                    } else if let Some(dir) = config_dir {
894                        dir.join(&expanded)
895                    } else {
896                        continue;
897                    };
898                    if let Some(parent) = resolved.parent() {
899                        let parent = parent.to_path_buf();
900                        if seen.insert(parent.clone()) {
901                            dirs.push(parent);
902                        }
903                    }
904                }
905                // Recurse into resolved files
906                for file in &include.resolved_files {
907                    Self::collect_include_glob_dirs(&file.elements, file.path.parent(), seen, dirs);
908                }
909            }
910        }
911    }
912
913    /// Remove `# purple:group <Name>` headers that have no corresponding
914    /// provider hosts. Returns the number of headers removed.
915    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
916        // Collect all provider display names that have at least one host.
917        let active_providers: std::collections::HashSet<String> = self
918            .elements
919            .iter()
920            .filter_map(|e| {
921                if let ConfigElement::HostBlock(block) = e {
922                    block
923                        .provider()
924                        .map(|(name, _)| provider_group_display_name(&name).to_string())
925                } else {
926                    None
927                }
928            })
929            .collect();
930
931        let mut removed = 0;
932        self.elements.retain(|e| {
933            if let ConfigElement::GlobalLine(line) = e {
934                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
935                    if !active_providers.contains(rest.trim()) {
936                        removed += 1;
937                        return false;
938                    }
939                }
940            }
941            true
942        });
943        removed
944    }
945
946    /// Repair configs where `# purple:group` comments were absorbed into the
947    /// preceding host block's directives instead of being stored as GlobalLines.
948    /// Returns the number of blocks that were repaired.
949    pub fn repair_absorbed_group_comments(&mut self) -> usize {
950        let mut repaired = 0;
951        let mut idx = 0;
952        while idx < self.elements.len() {
953            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
954                block
955                    .directives
956                    .iter()
957                    .any(|d| d.is_non_directive && d.raw_line.trim().starts_with("# purple:group "))
958            } else {
959                false
960            };
961
962            if !needs_repair {
963                idx += 1;
964                continue;
965            }
966
967            // Find the index of the first absorbed group comment in this block's directives.
968            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
969                block
970            } else {
971                unreachable!()
972            };
973
974            let group_idx = block
975                .directives
976                .iter()
977                .position(|d| {
978                    d.is_non_directive && d.raw_line.trim().starts_with("# purple:group ")
979                })
980                .unwrap();
981
982            // Find where trailing blanks before the group comment start.
983            let mut keep_end = group_idx;
984            while keep_end > 0
985                && block.directives[keep_end - 1].is_non_directive
986                && block.directives[keep_end - 1].raw_line.trim().is_empty()
987            {
988                keep_end -= 1;
989            }
990
991            // Collect everything from keep_end onward as GlobalLines.
992            let extracted: Vec<ConfigElement> = block
993                .directives
994                .drain(keep_end..)
995                .map(|d| ConfigElement::GlobalLine(d.raw_line))
996                .collect();
997
998            // Insert extracted GlobalLines right after this HostBlock.
999            let insert_at = idx + 1;
1000            for (i, elem) in extracted.into_iter().enumerate() {
1001                self.elements.insert(insert_at + i, elem);
1002            }
1003
1004            repaired += 1;
1005            // Advance past the inserted elements.
1006            idx = insert_at;
1007            // Skip the inserted elements to continue scanning.
1008            while idx < self.elements.len() {
1009                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
1010                    break;
1011                }
1012                idx += 1;
1013            }
1014        }
1015        repaired
1016    }
1017
1018    /// Recursively collect host entries from a list of elements.
1019    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
1020        for e in elements {
1021            match e {
1022                ConfigElement::HostBlock(block) => {
1023                    if is_host_pattern(&block.host_pattern) {
1024                        continue;
1025                    }
1026                    entries.push(block.to_host_entry());
1027                }
1028                ConfigElement::Include(include) => {
1029                    for file in &include.resolved_files {
1030                        let start = entries.len();
1031                        Self::collect_host_entries(&file.elements, entries);
1032                        for entry in &mut entries[start..] {
1033                            if entry.source_file.is_none() {
1034                                entry.source_file = Some(file.path.clone());
1035                            }
1036                        }
1037                    }
1038                }
1039                ConfigElement::GlobalLine(_) => {}
1040            }
1041        }
1042    }
1043
1044    /// Check if a host alias already exists (including in Include files).
1045    /// Walks the element tree directly without building HostEntry structs.
1046    pub fn has_host(&self, alias: &str) -> bool {
1047        Self::has_host_in_elements(&self.elements, alias)
1048    }
1049
1050    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
1051        for e in elements {
1052            match e {
1053                ConfigElement::HostBlock(block) => {
1054                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1055                        return true;
1056                    }
1057                }
1058                ConfigElement::Include(include) => {
1059                    for file in &include.resolved_files {
1060                        if Self::has_host_in_elements(&file.elements, alias) {
1061                            return true;
1062                        }
1063                    }
1064                }
1065                ConfigElement::GlobalLine(_) => {}
1066            }
1067        }
1068        false
1069    }
1070
1071    /// Check if a host alias is from an included file (read-only).
1072    /// Handles multi-pattern Host lines by splitting on whitespace.
1073    pub fn is_included_host(&self, alias: &str) -> bool {
1074        // Not in top-level elements → must be in an Include
1075        for e in &self.elements {
1076            match e {
1077                ConfigElement::HostBlock(block) => {
1078                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1079                        return false;
1080                    }
1081                }
1082                ConfigElement::Include(include) => {
1083                    for file in &include.resolved_files {
1084                        if Self::has_host_in_elements(&file.elements, alias) {
1085                            return true;
1086                        }
1087                    }
1088                }
1089                ConfigElement::GlobalLine(_) => {}
1090            }
1091        }
1092        false
1093    }
1094
1095    /// Add a new host entry to the config.
1096    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
1097    /// so that SSH "first match wins" semantics are preserved. If wildcards are
1098    /// only at the top of the file (acting as global defaults), appends at end.
1099    pub fn add_host(&mut self, entry: &HostEntry) {
1100        let block = Self::entry_to_block(entry);
1101        let insert_pos = self.find_trailing_pattern_start();
1102
1103        if let Some(pos) = insert_pos {
1104            // Insert before the trailing pattern group, with blank separators
1105            let needs_blank_before = pos > 0
1106                && !matches!(
1107                    self.elements.get(pos - 1),
1108                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1109                );
1110            let mut idx = pos;
1111            if needs_blank_before {
1112                self.elements
1113                    .insert(idx, ConfigElement::GlobalLine(String::new()));
1114                idx += 1;
1115            }
1116            self.elements.insert(idx, ConfigElement::HostBlock(block));
1117            // Ensure a blank separator after the new block (before the wildcard group)
1118            let after = idx + 1;
1119            if after < self.elements.len()
1120                && !matches!(
1121                    self.elements.get(after),
1122                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
1123                )
1124            {
1125                self.elements
1126                    .insert(after, ConfigElement::GlobalLine(String::new()));
1127            }
1128        } else {
1129            // No trailing patterns: append at end
1130            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
1131                self.elements.push(ConfigElement::GlobalLine(String::new()));
1132            }
1133            self.elements.push(ConfigElement::HostBlock(block));
1134        }
1135    }
1136
1137    /// Find the start of a trailing group of wildcard/pattern Host blocks.
1138    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
1139    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
1140    /// i.e. patterns start at position 0 — in that case we append at end).
1141    fn find_trailing_pattern_start(&self) -> Option<usize> {
1142        let mut first_pattern_pos = None;
1143        for i in (0..self.elements.len()).rev() {
1144            match &self.elements[i] {
1145                ConfigElement::HostBlock(block) => {
1146                    if is_host_pattern(&block.host_pattern) {
1147                        first_pattern_pos = Some(i);
1148                    } else {
1149                        // Found a concrete host: the trailing group starts after this
1150                        break;
1151                    }
1152                }
1153                ConfigElement::GlobalLine(_) => {
1154                    // Blank lines, comments, Match blocks between patterns: keep scanning
1155                    if first_pattern_pos.is_some() {
1156                        first_pattern_pos = Some(i);
1157                    }
1158                }
1159                ConfigElement::Include(_) => break,
1160            }
1161        }
1162        // Don't return position 0 — that means everything is patterns (or patterns at top)
1163        first_pattern_pos.filter(|&pos| pos > 0)
1164    }
1165
1166    /// Check if the last element already ends with a blank line.
1167    pub fn last_element_has_trailing_blank(&self) -> bool {
1168        match self.elements.last() {
1169            Some(ConfigElement::HostBlock(block)) => block
1170                .directives
1171                .last()
1172                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
1173            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
1174            _ => false,
1175        }
1176    }
1177
1178    /// Update an existing host entry by alias.
1179    /// Merges changes into the existing block, preserving unknown directives.
1180    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
1181        for element in &mut self.elements {
1182            if let ConfigElement::HostBlock(block) = element {
1183                if block.host_pattern == old_alias {
1184                    // Update host pattern (preserve raw_host_line when alias unchanged)
1185                    if entry.alias != block.host_pattern {
1186                        block.host_pattern = entry.alias.clone();
1187                        block.raw_host_line = format!("Host {}", entry.alias);
1188                    }
1189
1190                    // Merge known directives (update existing, add missing, remove empty)
1191                    Self::upsert_directive(block, "HostName", &entry.hostname);
1192                    Self::upsert_directive(block, "User", &entry.user);
1193                    if entry.port != 22 {
1194                        Self::upsert_directive(block, "Port", &entry.port.to_string());
1195                    } else {
1196                        // Remove explicit Port 22 (it's the default)
1197                        block
1198                            .directives
1199                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
1200                    }
1201                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
1202                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
1203                    return;
1204                }
1205            }
1206        }
1207    }
1208
1209    /// Update a directive in-place, add it if missing, or remove it if value is empty.
1210    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
1211        if value.is_empty() {
1212            block
1213                .directives
1214                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
1215            return;
1216        }
1217        let indent = block.detect_indent();
1218        for d in &mut block.directives {
1219            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
1220                // Only rebuild raw_line when value actually changed (preserves inline comments)
1221                if d.value != value {
1222                    d.value = value.to_string();
1223                    // Detect separator style from original raw_line and preserve it.
1224                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
1225                    // Only considers '=' as separator if it appears before any
1226                    // non-whitespace content (avoids matching '=' inside values
1227                    // like "IdentityFile ~/.ssh/id=prod").
1228                    let trimmed = d.raw_line.trim_start();
1229                    let after_key = &trimmed[d.key.len()..];
1230                    let sep = if after_key.trim_start().starts_with('=') {
1231                        let eq_pos = after_key.find('=').unwrap();
1232                        let after_eq = &after_key[eq_pos + 1..];
1233                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
1234                        after_key[..eq_pos + 1 + trailing_ws].to_string()
1235                    } else {
1236                        " ".to_string()
1237                    };
1238                    // Preserve inline comment from original raw_line (e.g. "# production")
1239                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
1240                    d.raw_line = format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
1241                }
1242                return;
1243            }
1244        }
1245        // Not found — insert before trailing blanks
1246        let pos = block.content_end();
1247        block.directives.insert(
1248            pos,
1249            Directive {
1250                key: key.to_string(),
1251                value: value.to_string(),
1252                raw_line: format!("{}{} {}", indent, key, value),
1253                is_non_directive: false,
1254            },
1255        );
1256    }
1257
1258    /// Extract the inline comment suffix from a directive's raw line.
1259    /// Returns the trailing portion (e.g. " # production") or empty string.
1260    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
1261    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
1262        let trimmed = raw_line.trim_start();
1263        if trimmed.len() <= key.len() {
1264            return String::new();
1265        }
1266        // Skip past key and separator to reach the value portion
1267        let after_key = &trimmed[key.len()..];
1268        let rest = after_key.trim_start();
1269        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
1270        // Scan for inline comment (# preceded by whitespace, outside quotes)
1271        let bytes = rest.as_bytes();
1272        let mut in_quote = false;
1273        for i in 0..bytes.len() {
1274            if bytes[i] == b'"' {
1275                in_quote = !in_quote;
1276            } else if !in_quote
1277                && bytes[i] == b'#'
1278                && i > 0
1279                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
1280            {
1281                // Found comment start. The clean value ends before the whitespace preceding #.
1282                let clean_end = rest[..i].trim_end().len();
1283                return rest[clean_end..].to_string();
1284            }
1285        }
1286        String::new()
1287    }
1288
1289    /// Set provider on a host block by alias.
1290    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
1291        for element in &mut self.elements {
1292            if let ConfigElement::HostBlock(block) = element {
1293                if block.host_pattern == alias {
1294                    block.set_provider(provider_name, server_id);
1295                    return;
1296                }
1297            }
1298        }
1299    }
1300
1301    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
1302    /// Searches both top-level elements and Include files so that provider hosts
1303    /// in included configs are recognized during sync (prevents duplicate additions).
1304    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
1305        let mut results = Vec::new();
1306        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
1307        results
1308    }
1309
1310    fn collect_provider_hosts(
1311        elements: &[ConfigElement],
1312        provider_name: &str,
1313        results: &mut Vec<(String, String)>,
1314    ) {
1315        for element in elements {
1316            match element {
1317                ConfigElement::HostBlock(block) => {
1318                    if let Some((name, id)) = block.provider() {
1319                        if name == provider_name {
1320                            results.push((block.host_pattern.clone(), id));
1321                        }
1322                    }
1323                }
1324                ConfigElement::Include(include) => {
1325                    for file in &include.resolved_files {
1326                        Self::collect_provider_hosts(&file.elements, provider_name, results);
1327                    }
1328                }
1329                ConfigElement::GlobalLine(_) => {}
1330            }
1331        }
1332    }
1333
1334    /// Compare two directive values with whitespace normalization.
1335    /// Handles hand-edited configs with tabs or multiple spaces.
1336    fn values_match(a: &str, b: &str) -> bool {
1337        a.split_whitespace().eq(b.split_whitespace())
1338    }
1339
1340    /// Add a forwarding directive to a host block.
1341    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
1342    /// Uses split_whitespace matching for multi-pattern Host lines.
1343    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
1344        for element in &mut self.elements {
1345            if let ConfigElement::HostBlock(block) = element {
1346                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1347                    let indent = block.detect_indent();
1348                    let pos = block.content_end();
1349                    block.directives.insert(
1350                        pos,
1351                        Directive {
1352                            key: directive_key.to_string(),
1353                            value: value.to_string(),
1354                            raw_line: format!("{}{} {}", indent, directive_key, value),
1355                            is_non_directive: false,
1356                        },
1357                    );
1358                    return;
1359                }
1360            }
1361        }
1362    }
1363
1364    /// Remove a specific forwarding directive from a host block.
1365    /// Matches key (case-insensitive) and value (whitespace-normalized).
1366    /// Uses split_whitespace matching for multi-pattern Host lines.
1367    /// Returns true if a directive was actually removed.
1368    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
1369        for element in &mut self.elements {
1370            if let ConfigElement::HostBlock(block) = element {
1371                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1372                    if let Some(pos) = block.directives.iter().position(|d| {
1373                        !d.is_non_directive
1374                            && d.key.eq_ignore_ascii_case(directive_key)
1375                            && Self::values_match(&d.value, value)
1376                    }) {
1377                        block.directives.remove(pos);
1378                        return true;
1379                    }
1380                    return false;
1381                }
1382            }
1383        }
1384        false
1385    }
1386
1387    /// Check if a host block has a specific forwarding directive.
1388    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
1389    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
1390        for element in &self.elements {
1391            if let ConfigElement::HostBlock(block) = element {
1392                if block.host_pattern.split_whitespace().any(|p| p == alias) {
1393                    return block.directives.iter().any(|d| {
1394                        !d.is_non_directive
1395                            && d.key.eq_ignore_ascii_case(directive_key)
1396                            && Self::values_match(&d.value, value)
1397                    });
1398                }
1399            }
1400        }
1401        false
1402    }
1403
1404    /// Find tunnel directives for a host alias, searching all elements including
1405    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1406    /// Host lines.
1407    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1408        Self::find_tunnel_directives_in(&self.elements, alias)
1409    }
1410
1411    fn find_tunnel_directives_in(
1412        elements: &[ConfigElement],
1413        alias: &str,
1414    ) -> Vec<crate::tunnel::TunnelRule> {
1415        for element in elements {
1416            match element {
1417                ConfigElement::HostBlock(block) => {
1418                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1419                        return block.tunnel_directives();
1420                    }
1421                }
1422                ConfigElement::Include(include) => {
1423                    for file in &include.resolved_files {
1424                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1425                        if !rules.is_empty() {
1426                            return rules;
1427                        }
1428                    }
1429                }
1430                ConfigElement::GlobalLine(_) => {}
1431            }
1432        }
1433        Vec::new()
1434    }
1435
1436    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1437    pub fn deduplicate_alias(&self, base: &str) -> String {
1438        self.deduplicate_alias_excluding(base, None)
1439    }
1440
1441    /// Generate a unique alias, optionally excluding one alias from collision detection.
1442    /// Used during rename so the host being renamed doesn't collide with itself.
1443    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1444        let is_taken = |alias: &str| {
1445            if exclude == Some(alias) {
1446                return false;
1447            }
1448            self.has_host(alias)
1449        };
1450        if !is_taken(base) {
1451            return base.to_string();
1452        }
1453        for n in 2..=9999 {
1454            let candidate = format!("{}-{}", base, n);
1455            if !is_taken(&candidate) {
1456                return candidate;
1457            }
1458        }
1459        // Practically unreachable: fall back to PID-based suffix
1460        format!("{}-{}", base, std::process::id())
1461    }
1462
1463    /// Set tags on a host block by alias.
1464    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1465        for element in &mut self.elements {
1466            if let ConfigElement::HostBlock(block) = element {
1467                if block.host_pattern == alias {
1468                    block.set_tags(tags);
1469                    return;
1470                }
1471            }
1472        }
1473    }
1474
1475    /// Set provider-synced tags on a host block by alias.
1476    pub fn set_host_provider_tags(&mut self, alias: &str, tags: &[String]) {
1477        for element in &mut self.elements {
1478            if let ConfigElement::HostBlock(block) = element {
1479                if block.host_pattern == alias {
1480                    block.set_provider_tags(tags);
1481                    return;
1482                }
1483            }
1484        }
1485    }
1486
1487    /// Set askpass source on a host block by alias.
1488    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1489        for element in &mut self.elements {
1490            if let ConfigElement::HostBlock(block) = element {
1491                if block.host_pattern == alias {
1492                    block.set_askpass(source);
1493                    return;
1494                }
1495            }
1496        }
1497    }
1498
1499    /// Set provider metadata on a host block by alias.
1500    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1501        for element in &mut self.elements {
1502            if let ConfigElement::HostBlock(block) = element {
1503                if block.host_pattern == alias {
1504                    block.set_meta(meta);
1505                    return;
1506                }
1507            }
1508        }
1509    }
1510
1511    /// Mark a host as stale by alias.
1512    pub fn set_host_stale(&mut self, alias: &str, timestamp: u64) {
1513        for element in &mut self.elements {
1514            if let ConfigElement::HostBlock(block) = element {
1515                if block.host_pattern == alias {
1516                    block.set_stale(timestamp);
1517                    return;
1518                }
1519            }
1520        }
1521    }
1522
1523    /// Clear stale marking from a host by alias.
1524    pub fn clear_host_stale(&mut self, alias: &str) {
1525        for element in &mut self.elements {
1526            if let ConfigElement::HostBlock(block) = element {
1527                if block.host_pattern == alias {
1528                    block.clear_stale();
1529                    return;
1530                }
1531            }
1532        }
1533    }
1534
1535    /// Collect all stale hosts with their timestamps.
1536    pub fn stale_hosts(&self) -> Vec<(String, u64)> {
1537        let mut result = Vec::new();
1538        for element in &self.elements {
1539            if let ConfigElement::HostBlock(block) = element {
1540                if let Some(ts) = block.stale() {
1541                    result.push((block.host_pattern.clone(), ts));
1542                }
1543            }
1544        }
1545        result
1546    }
1547
1548    /// Delete a host entry by alias.
1549    #[allow(dead_code)]
1550    pub fn delete_host(&mut self, alias: &str) {
1551        // Before deletion, check if this host belongs to a provider so we can
1552        // clean up an orphaned group header afterwards.
1553        let provider_name = self.elements.iter().find_map(|e| {
1554            if let ConfigElement::HostBlock(b) = e {
1555                if b.host_pattern == alias {
1556                    return b.provider().map(|(name, _)| name);
1557                }
1558            }
1559            None
1560        });
1561
1562        self.elements.retain(|e| match e {
1563            ConfigElement::HostBlock(block) => block.host_pattern != alias,
1564            _ => true,
1565        });
1566
1567        // Remove orphaned group header if no hosts remain for the provider.
1568        if let Some(name) = provider_name {
1569            self.remove_orphaned_group_header(&name);
1570        }
1571
1572        // Collapse consecutive blank lines left by deletion
1573        self.elements.dedup_by(|a, b| {
1574            matches!(
1575                (&*a, &*b),
1576                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1577                if x.trim().is_empty() && y.trim().is_empty()
1578            )
1579        });
1580    }
1581
1582    /// Delete a host and return the removed element and its position for undo.
1583    /// Does NOT collapse blank lines or remove group headers so the position
1584    /// stays valid for re-insertion via `insert_host_at()`.
1585    /// Orphaned group headers (if any) are cleaned up at next startup.
1586    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1587        let pos = self
1588            .elements
1589            .iter()
1590            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias))?;
1591        let element = self.elements.remove(pos);
1592        Some((element, pos))
1593    }
1594
1595    /// Find the position of the `# purple:group <DisplayName>` GlobalLine for a provider.
1596    #[allow(dead_code)]
1597    fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1598        let display = provider_group_display_name(provider_name);
1599        let header = format!("# purple:group {}", display);
1600        self.elements
1601            .iter()
1602            .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1603    }
1604
1605    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
1606    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
1607    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1608        if self.find_hosts_by_provider(provider_name).is_empty() {
1609            let display = provider_group_display_name(provider_name);
1610            let header = format!("# purple:group {}", display);
1611            self.elements
1612                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1613        }
1614    }
1615
1616    /// Insert a host block at a specific position (for undo).
1617    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1618        let pos = position.min(self.elements.len());
1619        self.elements.insert(pos, element);
1620    }
1621
1622    /// Find the position after the last HostBlock that belongs to a provider.
1623    /// Returns `None` if no hosts for this provider exist in the config.
1624    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1625    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1626        let mut last_pos = None;
1627        for (i, element) in self.elements.iter().enumerate() {
1628            if let ConfigElement::HostBlock(block) = element {
1629                if let Some((name, _)) = block.provider() {
1630                    if name == provider_name {
1631                        last_pos = Some(i);
1632                    }
1633                }
1634            }
1635        }
1636        // Return position after the last provider host
1637        last_pos.map(|p| p + 1)
1638    }
1639
1640    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1641    #[allow(dead_code)]
1642    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1643        let pos_a = self
1644            .elements
1645            .iter()
1646            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a));
1647        let pos_b = self
1648            .elements
1649            .iter()
1650            .position(|e| matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b));
1651        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1652            if a == b {
1653                return false;
1654            }
1655            let (first, second) = (a.min(b), a.max(b));
1656
1657            // Strip trailing blanks from both blocks before swap
1658            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1659                block.pop_trailing_blanks();
1660            }
1661            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1662                block.pop_trailing_blanks();
1663            }
1664
1665            // Swap
1666            self.elements.swap(first, second);
1667
1668            // Add trailing blank to first block (separator between the two)
1669            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1670                block.ensure_trailing_blank();
1671            }
1672
1673            // Add trailing blank to second only if not the last element
1674            if second < self.elements.len() - 1 {
1675                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1676                    block.ensure_trailing_blank();
1677                }
1678            }
1679
1680            return true;
1681        }
1682        false
1683    }
1684
1685    /// Convert a HostEntry into a new HostBlock with clean formatting.
1686    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1687        let mut directives = Vec::new();
1688
1689        if !entry.hostname.is_empty() {
1690            directives.push(Directive {
1691                key: "HostName".to_string(),
1692                value: entry.hostname.clone(),
1693                raw_line: format!("  HostName {}", entry.hostname),
1694                is_non_directive: false,
1695            });
1696        }
1697        if !entry.user.is_empty() {
1698            directives.push(Directive {
1699                key: "User".to_string(),
1700                value: entry.user.clone(),
1701                raw_line: format!("  User {}", entry.user),
1702                is_non_directive: false,
1703            });
1704        }
1705        if entry.port != 22 {
1706            directives.push(Directive {
1707                key: "Port".to_string(),
1708                value: entry.port.to_string(),
1709                raw_line: format!("  Port {}", entry.port),
1710                is_non_directive: false,
1711            });
1712        }
1713        if !entry.identity_file.is_empty() {
1714            directives.push(Directive {
1715                key: "IdentityFile".to_string(),
1716                value: entry.identity_file.clone(),
1717                raw_line: format!("  IdentityFile {}", entry.identity_file),
1718                is_non_directive: false,
1719            });
1720        }
1721        if !entry.proxy_jump.is_empty() {
1722            directives.push(Directive {
1723                key: "ProxyJump".to_string(),
1724                value: entry.proxy_jump.clone(),
1725                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
1726                is_non_directive: false,
1727            });
1728        }
1729
1730        HostBlock {
1731            host_pattern: entry.alias.clone(),
1732            raw_host_line: format!("Host {}", entry.alias),
1733            directives,
1734        }
1735    }
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740    use super::*;
1741
1742    fn parse_str(content: &str) -> SshConfigFile {
1743        SshConfigFile {
1744            elements: SshConfigFile::parse_content(content),
1745            path: PathBuf::from("/tmp/test_config"),
1746            crlf: false,
1747            bom: false,
1748        }
1749    }
1750
1751    #[test]
1752    fn tunnel_directives_extracts_forwards() {
1753        let config = parse_str(
1754            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1755        );
1756        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1757            let rules = block.tunnel_directives();
1758            assert_eq!(rules.len(), 3);
1759            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1760            assert_eq!(rules[0].bind_port, 8080);
1761            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1762            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1763        } else {
1764            panic!("Expected HostBlock");
1765        }
1766    }
1767
1768    #[test]
1769    fn tunnel_count_counts_forwards() {
1770        let config = parse_str(
1771            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
1772        );
1773        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1774            assert_eq!(block.tunnel_count(), 2);
1775        } else {
1776            panic!("Expected HostBlock");
1777        }
1778    }
1779
1780    #[test]
1781    fn tunnel_count_zero_for_no_forwards() {
1782        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1783        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1784            assert_eq!(block.tunnel_count(), 0);
1785            assert!(!block.has_tunnels());
1786        } else {
1787            panic!("Expected HostBlock");
1788        }
1789    }
1790
1791    #[test]
1792    fn has_tunnels_true_with_forward() {
1793        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
1794        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1795            assert!(block.has_tunnels());
1796        } else {
1797            panic!("Expected HostBlock");
1798        }
1799    }
1800
1801    #[test]
1802    fn add_forward_inserts_directive() {
1803        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1804        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1805        let output = config.serialize();
1806        assert!(output.contains("LocalForward 8080 localhost:80"));
1807        // Existing directives preserved
1808        assert!(output.contains("HostName 10.0.0.1"));
1809        assert!(output.contains("User admin"));
1810    }
1811
1812    #[test]
1813    fn add_forward_preserves_indentation() {
1814        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1815        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1816        let output = config.serialize();
1817        assert!(output.contains("\tLocalForward 8080 localhost:80"));
1818    }
1819
1820    #[test]
1821    fn add_multiple_forwards_same_type() {
1822        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1823        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1824        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1825        let output = config.serialize();
1826        assert!(output.contains("LocalForward 8080 localhost:80"));
1827        assert!(output.contains("LocalForward 9090 localhost:90"));
1828    }
1829
1830    #[test]
1831    fn remove_forward_removes_exact_match() {
1832        let mut config = parse_str(
1833            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1834        );
1835        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1836        let output = config.serialize();
1837        assert!(!output.contains("8080 localhost:80"));
1838        assert!(output.contains("9090 localhost:90"));
1839    }
1840
1841    #[test]
1842    fn remove_forward_leaves_other_directives() {
1843        let mut config = parse_str(
1844            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
1845        );
1846        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1847        let output = config.serialize();
1848        assert!(!output.contains("LocalForward"));
1849        assert!(output.contains("HostName 10.0.0.1"));
1850        assert!(output.contains("User admin"));
1851    }
1852
1853    #[test]
1854    fn remove_forward_no_match_is_noop() {
1855        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
1856        let mut config = parse_str(original);
1857        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1858        assert_eq!(config.serialize(), original);
1859    }
1860
1861    #[test]
1862    fn host_entry_tunnel_count_populated() {
1863        let config = parse_str(
1864            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
1865        );
1866        let entries = config.host_entries();
1867        assert_eq!(entries.len(), 1);
1868        assert_eq!(entries[0].tunnel_count, 2);
1869    }
1870
1871    #[test]
1872    fn remove_forward_returns_true_on_match() {
1873        let mut config =
1874            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
1875        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1876    }
1877
1878    #[test]
1879    fn remove_forward_returns_false_on_no_match() {
1880        let mut config =
1881            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
1882        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1883    }
1884
1885    #[test]
1886    fn remove_forward_returns_false_for_unknown_host() {
1887        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1888        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1889    }
1890
1891    #[test]
1892    fn has_forward_finds_match() {
1893        let config =
1894            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
1895        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1896    }
1897
1898    #[test]
1899    fn has_forward_no_match() {
1900        let config =
1901            parse_str("Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
1902        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1903        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1904    }
1905
1906    #[test]
1907    fn has_forward_case_insensitive_key() {
1908        let config =
1909            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
1910        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1911    }
1912
1913    #[test]
1914    fn add_forward_to_empty_block() {
1915        let mut config = parse_str("Host myserver\n");
1916        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1917        let output = config.serialize();
1918        assert!(output.contains("LocalForward 8080 localhost:80"));
1919    }
1920
1921    #[test]
1922    fn remove_forward_case_insensitive_key_match() {
1923        let mut config =
1924            parse_str("Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n");
1925        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1926        assert!(!config.serialize().contains("localforward"));
1927    }
1928
1929    #[test]
1930    fn tunnel_count_case_insensitive() {
1931        let config = parse_str(
1932            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
1933        );
1934        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1935            assert_eq!(block.tunnel_count(), 3);
1936        } else {
1937            panic!("Expected HostBlock");
1938        }
1939    }
1940
1941    #[test]
1942    fn tunnel_directives_extracts_all_types() {
1943        let config = parse_str(
1944            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1945        );
1946        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1947            let rules = block.tunnel_directives();
1948            assert_eq!(rules.len(), 3);
1949            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1950            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1951            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1952        } else {
1953            panic!("Expected HostBlock");
1954        }
1955    }
1956
1957    #[test]
1958    fn tunnel_directives_skips_malformed() {
1959        let config = parse_str("Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n");
1960        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1961            let rules = block.tunnel_directives();
1962            assert_eq!(rules.len(), 1);
1963            assert_eq!(rules[0].bind_port, 1080);
1964        } else {
1965            panic!("Expected HostBlock");
1966        }
1967    }
1968
1969    #[test]
1970    fn find_tunnel_directives_multi_pattern_host() {
1971        let config =
1972            parse_str("Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n");
1973        let rules = config.find_tunnel_directives("prod");
1974        assert_eq!(rules.len(), 1);
1975        assert_eq!(rules[0].bind_port, 8080);
1976        let rules2 = config.find_tunnel_directives("staging");
1977        assert_eq!(rules2.len(), 1);
1978    }
1979
1980    #[test]
1981    fn find_tunnel_directives_no_match() {
1982        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
1983        let rules = config.find_tunnel_directives("nohost");
1984        assert!(rules.is_empty());
1985    }
1986
1987    #[test]
1988    fn has_forward_exact_match() {
1989        let config = parse_str("Host myserver\n  LocalForward 8080 localhost:80\n");
1990        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1991        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1992        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1993        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1994    }
1995
1996    #[test]
1997    fn has_forward_whitespace_normalized() {
1998        let config = parse_str("Host myserver\n  LocalForward 8080  localhost:80\n");
1999        // Extra space in config value vs single space in query — should still match
2000        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2001    }
2002
2003    #[test]
2004    fn has_forward_multi_pattern_host() {
2005        let config = parse_str("Host prod staging\n  LocalForward 8080 localhost:80\n");
2006        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2007        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2008    }
2009
2010    #[test]
2011    fn add_forward_multi_pattern_host() {
2012        let mut config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
2013        config.add_forward("prod", "LocalForward", "8080 localhost:80");
2014        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
2015        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2016    }
2017
2018    #[test]
2019    fn remove_forward_multi_pattern_host() {
2020        let mut config = parse_str(
2021            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
2022        );
2023        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
2024        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
2025        // Other forward should remain
2026        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
2027    }
2028
2029    #[test]
2030    fn edit_tunnel_detects_duplicate_after_remove() {
2031        // Simulates edit flow: remove old, then check if new value already exists
2032        let mut config = parse_str(
2033            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
2034        );
2035        // Edit rule A (8080) toward rule B (9090): remove A first
2036        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2037        // Now check if the target value already exists — should detect duplicate
2038        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
2039    }
2040
2041    #[test]
2042    fn has_forward_tab_whitespace_normalized() {
2043        let config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
2044        // Tab in config value vs space in query — should match via values_match
2045        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2046    }
2047
2048    #[test]
2049    fn remove_forward_tab_whitespace_normalized() {
2050        let mut config = parse_str("Host myserver\n  LocalForward 8080\tlocalhost:80\n");
2051        // Remove with single space should match tab-separated value
2052        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
2053        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
2054    }
2055
2056    #[test]
2057    fn upsert_preserves_space_separator_when_value_contains_equals() {
2058        let mut config = parse_str("Host myserver\n  IdentityFile ~/.ssh/id=prod\n");
2059        let entry = HostEntry {
2060            alias: "myserver".to_string(),
2061            hostname: "10.0.0.1".to_string(),
2062            identity_file: "~/.ssh/id=staging".to_string(),
2063            port: 22,
2064            ..Default::default()
2065        };
2066        config.update_host("myserver", &entry);
2067        let output = config.serialize();
2068        // Separator should remain a space, not pick up the = from the value
2069        assert!(
2070            output.contains("  IdentityFile ~/.ssh/id=staging"),
2071            "got: {}",
2072            output
2073        );
2074        assert!(!output.contains("IdentityFile="), "got: {}", output);
2075    }
2076
2077    #[test]
2078    fn upsert_preserves_equals_separator() {
2079        let mut config = parse_str("Host myserver\n  IdentityFile=~/.ssh/id_rsa\n");
2080        let entry = HostEntry {
2081            alias: "myserver".to_string(),
2082            hostname: "10.0.0.1".to_string(),
2083            identity_file: "~/.ssh/id_ed25519".to_string(),
2084            port: 22,
2085            ..Default::default()
2086        };
2087        config.update_host("myserver", &entry);
2088        let output = config.serialize();
2089        assert!(
2090            output.contains("IdentityFile=~/.ssh/id_ed25519"),
2091            "got: {}",
2092            output
2093        );
2094    }
2095
2096    #[test]
2097    fn upsert_preserves_spaced_equals_separator() {
2098        let mut config = parse_str("Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n");
2099        let entry = HostEntry {
2100            alias: "myserver".to_string(),
2101            hostname: "10.0.0.1".to_string(),
2102            identity_file: "~/.ssh/id_ed25519".to_string(),
2103            port: 22,
2104            ..Default::default()
2105        };
2106        config.update_host("myserver", &entry);
2107        let output = config.serialize();
2108        assert!(
2109            output.contains("IdentityFile = ~/.ssh/id_ed25519"),
2110            "got: {}",
2111            output
2112        );
2113    }
2114
2115    #[test]
2116    fn is_included_host_false_for_main_config() {
2117        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2118        assert!(!config.is_included_host("myserver"));
2119    }
2120
2121    #[test]
2122    fn is_included_host_false_for_nonexistent() {
2123        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2124        assert!(!config.is_included_host("nohost"));
2125    }
2126
2127    #[test]
2128    fn is_included_host_multi_pattern_main_config() {
2129        let config = parse_str("Host prod staging\n  HostName 10.0.0.1\n");
2130        assert!(!config.is_included_host("prod"));
2131        assert!(!config.is_included_host("staging"));
2132    }
2133
2134    // =========================================================================
2135    // HostBlock::askpass() and set_askpass() tests
2136    // =========================================================================
2137
2138    fn first_block(config: &SshConfigFile) -> &HostBlock {
2139        match config.elements.first().unwrap() {
2140            ConfigElement::HostBlock(b) => b,
2141            _ => panic!("Expected HostBlock"),
2142        }
2143    }
2144
2145    fn first_block_mut(config: &mut SshConfigFile) -> &mut HostBlock {
2146        match config.elements.first_mut().unwrap() {
2147            ConfigElement::HostBlock(b) => b,
2148            _ => panic!("Expected HostBlock"),
2149        }
2150    }
2151
2152    fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
2153        let mut count = 0;
2154        for el in &config.elements {
2155            if let ConfigElement::HostBlock(b) = el {
2156                if count == idx {
2157                    return b;
2158                }
2159                count += 1;
2160            }
2161        }
2162        panic!("No HostBlock at index {}", idx);
2163    }
2164
2165    #[test]
2166    fn askpass_returns_none_when_absent() {
2167        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2168        assert_eq!(first_block(&config).askpass(), None);
2169    }
2170
2171    #[test]
2172    fn askpass_returns_keychain() {
2173        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2174        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2175    }
2176
2177    #[test]
2178    fn askpass_returns_op_uri() {
2179        let config = parse_str(
2180            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/Item/field\n",
2181        );
2182        assert_eq!(
2183            first_block(&config).askpass(),
2184            Some("op://Vault/Item/field".to_string())
2185        );
2186    }
2187
2188    #[test]
2189    fn askpass_returns_vault_with_field() {
2190        let config = parse_str(
2191            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#password\n",
2192        );
2193        assert_eq!(
2194            first_block(&config).askpass(),
2195            Some("vault:secret/ssh#password".to_string())
2196        );
2197    }
2198
2199    #[test]
2200    fn askpass_returns_bw_source() {
2201        let config =
2202            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
2203        assert_eq!(
2204            first_block(&config).askpass(),
2205            Some("bw:my-item".to_string())
2206        );
2207    }
2208
2209    #[test]
2210    fn askpass_returns_pass_source() {
2211        let config =
2212            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
2213        assert_eq!(
2214            first_block(&config).askpass(),
2215            Some("pass:ssh/prod".to_string())
2216        );
2217    }
2218
2219    #[test]
2220    fn askpass_returns_custom_command() {
2221        let config =
2222            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass get-pass %a %h\n");
2223        assert_eq!(
2224            first_block(&config).askpass(),
2225            Some("get-pass %a %h".to_string())
2226        );
2227    }
2228
2229    #[test]
2230    fn askpass_ignores_empty_value() {
2231        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass \n");
2232        assert_eq!(first_block(&config).askpass(), None);
2233    }
2234
2235    #[test]
2236    fn askpass_ignores_non_askpass_purple_comments() {
2237        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod\n");
2238        assert_eq!(first_block(&config).askpass(), None);
2239    }
2240
2241    #[test]
2242    fn set_askpass_adds_comment() {
2243        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2244        config.set_host_askpass("myserver", "keychain");
2245        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2246    }
2247
2248    #[test]
2249    fn set_askpass_replaces_existing() {
2250        let mut config =
2251            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2252        config.set_host_askpass("myserver", "op://V/I/p");
2253        assert_eq!(
2254            first_block(&config).askpass(),
2255            Some("op://V/I/p".to_string())
2256        );
2257    }
2258
2259    #[test]
2260    fn set_askpass_empty_removes_comment() {
2261        let mut config =
2262            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2263        config.set_host_askpass("myserver", "");
2264        assert_eq!(first_block(&config).askpass(), None);
2265    }
2266
2267    #[test]
2268    fn set_askpass_preserves_other_directives() {
2269        let mut config =
2270            parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n  # purple:tags prod\n");
2271        config.set_host_askpass("myserver", "vault:secret/ssh");
2272        assert_eq!(
2273            first_block(&config).askpass(),
2274            Some("vault:secret/ssh".to_string())
2275        );
2276        let entry = first_block(&config).to_host_entry();
2277        assert_eq!(entry.user, "admin");
2278        assert!(entry.tags.contains(&"prod".to_string()));
2279    }
2280
2281    #[test]
2282    fn set_askpass_preserves_indent() {
2283        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
2284        config.set_host_askpass("myserver", "keychain");
2285        let raw = first_block(&config)
2286            .directives
2287            .iter()
2288            .find(|d| d.raw_line.contains("purple:askpass"))
2289            .unwrap();
2290        assert!(
2291            raw.raw_line.starts_with("    "),
2292            "Expected 4-space indent, got: {:?}",
2293            raw.raw_line
2294        );
2295    }
2296
2297    #[test]
2298    fn set_askpass_on_nonexistent_host() {
2299        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2300        config.set_host_askpass("nohost", "keychain");
2301        assert_eq!(first_block(&config).askpass(), None);
2302    }
2303
2304    #[test]
2305    fn to_entry_includes_askpass() {
2306        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
2307        let entries = config.host_entries();
2308        assert_eq!(entries.len(), 1);
2309        assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
2310    }
2311
2312    #[test]
2313    fn to_entry_askpass_none_when_absent() {
2314        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2315        let entries = config.host_entries();
2316        assert_eq!(entries.len(), 1);
2317        assert_eq!(entries[0].askpass, None);
2318    }
2319
2320    #[test]
2321    fn set_askpass_vault_with_hash_field() {
2322        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2323        config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
2324        assert_eq!(
2325            first_block(&config).askpass(),
2326            Some("vault:secret/data/team#api_key".to_string())
2327        );
2328    }
2329
2330    #[test]
2331    fn set_askpass_custom_command_with_percent() {
2332        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2333        config.set_host_askpass("myserver", "get-pass %a %h");
2334        assert_eq!(
2335            first_block(&config).askpass(),
2336            Some("get-pass %a %h".to_string())
2337        );
2338    }
2339
2340    #[test]
2341    fn multiple_hosts_independent_askpass() {
2342        let mut config = parse_str("Host alpha\n  HostName a.com\n\nHost beta\n  HostName b.com\n");
2343        config.set_host_askpass("alpha", "keychain");
2344        config.set_host_askpass("beta", "vault:secret/ssh");
2345        assert_eq!(
2346            block_by_index(&config, 0).askpass(),
2347            Some("keychain".to_string())
2348        );
2349        assert_eq!(
2350            block_by_index(&config, 1).askpass(),
2351            Some("vault:secret/ssh".to_string())
2352        );
2353    }
2354
2355    #[test]
2356    fn set_askpass_then_clear_then_set_again() {
2357        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2358        config.set_host_askpass("myserver", "keychain");
2359        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2360        config.set_host_askpass("myserver", "");
2361        assert_eq!(first_block(&config).askpass(), None);
2362        config.set_host_askpass("myserver", "op://V/I/p");
2363        assert_eq!(
2364            first_block(&config).askpass(),
2365            Some("op://V/I/p".to_string())
2366        );
2367    }
2368
2369    #[test]
2370    fn askpass_tab_indent_preserved() {
2371        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
2372        config.set_host_askpass("myserver", "pass:ssh/prod");
2373        let raw = first_block(&config)
2374            .directives
2375            .iter()
2376            .find(|d| d.raw_line.contains("purple:askpass"))
2377            .unwrap();
2378        assert!(
2379            raw.raw_line.starts_with("\t"),
2380            "Expected tab indent, got: {:?}",
2381            raw.raw_line
2382        );
2383    }
2384
2385    #[test]
2386    fn askpass_coexists_with_provider_comment() {
2387        let config = parse_str(
2388            "Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:123\n  # purple:askpass keychain\n",
2389        );
2390        let block = first_block(&config);
2391        assert_eq!(block.askpass(), Some("keychain".to_string()));
2392        assert!(block.provider().is_some());
2393    }
2394
2395    #[test]
2396    fn set_askpass_does_not_remove_tags() {
2397        let mut config =
2398            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod,staging\n");
2399        config.set_host_askpass("myserver", "keychain");
2400        let entry = first_block(&config).to_host_entry();
2401        assert_eq!(entry.askpass, Some("keychain".to_string()));
2402        assert!(entry.tags.contains(&"prod".to_string()));
2403        assert!(entry.tags.contains(&"staging".to_string()));
2404    }
2405
2406    #[test]
2407    fn askpass_idempotent_set_same_value() {
2408        let mut config =
2409            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
2410        config.set_host_askpass("myserver", "keychain");
2411        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2412        let serialized = config.serialize();
2413        assert_eq!(
2414            serialized.matches("purple:askpass").count(),
2415            1,
2416            "Should have exactly one askpass comment"
2417        );
2418    }
2419
2420    #[test]
2421    fn askpass_with_value_containing_equals() {
2422        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2423        config.set_host_askpass("myserver", "cmd --opt=val %h");
2424        assert_eq!(
2425            first_block(&config).askpass(),
2426            Some("cmd --opt=val %h".to_string())
2427        );
2428    }
2429
2430    #[test]
2431    fn askpass_with_value_containing_hash() {
2432        let config =
2433            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:a/b#c\n");
2434        assert_eq!(
2435            first_block(&config).askpass(),
2436            Some("vault:a/b#c".to_string())
2437        );
2438    }
2439
2440    #[test]
2441    fn askpass_with_long_op_uri() {
2442        let uri = "op://My Personal Vault/SSH Production Server/password";
2443        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2444        config.set_host_askpass("myserver", uri);
2445        assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
2446    }
2447
2448    #[test]
2449    fn askpass_does_not_interfere_with_host_matching() {
2450        // askpass is stored as a non-directive comment; it shouldn't affect SSH matching
2451        let config = parse_str(
2452            "Host myserver\n  HostName 10.0.0.1\n  User root\n  # purple:askpass keychain\n",
2453        );
2454        let entry = first_block(&config).to_host_entry();
2455        assert_eq!(entry.user, "root");
2456        assert_eq!(entry.hostname, "10.0.0.1");
2457        assert_eq!(entry.askpass, Some("keychain".to_string()));
2458    }
2459
2460    #[test]
2461    fn set_askpass_on_host_with_many_directives() {
2462        let config_str = "\
2463Host myserver
2464  HostName 10.0.0.1
2465  User admin
2466  Port 2222
2467  IdentityFile ~/.ssh/id_ed25519
2468  ProxyJump bastion
2469  # purple:tags prod,us-east
2470";
2471        let mut config = parse_str(config_str);
2472        config.set_host_askpass("myserver", "pass:ssh/prod");
2473        let entry = first_block(&config).to_host_entry();
2474        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2475        assert_eq!(entry.user, "admin");
2476        assert_eq!(entry.port, 2222);
2477        assert!(entry.tags.contains(&"prod".to_string()));
2478    }
2479
2480    #[test]
2481    fn askpass_with_crlf_line_endings() {
2482        let config =
2483            parse_str("Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n");
2484        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
2485    }
2486
2487    #[test]
2488    fn askpass_only_on_first_matching_host() {
2489        // If two Host blocks have the same alias (unusual), askpass comes from first
2490        let config = parse_str(
2491            "Host dup\n  HostName a.com\n  # purple:askpass keychain\n\nHost dup\n  HostName b.com\n  # purple:askpass vault:x\n",
2492        );
2493        let entries = config.host_entries();
2494        // First match
2495        assert_eq!(entries[0].askpass, Some("keychain".to_string()));
2496    }
2497
2498    #[test]
2499    fn set_askpass_preserves_other_non_directive_comments() {
2500        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";
2501        let mut config = parse_str(config_str);
2502        config.set_host_askpass("myserver", "new-source");
2503        let serialized = config.serialize();
2504        assert!(serialized.contains("# This is a user comment"));
2505        assert!(serialized.contains("# Another comment"));
2506        assert!(serialized.contains("# purple:askpass new-source"));
2507        assert!(!serialized.contains("# purple:askpass old"));
2508    }
2509
2510    #[test]
2511    fn askpass_mixed_with_tunnel_directives() {
2512        let config_str = "\
2513Host myserver
2514  HostName 10.0.0.1
2515  LocalForward 8080 localhost:80
2516  # purple:askpass bw:item
2517  RemoteForward 9090 localhost:9090
2518";
2519        let config = parse_str(config_str);
2520        let entry = first_block(&config).to_host_entry();
2521        assert_eq!(entry.askpass, Some("bw:item".to_string()));
2522        assert_eq!(entry.tunnel_count, 2);
2523    }
2524
2525    // =========================================================================
2526    // askpass: set_askpass idempotent (same value)
2527    // =========================================================================
2528
2529    #[test]
2530    fn set_askpass_idempotent_same_value() {
2531        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n";
2532        let mut config = parse_str(config_str);
2533        config.set_host_askpass("myserver", "keychain");
2534        let output = config.serialize();
2535        // Should still have exactly one askpass comment
2536        assert_eq!(output.matches("purple:askpass").count(), 1);
2537        assert!(output.contains("# purple:askpass keychain"));
2538    }
2539
2540    #[test]
2541    fn set_askpass_with_equals_in_value() {
2542        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2543        config.set_host_askpass("myserver", "cmd --opt=val");
2544        let entries = config.host_entries();
2545        assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2546    }
2547
2548    #[test]
2549    fn set_askpass_with_hash_in_value() {
2550        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2551        config.set_host_askpass("myserver", "vault:secret/data#field");
2552        let entries = config.host_entries();
2553        assert_eq!(
2554            entries[0].askpass,
2555            Some("vault:secret/data#field".to_string())
2556        );
2557    }
2558
2559    #[test]
2560    fn set_askpass_long_op_uri() {
2561        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2562        let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2563        config.set_host_askpass("myserver", long_uri);
2564        assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2565    }
2566
2567    #[test]
2568    fn askpass_host_with_multi_pattern_is_skipped() {
2569        // Multi-pattern host blocks ("Host prod staging") are treated as patterns
2570        // and are not included in host_entries(), so set_askpass is a no-op
2571        let config_str = "Host prod staging\n  HostName 10.0.0.1\n";
2572        let mut config = parse_str(config_str);
2573        config.set_host_askpass("prod", "keychain");
2574        // No entries because multi-pattern hosts are pattern hosts
2575        assert!(config.host_entries().is_empty());
2576    }
2577
2578    #[test]
2579    fn askpass_survives_directive_reorder() {
2580        // askpass should survive even when directives are in unusual order
2581        let config_str = "\
2582Host myserver
2583  # purple:askpass op://V/I/p
2584  HostName 10.0.0.1
2585  User root
2586";
2587        let config = parse_str(config_str);
2588        let entry = first_block(&config).to_host_entry();
2589        assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2590        assert_eq!(entry.hostname, "10.0.0.1");
2591    }
2592
2593    #[test]
2594    fn askpass_among_many_purple_comments() {
2595        let config_str = "\
2596Host myserver
2597  HostName 10.0.0.1
2598  # purple:tags prod,us-east
2599  # purple:provider do:12345
2600  # purple:askpass pass:ssh/prod
2601";
2602        let config = parse_str(config_str);
2603        let entry = first_block(&config).to_host_entry();
2604        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2605        assert!(entry.tags.contains(&"prod".to_string()));
2606    }
2607
2608    #[test]
2609    fn meta_empty_when_no_comment() {
2610        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2611        let config = parse_str(config_str);
2612        let meta = first_block(&config).meta();
2613        assert!(meta.is_empty());
2614    }
2615
2616    #[test]
2617    fn meta_parses_key_value_pairs() {
2618        let config_str = "\
2619Host myhost
2620  HostName 1.2.3.4
2621  # purple:meta region=nyc3,plan=s-1vcpu-1gb
2622";
2623        let config = parse_str(config_str);
2624        let meta = first_block(&config).meta();
2625        assert_eq!(meta.len(), 2);
2626        assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2627        assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2628    }
2629
2630    #[test]
2631    fn meta_round_trip() {
2632        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2633        let mut config = parse_str(config_str);
2634        let meta = vec![
2635            ("region".to_string(), "fra1".to_string()),
2636            ("plan".to_string(), "cx11".to_string()),
2637        ];
2638        config.set_host_meta("myhost", &meta);
2639        let output = config.serialize();
2640        assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2641
2642        let config2 = parse_str(&output);
2643        let parsed = first_block(&config2).meta();
2644        assert_eq!(parsed, meta);
2645    }
2646
2647    #[test]
2648    fn meta_replaces_existing() {
2649        let config_str = "\
2650Host myhost
2651  HostName 1.2.3.4
2652  # purple:meta region=old
2653";
2654        let mut config = parse_str(config_str);
2655        config.set_host_meta("myhost", &[("region".to_string(), "new".to_string())]);
2656        let output = config.serialize();
2657        assert!(!output.contains("region=old"));
2658        assert!(output.contains("region=new"));
2659    }
2660
2661    #[test]
2662    fn meta_removed_when_empty() {
2663        let config_str = "\
2664Host myhost
2665  HostName 1.2.3.4
2666  # purple:meta region=nyc3
2667";
2668        let mut config = parse_str(config_str);
2669        config.set_host_meta("myhost", &[]);
2670        let output = config.serialize();
2671        assert!(!output.contains("purple:meta"));
2672    }
2673
2674    #[test]
2675    fn meta_sanitizes_commas_in_values() {
2676        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2677        let mut config = parse_str(config_str);
2678        let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2679        config.set_host_meta("myhost", &meta);
2680        let output = config.serialize();
2681        // Comma stripped to prevent parse corruption
2682        assert!(output.contains("plan=s-1vcpu1gb"));
2683
2684        let config2 = parse_str(&output);
2685        let parsed = first_block(&config2).meta();
2686        assert_eq!(parsed[0].1, "s-1vcpu1gb");
2687    }
2688
2689    #[test]
2690    fn meta_in_host_entry() {
2691        let config_str = "\
2692Host myhost
2693  HostName 1.2.3.4
2694  # purple:meta region=nyc3,plan=s-1vcpu-1gb
2695";
2696        let config = parse_str(config_str);
2697        let entry = first_block(&config).to_host_entry();
2698        assert_eq!(entry.provider_meta.len(), 2);
2699        assert_eq!(entry.provider_meta[0].0, "region");
2700        assert_eq!(entry.provider_meta[1].0, "plan");
2701    }
2702
2703    #[test]
2704    fn repair_absorbed_group_comment() {
2705        // Simulate the bug: group comment absorbed into preceding block's directives.
2706        let mut config = SshConfigFile {
2707            elements: vec![ConfigElement::HostBlock(HostBlock {
2708                host_pattern: "myserver".to_string(),
2709                raw_host_line: "Host myserver".to_string(),
2710                directives: vec![
2711                    Directive {
2712                        key: "HostName".to_string(),
2713                        value: "10.0.0.1".to_string(),
2714                        raw_line: "  HostName 10.0.0.1".to_string(),
2715                        is_non_directive: false,
2716                    },
2717                    Directive {
2718                        key: String::new(),
2719                        value: String::new(),
2720                        raw_line: "# purple:group Production".to_string(),
2721                        is_non_directive: true,
2722                    },
2723                ],
2724            })],
2725            path: PathBuf::from("/tmp/test_config"),
2726            crlf: false,
2727            bom: false,
2728        };
2729        let count = config.repair_absorbed_group_comments();
2730        assert_eq!(count, 1);
2731        assert_eq!(config.elements.len(), 2);
2732        // Block should only have the HostName directive.
2733        if let ConfigElement::HostBlock(block) = &config.elements[0] {
2734            assert_eq!(block.directives.len(), 1);
2735            assert_eq!(block.directives[0].key, "HostName");
2736        } else {
2737            panic!("Expected HostBlock");
2738        }
2739        // Group comment should be a GlobalLine.
2740        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2741            assert_eq!(line, "# purple:group Production");
2742        } else {
2743            panic!("Expected GlobalLine for group comment");
2744        }
2745    }
2746
2747    #[test]
2748    fn repair_strips_trailing_blanks_before_group() {
2749        let mut config = SshConfigFile {
2750            elements: vec![ConfigElement::HostBlock(HostBlock {
2751                host_pattern: "myserver".to_string(),
2752                raw_host_line: "Host myserver".to_string(),
2753                directives: vec![
2754                    Directive {
2755                        key: "HostName".to_string(),
2756                        value: "10.0.0.1".to_string(),
2757                        raw_line: "  HostName 10.0.0.1".to_string(),
2758                        is_non_directive: false,
2759                    },
2760                    Directive {
2761                        key: String::new(),
2762                        value: String::new(),
2763                        raw_line: "".to_string(),
2764                        is_non_directive: true,
2765                    },
2766                    Directive {
2767                        key: String::new(),
2768                        value: String::new(),
2769                        raw_line: "# purple:group Staging".to_string(),
2770                        is_non_directive: true,
2771                    },
2772                ],
2773            })],
2774            path: PathBuf::from("/tmp/test_config"),
2775            crlf: false,
2776            bom: false,
2777        };
2778        let count = config.repair_absorbed_group_comments();
2779        assert_eq!(count, 1);
2780        // Block keeps only HostName.
2781        if let ConfigElement::HostBlock(block) = &config.elements[0] {
2782            assert_eq!(block.directives.len(), 1);
2783        } else {
2784            panic!("Expected HostBlock");
2785        }
2786        // Blank line and group comment are now GlobalLines.
2787        assert_eq!(config.elements.len(), 3);
2788        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2789            assert!(line.trim().is_empty());
2790        } else {
2791            panic!("Expected blank GlobalLine");
2792        }
2793        if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2794            assert!(line.starts_with("# purple:group"));
2795        } else {
2796            panic!("Expected group GlobalLine");
2797        }
2798    }
2799
2800    #[test]
2801    fn repair_clean_config_returns_zero() {
2802        let mut config =
2803            parse_str("# purple:group Production\nHost myserver\n  HostName 10.0.0.1\n");
2804        let count = config.repair_absorbed_group_comments();
2805        assert_eq!(count, 0);
2806    }
2807
2808    #[test]
2809    fn repair_roundtrip_serializes_correctly() {
2810        // Build a corrupted config manually.
2811        let mut config = SshConfigFile {
2812            elements: vec![
2813                ConfigElement::HostBlock(HostBlock {
2814                    host_pattern: "server1".to_string(),
2815                    raw_host_line: "Host server1".to_string(),
2816                    directives: vec![
2817                        Directive {
2818                            key: "HostName".to_string(),
2819                            value: "10.0.0.1".to_string(),
2820                            raw_line: "  HostName 10.0.0.1".to_string(),
2821                            is_non_directive: false,
2822                        },
2823                        Directive {
2824                            key: String::new(),
2825                            value: String::new(),
2826                            raw_line: "".to_string(),
2827                            is_non_directive: true,
2828                        },
2829                        Directive {
2830                            key: String::new(),
2831                            value: String::new(),
2832                            raw_line: "# purple:group Staging".to_string(),
2833                            is_non_directive: true,
2834                        },
2835                    ],
2836                }),
2837                ConfigElement::HostBlock(HostBlock {
2838                    host_pattern: "server2".to_string(),
2839                    raw_host_line: "Host server2".to_string(),
2840                    directives: vec![Directive {
2841                        key: "HostName".to_string(),
2842                        value: "10.0.0.2".to_string(),
2843                        raw_line: "  HostName 10.0.0.2".to_string(),
2844                        is_non_directive: false,
2845                    }],
2846                }),
2847            ],
2848            path: PathBuf::from("/tmp/test_config"),
2849            crlf: false,
2850            bom: false,
2851        };
2852        let count = config.repair_absorbed_group_comments();
2853        assert_eq!(count, 1);
2854        let output = config.serialize();
2855        // The group comment should appear between the two host blocks.
2856        let expected = "\
2857Host server1
2858  HostName 10.0.0.1
2859
2860# purple:group Staging
2861Host server2
2862  HostName 10.0.0.2
2863";
2864        assert_eq!(output, expected);
2865    }
2866
2867    // =========================================================================
2868    // delete_host: orphaned group header cleanup
2869    // =========================================================================
2870
2871    #[test]
2872    fn delete_last_provider_host_removes_group_header() {
2873        let config_str = "\
2874# purple:group DigitalOcean
2875Host do-web
2876  HostName 1.2.3.4
2877  # purple:provider digitalocean:123
2878";
2879        let mut config = parse_str(config_str);
2880        config.delete_host("do-web");
2881        let has_header = config
2882            .elements
2883            .iter()
2884            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2885        assert!(
2886            !has_header,
2887            "Group header should be removed when last provider host is deleted"
2888        );
2889    }
2890
2891    #[test]
2892    fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2893        let config_str = "\
2894# purple:group DigitalOcean
2895Host do-web
2896  HostName 1.2.3.4
2897  # purple:provider digitalocean:123
2898
2899Host do-db
2900  HostName 5.6.7.8
2901  # purple:provider digitalocean:456
2902";
2903        let mut config = parse_str(config_str);
2904        config.delete_host("do-web");
2905        let has_header = config.elements.iter().any(|e| {
2906            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2907        });
2908        assert!(
2909            has_header,
2910            "Group header should be preserved when other provider hosts remain"
2911        );
2912        assert_eq!(config.host_entries().len(), 1);
2913    }
2914
2915    #[test]
2916    fn delete_non_provider_host_leaves_group_headers() {
2917        let config_str = "\
2918Host personal
2919  HostName 10.0.0.1
2920
2921# purple:group DigitalOcean
2922Host do-web
2923  HostName 1.2.3.4
2924  # purple:provider digitalocean:123
2925";
2926        let mut config = parse_str(config_str);
2927        config.delete_host("personal");
2928        let has_header = config.elements.iter().any(|e| {
2929            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2930        });
2931        assert!(
2932            has_header,
2933            "Group header should not be affected by deleting a non-provider host"
2934        );
2935        assert_eq!(config.host_entries().len(), 1);
2936    }
2937
2938    #[test]
2939    fn delete_host_undoable_keeps_group_header_for_undo() {
2940        // delete_host_undoable does NOT remove orphaned group headers so that
2941        // undo (insert_host_at) can restore the config to its original state.
2942        // Orphaned headers are cleaned up at startup instead.
2943        let config_str = "\
2944# purple:group Vultr
2945Host vultr-web
2946  HostName 2.3.4.5
2947  # purple:provider vultr:789
2948";
2949        let mut config = parse_str(config_str);
2950        let result = config.delete_host_undoable("vultr-web");
2951        assert!(result.is_some());
2952        let has_header = config
2953            .elements
2954            .iter()
2955            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group")));
2956        assert!(has_header, "Group header should be kept for undo");
2957    }
2958
2959    #[test]
2960    fn delete_host_undoable_preserves_header_when_others_remain() {
2961        let config_str = "\
2962# purple:group AWS EC2
2963Host aws-web
2964  HostName 3.4.5.6
2965  # purple:provider aws:i-111
2966
2967Host aws-db
2968  HostName 7.8.9.0
2969  # purple:provider aws:i-222
2970";
2971        let mut config = parse_str(config_str);
2972        let result = config.delete_host_undoable("aws-web");
2973        assert!(result.is_some());
2974        let has_header = config.elements.iter().any(
2975            |e| matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2")),
2976        );
2977        assert!(
2978            has_header,
2979            "Group header preserved when other provider hosts remain (undoable)"
2980        );
2981    }
2982
2983    #[test]
2984    fn delete_host_undoable_returns_original_position_for_undo() {
2985        // Group header at index 0, host at index 1. Undo re-inserts at index 1
2986        // which correctly restores the host after the group header.
2987        let config_str = "\
2988# purple:group Vultr
2989Host vultr-web
2990  HostName 2.3.4.5
2991  # purple:provider vultr:789
2992
2993Host manual
2994  HostName 10.0.0.1
2995";
2996        let mut config = parse_str(config_str);
2997        let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2998        // Position is the original index (1), not adjusted, since no header was removed
2999        assert_eq!(pos, 1, "Position should be the original host index");
3000        // Undo: re-insert at the original position
3001        config.insert_host_at(element, pos);
3002        // The host should be back, group header intact, manual host accessible
3003        let output = config.serialize();
3004        assert!(
3005            output.contains("# purple:group Vultr"),
3006            "Group header should be present"
3007        );
3008        assert!(output.contains("Host vultr-web"), "Host should be restored");
3009        assert!(output.contains("Host manual"), "Manual host should survive");
3010        assert_eq!(config_str, output);
3011    }
3012
3013    // =========================================================================
3014    // add_host: wildcard ordering
3015    // =========================================================================
3016
3017    #[test]
3018    fn add_host_inserts_before_trailing_wildcard() {
3019        let config_str = "\
3020Host existing
3021  HostName 10.0.0.1
3022
3023Host *
3024  ServerAliveInterval 60
3025";
3026        let mut config = parse_str(config_str);
3027        let entry = HostEntry {
3028            alias: "newhost".to_string(),
3029            hostname: "10.0.0.2".to_string(),
3030            port: 22,
3031            ..Default::default()
3032        };
3033        config.add_host(&entry);
3034        let output = config.serialize();
3035        let new_pos = output.find("Host newhost").unwrap();
3036        let wildcard_pos = output.find("Host *").unwrap();
3037        assert!(
3038            new_pos < wildcard_pos,
3039            "New host should appear before Host *: {}",
3040            output
3041        );
3042        let existing_pos = output.find("Host existing").unwrap();
3043        assert!(existing_pos < new_pos);
3044    }
3045
3046    #[test]
3047    fn add_host_appends_when_no_wildcards() {
3048        let config_str = "\
3049Host existing
3050  HostName 10.0.0.1
3051";
3052        let mut config = parse_str(config_str);
3053        let entry = HostEntry {
3054            alias: "newhost".to_string(),
3055            hostname: "10.0.0.2".to_string(),
3056            port: 22,
3057            ..Default::default()
3058        };
3059        config.add_host(&entry);
3060        let output = config.serialize();
3061        let existing_pos = output.find("Host existing").unwrap();
3062        let new_pos = output.find("Host newhost").unwrap();
3063        assert!(existing_pos < new_pos, "New host should be appended at end");
3064    }
3065
3066    #[test]
3067    fn add_host_appends_when_wildcard_at_beginning() {
3068        // Host * at the top acts as global defaults. New hosts go after it.
3069        let config_str = "\
3070Host *
3071  ServerAliveInterval 60
3072
3073Host existing
3074  HostName 10.0.0.1
3075";
3076        let mut config = parse_str(config_str);
3077        let entry = HostEntry {
3078            alias: "newhost".to_string(),
3079            hostname: "10.0.0.2".to_string(),
3080            port: 22,
3081            ..Default::default()
3082        };
3083        config.add_host(&entry);
3084        let output = config.serialize();
3085        let existing_pos = output.find("Host existing").unwrap();
3086        let new_pos = output.find("Host newhost").unwrap();
3087        assert!(
3088            existing_pos < new_pos,
3089            "New host should be appended at end when wildcard is at top: {}",
3090            output
3091        );
3092    }
3093
3094    #[test]
3095    fn add_host_inserts_before_trailing_pattern_host() {
3096        let config_str = "\
3097Host existing
3098  HostName 10.0.0.1
3099
3100Host *.example.com
3101  ProxyJump bastion
3102";
3103        let mut config = parse_str(config_str);
3104        let entry = HostEntry {
3105            alias: "newhost".to_string(),
3106            hostname: "10.0.0.2".to_string(),
3107            port: 22,
3108            ..Default::default()
3109        };
3110        config.add_host(&entry);
3111        let output = config.serialize();
3112        let new_pos = output.find("Host newhost").unwrap();
3113        let pattern_pos = output.find("Host *.example.com").unwrap();
3114        assert!(
3115            new_pos < pattern_pos,
3116            "New host should appear before pattern host: {}",
3117            output
3118        );
3119    }
3120
3121    #[test]
3122    fn add_host_no_triple_blank_lines() {
3123        let config_str = "\
3124Host existing
3125  HostName 10.0.0.1
3126
3127Host *
3128  ServerAliveInterval 60
3129";
3130        let mut config = parse_str(config_str);
3131        let entry = HostEntry {
3132            alias: "newhost".to_string(),
3133            hostname: "10.0.0.2".to_string(),
3134            port: 22,
3135            ..Default::default()
3136        };
3137        config.add_host(&entry);
3138        let output = config.serialize();
3139        assert!(
3140            !output.contains("\n\n\n"),
3141            "Should not have triple blank lines: {}",
3142            output
3143        );
3144    }
3145
3146    #[test]
3147    fn provider_group_display_name_matches_providers_mod() {
3148        // Ensure the duplicated display name function in model.rs stays in sync
3149        // with providers::provider_display_name(). If these diverge, group header
3150        // cleanup (remove_orphaned_group_header) will fail to match headers
3151        // written by the sync engine.
3152        let providers = [
3153            "digitalocean",
3154            "vultr",
3155            "linode",
3156            "hetzner",
3157            "upcloud",
3158            "proxmox",
3159            "aws",
3160            "scaleway",
3161            "gcp",
3162            "azure",
3163            "tailscale",
3164            "oracle",
3165        ];
3166        for name in &providers {
3167            assert_eq!(
3168                provider_group_display_name(name),
3169                crate::providers::provider_display_name(name),
3170                "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
3171                name,
3172                provider_group_display_name(name),
3173                crate::providers::provider_display_name(name),
3174            );
3175        }
3176    }
3177
3178    #[test]
3179    fn test_sanitize_tag_strips_control_chars() {
3180        assert_eq!(HostBlock::sanitize_tag("prod"), "prod");
3181        assert_eq!(HostBlock::sanitize_tag("prod\n"), "prod");
3182        assert_eq!(HostBlock::sanitize_tag("pr\x00od"), "prod");
3183        assert_eq!(HostBlock::sanitize_tag("\t\r\n"), "");
3184    }
3185
3186    #[test]
3187    fn test_sanitize_tag_strips_commas() {
3188        assert_eq!(HostBlock::sanitize_tag("prod,staging"), "prodstaging");
3189        assert_eq!(HostBlock::sanitize_tag(",,,"), "");
3190    }
3191
3192    #[test]
3193    fn test_sanitize_tag_strips_bidi() {
3194        assert_eq!(HostBlock::sanitize_tag("prod\u{202E}tset"), "prodtset");
3195        assert_eq!(HostBlock::sanitize_tag("\u{200B}zero\u{FEFF}"), "zero");
3196    }
3197
3198    #[test]
3199    fn test_sanitize_tag_truncates_long() {
3200        let long = "a".repeat(200);
3201        assert_eq!(HostBlock::sanitize_tag(&long).len(), 128);
3202    }
3203
3204    #[test]
3205    fn test_sanitize_tag_preserves_unicode() {
3206        assert_eq!(HostBlock::sanitize_tag("日本語"), "日本語");
3207        assert_eq!(HostBlock::sanitize_tag("café"), "café");
3208    }
3209
3210    // =========================================================================
3211    // provider_tags parsing and has_provider_tags_comment tests
3212    // =========================================================================
3213
3214    #[test]
3215    fn test_provider_tags_parsing() {
3216        let config =
3217            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags a,b,c\n");
3218        let entry = first_block(&config).to_host_entry();
3219        assert_eq!(entry.provider_tags, vec!["a", "b", "c"]);
3220    }
3221
3222    #[test]
3223    fn test_provider_tags_empty() {
3224        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3225        let entry = first_block(&config).to_host_entry();
3226        assert!(entry.provider_tags.is_empty());
3227    }
3228
3229    #[test]
3230    fn test_has_provider_tags_comment_present() {
3231        let config =
3232            parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags prod\n");
3233        assert!(first_block(&config).has_provider_tags_comment());
3234        assert!(first_block(&config).to_host_entry().has_provider_tags);
3235    }
3236
3237    #[test]
3238    fn test_has_provider_tags_comment_sentinel() {
3239        // Bare sentinel (no tags) still counts as "has provider_tags"
3240        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider_tags\n");
3241        assert!(first_block(&config).has_provider_tags_comment());
3242        assert!(first_block(&config).to_host_entry().has_provider_tags);
3243        assert!(
3244            first_block(&config)
3245                .to_host_entry()
3246                .provider_tags
3247                .is_empty()
3248        );
3249    }
3250
3251    #[test]
3252    fn test_has_provider_tags_comment_absent() {
3253        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3254        assert!(!first_block(&config).has_provider_tags_comment());
3255        assert!(!first_block(&config).to_host_entry().has_provider_tags);
3256    }
3257
3258    #[test]
3259    fn test_set_tags_does_not_delete_provider_tags() {
3260        let mut config = parse_str(
3261            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1\n  # purple:provider_tags cloud1,cloud2\n",
3262        );
3263        config.set_host_tags("myserver", &["newuser".to_string()]);
3264        let entry = first_block(&config).to_host_entry();
3265        assert_eq!(entry.tags, vec!["newuser"]);
3266        assert_eq!(entry.provider_tags, vec!["cloud1", "cloud2"]);
3267    }
3268
3269    #[test]
3270    fn test_set_provider_tags_does_not_delete_user_tags() {
3271        let mut config = parse_str(
3272            "Host myserver\n  HostName 10.0.0.1\n  # purple:tags user1,user2\n  # purple:provider_tags old\n",
3273        );
3274        config.set_host_provider_tags("myserver", &["new1".to_string(), "new2".to_string()]);
3275        let entry = first_block(&config).to_host_entry();
3276        assert_eq!(entry.tags, vec!["user1", "user2"]);
3277        assert_eq!(entry.provider_tags, vec!["new1", "new2"]);
3278    }
3279
3280    #[test]
3281    fn test_set_askpass_does_not_delete_similar_comments() {
3282        // A hypothetical "# purple:askpass_backup test" should NOT be deleted by set_askpass
3283        let mut config = parse_str(
3284            "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n  # purple:askpass_backup test\n",
3285        );
3286        config.set_host_askpass("myserver", "op://vault/item/pass");
3287        let entry = first_block(&config).to_host_entry();
3288        assert_eq!(entry.askpass, Some("op://vault/item/pass".to_string()));
3289        // The similar-but-different comment survives
3290        let serialized = config.serialize();
3291        assert!(serialized.contains("purple:askpass_backup test"));
3292    }
3293
3294    #[test]
3295    fn test_set_meta_does_not_delete_similar_comments() {
3296        // A hypothetical "# purple:metadata foo" should NOT be deleted by set_meta
3297        let mut config = parse_str(
3298            "Host myserver\n  HostName 10.0.0.1\n  # purple:meta region=us-east\n  # purple:metadata foo\n",
3299        );
3300        config.set_host_meta("myserver", &[("region".to_string(), "eu-west".to_string())]);
3301        let serialized = config.serialize();
3302        assert!(serialized.contains("purple:meta region=eu-west"));
3303        assert!(serialized.contains("purple:metadata foo"));
3304    }
3305
3306    #[test]
3307    fn test_set_meta_sanitizes_control_chars() {
3308        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
3309        config.set_host_meta(
3310            "myserver",
3311            &[
3312                ("region".to_string(), "us\x00east".to_string()),
3313                ("zone".to_string(), "a\u{202E}b".to_string()),
3314            ],
3315        );
3316        let serialized = config.serialize();
3317        // Control chars and bidi should be stripped from values
3318        assert!(serialized.contains("region=useast"));
3319        assert!(serialized.contains("zone=ab"));
3320        assert!(!serialized.contains('\x00'));
3321        assert!(!serialized.contains('\u{202E}'));
3322    }
3323
3324    // ── stale tests ──────────────────────────────────────────────────────
3325
3326    #[test]
3327    fn stale_returns_timestamp() {
3328        let config_str = "\
3329Host web
3330  HostName 1.2.3.4
3331  # purple:stale 1711900000
3332";
3333        let config = parse_str(config_str);
3334        assert_eq!(first_block(&config).stale(), Some(1711900000));
3335    }
3336
3337    #[test]
3338    fn stale_returns_none_when_absent() {
3339        let config_str = "Host web\n  HostName 1.2.3.4\n";
3340        let config = parse_str(config_str);
3341        assert_eq!(first_block(&config).stale(), None);
3342    }
3343
3344    #[test]
3345    fn stale_returns_none_for_malformed() {
3346        for bad in &[
3347            "Host w\n  HostName 1.2.3.4\n  # purple:stale abc\n",
3348            "Host w\n  HostName 1.2.3.4\n  # purple:stale\n",
3349            "Host w\n  HostName 1.2.3.4\n  # purple:stale -1\n",
3350        ] {
3351            let config = parse_str(bad);
3352            assert_eq!(first_block(&config).stale(), None, "input: {bad}");
3353        }
3354    }
3355
3356    #[test]
3357    fn set_stale_adds_comment() {
3358        let config_str = "Host web\n  HostName 1.2.3.4\n";
3359        let mut config = parse_str(config_str);
3360        first_block_mut(&mut config).set_stale(1711900000);
3361        assert_eq!(first_block(&config).stale(), Some(1711900000));
3362        assert!(config.serialize().contains("# purple:stale 1711900000"));
3363    }
3364
3365    #[test]
3366    fn set_stale_replaces_existing() {
3367        let config_str = "\
3368Host web
3369  HostName 1.2.3.4
3370  # purple:stale 1000
3371";
3372        let mut config = parse_str(config_str);
3373        first_block_mut(&mut config).set_stale(2000);
3374        assert_eq!(first_block(&config).stale(), Some(2000));
3375        let output = config.serialize();
3376        assert!(!output.contains("1000"));
3377        assert!(output.contains("# purple:stale 2000"));
3378    }
3379
3380    #[test]
3381    fn clear_stale_removes_comment() {
3382        let config_str = "\
3383Host web
3384  HostName 1.2.3.4
3385  # purple:stale 1711900000
3386";
3387        let mut config = parse_str(config_str);
3388        first_block_mut(&mut config).clear_stale();
3389        assert_eq!(first_block(&config).stale(), None);
3390        assert!(!config.serialize().contains("purple:stale"));
3391    }
3392
3393    #[test]
3394    fn clear_stale_when_absent_is_noop() {
3395        let config_str = "Host web\n  HostName 1.2.3.4\n";
3396        let mut config = parse_str(config_str);
3397        let before = config.serialize();
3398        first_block_mut(&mut config).clear_stale();
3399        assert_eq!(config.serialize(), before);
3400    }
3401
3402    #[test]
3403    fn stale_roundtrip() {
3404        let config_str = "\
3405Host web
3406  HostName 1.2.3.4
3407  # purple:stale 1711900000
3408";
3409        let config = parse_str(config_str);
3410        let output = config.serialize();
3411        let config2 = parse_str(&output);
3412        assert_eq!(first_block(&config2).stale(), Some(1711900000));
3413    }
3414
3415    #[test]
3416    fn stale_in_host_entry() {
3417        let config_str = "\
3418Host web
3419  HostName 1.2.3.4
3420  # purple:stale 1711900000
3421";
3422        let config = parse_str(config_str);
3423        let entry = first_block(&config).to_host_entry();
3424        assert_eq!(entry.stale, Some(1711900000));
3425    }
3426
3427    #[test]
3428    fn stale_coexists_with_other_annotations() {
3429        let config_str = "\
3430Host web
3431  HostName 1.2.3.4
3432  # purple:tags prod
3433  # purple:provider do:12345
3434  # purple:askpass keychain
3435  # purple:meta region=nyc3
3436  # purple:stale 1711900000
3437";
3438        let config = parse_str(config_str);
3439        let entry = first_block(&config).to_host_entry();
3440        assert_eq!(entry.stale, Some(1711900000));
3441        assert!(entry.tags.contains(&"prod".to_string()));
3442        assert_eq!(entry.provider, Some("do".to_string()));
3443        assert_eq!(entry.askpass, Some("keychain".to_string()));
3444        assert_eq!(entry.provider_meta[0].0, "region");
3445    }
3446
3447    #[test]
3448    fn set_host_stale_delegates() {
3449        let config_str = "\
3450Host web
3451  HostName 1.2.3.4
3452
3453Host db
3454  HostName 5.6.7.8
3455";
3456        let mut config = parse_str(config_str);
3457        config.set_host_stale("db", 1234567890);
3458        assert_eq!(config.host_entries()[1].stale, Some(1234567890));
3459        assert_eq!(config.host_entries()[0].stale, None);
3460    }
3461
3462    #[test]
3463    fn clear_host_stale_delegates() {
3464        let config_str = "\
3465Host web
3466  HostName 1.2.3.4
3467  # purple:stale 1711900000
3468";
3469        let mut config = parse_str(config_str);
3470        config.clear_host_stale("web");
3471        assert_eq!(first_block(&config).stale(), None);
3472    }
3473
3474    #[test]
3475    fn stale_hosts_collects_all() {
3476        let config_str = "\
3477Host web
3478  HostName 1.2.3.4
3479  # purple:stale 1000
3480
3481Host db
3482  HostName 5.6.7.8
3483
3484Host app
3485  HostName 9.10.11.12
3486  # purple:stale 2000
3487";
3488        let config = parse_str(config_str);
3489        let stale = config.stale_hosts();
3490        assert_eq!(stale.len(), 2);
3491        assert_eq!(stale[0], ("web".to_string(), 1000));
3492        assert_eq!(stale[1], ("app".to_string(), 2000));
3493    }
3494
3495    #[test]
3496    fn set_stale_preserves_indent() {
3497        let config_str = "Host web\n\tHostName 1.2.3.4\n";
3498        let mut config = parse_str(config_str);
3499        first_block_mut(&mut config).set_stale(1711900000);
3500        assert!(config.serialize().contains("\t# purple:stale 1711900000"));
3501    }
3502
3503    #[test]
3504    fn stale_does_not_match_similar_comments() {
3505        let config_str = "\
3506Host web
3507  HostName 1.2.3.4
3508  # purple:stale_backup 999
3509";
3510        let config = parse_str(config_str);
3511        assert_eq!(first_block(&config).stale(), None);
3512    }
3513
3514    #[test]
3515    fn stale_with_whitespace_in_timestamp() {
3516        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale  1711900000 \n";
3517        let config = parse_str(config_str);
3518        assert_eq!(first_block(&config).stale(), Some(1711900000));
3519    }
3520
3521    #[test]
3522    fn stale_with_u64_max() {
3523        let ts = u64::MAX;
3524        let config_str = format!("Host w\n  HostName 1.2.3.4\n  # purple:stale {}\n", ts);
3525        let config = parse_str(&config_str);
3526        assert_eq!(first_block(&config).stale(), Some(ts));
3527        // Round-trip
3528        let output = config.serialize();
3529        let config2 = parse_str(&output);
3530        assert_eq!(first_block(&config2).stale(), Some(ts));
3531    }
3532
3533    #[test]
3534    fn stale_with_u64_overflow() {
3535        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 18446744073709551616\n";
3536        let config = parse_str(config_str);
3537        assert_eq!(first_block(&config).stale(), None);
3538    }
3539
3540    #[test]
3541    fn stale_timestamp_zero() {
3542        let config_str = "Host w\n  HostName 1.2.3.4\n  # purple:stale 0\n";
3543        let config = parse_str(config_str);
3544        assert_eq!(first_block(&config).stale(), Some(0));
3545    }
3546
3547    #[test]
3548    fn set_host_stale_nonexistent_alias_is_noop() {
3549        let config_str = "Host web\n  HostName 1.2.3.4\n";
3550        let mut config = parse_str(config_str);
3551        let before = config.serialize();
3552        config.set_host_stale("nonexistent", 12345);
3553        assert_eq!(config.serialize(), before);
3554    }
3555
3556    #[test]
3557    fn clear_host_stale_nonexistent_alias_is_noop() {
3558        let config_str = "Host web\n  HostName 1.2.3.4\n";
3559        let mut config = parse_str(config_str);
3560        let before = config.serialize();
3561        config.clear_host_stale("nonexistent");
3562        assert_eq!(config.serialize(), before);
3563    }
3564
3565    #[test]
3566    fn stale_hosts_empty_config() {
3567        let config_str = "";
3568        let config = parse_str(config_str);
3569        assert!(config.stale_hosts().is_empty());
3570    }
3571
3572    #[test]
3573    fn stale_hosts_no_stale() {
3574        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
3575        let config = parse_str(config_str);
3576        assert!(config.stale_hosts().is_empty());
3577    }
3578
3579    #[test]
3580    fn clear_stale_preserves_other_purple_comments() {
3581        let config_str = "\
3582Host web
3583  HostName 1.2.3.4
3584  # purple:tags prod
3585  # purple:provider do:123
3586  # purple:askpass keychain
3587  # purple:meta region=nyc3
3588  # purple:stale 1711900000
3589";
3590        let mut config = parse_str(config_str);
3591        config.clear_host_stale("web");
3592        let entry = first_block(&config).to_host_entry();
3593        assert_eq!(entry.stale, None);
3594        assert!(entry.tags.contains(&"prod".to_string()));
3595        assert_eq!(entry.provider, Some("do".to_string()));
3596        assert_eq!(entry.askpass, Some("keychain".to_string()));
3597        assert_eq!(entry.provider_meta[0].0, "region");
3598    }
3599
3600    #[test]
3601    fn set_stale_preserves_other_purple_comments() {
3602        let config_str = "\
3603Host web
3604  HostName 1.2.3.4
3605  # purple:tags prod
3606  # purple:provider do:123
3607  # purple:askpass keychain
3608  # purple:meta region=nyc3
3609";
3610        let mut config = parse_str(config_str);
3611        config.set_host_stale("web", 1711900000);
3612        let entry = first_block(&config).to_host_entry();
3613        assert_eq!(entry.stale, Some(1711900000));
3614        assert!(entry.tags.contains(&"prod".to_string()));
3615        assert_eq!(entry.provider, Some("do".to_string()));
3616        assert_eq!(entry.askpass, Some("keychain".to_string()));
3617        assert_eq!(entry.provider_meta[0].0, "region");
3618    }
3619
3620    #[test]
3621    fn stale_multiple_comments_first_wins() {
3622        let config_str = "\
3623Host web
3624  HostName 1.2.3.4
3625  # purple:stale 1000
3626  # purple:stale 2000
3627";
3628        let config = parse_str(config_str);
3629        assert_eq!(first_block(&config).stale(), Some(1000));
3630    }
3631
3632    #[test]
3633    fn set_stale_removes_multiple_stale_comments() {
3634        let config_str = "\
3635Host web
3636  HostName 1.2.3.4
3637  # purple:stale 1000
3638  # purple:stale 2000
3639";
3640        let mut config = parse_str(config_str);
3641        first_block_mut(&mut config).set_stale(3000);
3642        assert_eq!(first_block(&config).stale(), Some(3000));
3643        let output = config.serialize();
3644        assert_eq!(output.matches("purple:stale").count(), 1);
3645    }
3646
3647    #[test]
3648    fn stale_absent_in_host_entry() {
3649        let config_str = "Host web\n  HostName 1.2.3.4\n";
3650        let config = parse_str(config_str);
3651        assert_eq!(first_block(&config).to_host_entry().stale, None);
3652    }
3653
3654    #[test]
3655    fn set_stale_four_space_indent() {
3656        let config_str = "Host web\n    HostName 1.2.3.4\n";
3657        let mut config = parse_str(config_str);
3658        first_block_mut(&mut config).set_stale(1711900000);
3659        assert!(config.serialize().contains("    # purple:stale 1711900000"));
3660    }
3661
3662    #[test]
3663    fn clear_stale_removes_bare_comment() {
3664        let config_str = "Host web\n  HostName 1.2.3.4\n  # purple:stale\n";
3665        let mut config = parse_str(config_str);
3666        first_block_mut(&mut config).clear_stale();
3667        assert!(!config.serialize().contains("purple:stale"));
3668    }
3669
3670    // ── SSH config integrity tests for stale operations ──────────────
3671
3672    #[test]
3673    fn stale_preserves_blank_line_between_hosts() {
3674        let config_str = "\
3675Host web
3676  HostName 1.2.3.4
3677
3678Host db
3679  HostName 5.6.7.8
3680";
3681        let mut config = parse_str(config_str);
3682        config.set_host_stale("web", 1711900000);
3683        let output = config.serialize();
3684        // There must still be a blank line between hosts
3685        assert!(
3686            output.contains("# purple:stale 1711900000\n\nHost db"),
3687            "blank line between hosts lost after set_stale:\n{}",
3688            output
3689        );
3690    }
3691
3692    #[test]
3693    fn stale_preserves_blank_line_before_group_header() {
3694        let config_str = "\
3695Host do-web
3696  HostName 1.2.3.4
3697  # purple:provider digitalocean:111
3698
3699# purple:group Hetzner
3700
3701Host hz-cache
3702  HostName 9.10.11.12
3703  # purple:provider hetzner:333
3704";
3705        let mut config = parse_str(config_str);
3706        config.set_host_stale("do-web", 1711900000);
3707        let output = config.serialize();
3708        // There must still be a blank line before the Hetzner group header
3709        assert!(
3710            output.contains("\n\n# purple:group Hetzner"),
3711            "blank line before group header lost after set_stale:\n{}",
3712            output
3713        );
3714    }
3715
3716    #[test]
3717    fn stale_set_and_clear_is_byte_identical() {
3718        let config_str = "\
3719Host manual
3720  HostName 10.0.0.1
3721  User admin
3722
3723# purple:group DigitalOcean
3724
3725Host do-web
3726  HostName 1.2.3.4
3727  User root
3728  # purple:provider digitalocean:111
3729  # purple:tags prod
3730
3731Host do-db
3732  HostName 5.6.7.8
3733  User root
3734  # purple:provider digitalocean:222
3735  # purple:meta region=nyc3
3736
3737# purple:group Hetzner
3738
3739Host hz-cache
3740  HostName 9.10.11.12
3741  User root
3742  # purple:provider hetzner:333
3743";
3744        let original = config_str.to_string();
3745        let mut config = parse_str(config_str);
3746
3747        // Mark stale
3748        config.set_host_stale("do-db", 1711900000);
3749        let after_stale = config.serialize();
3750        assert_ne!(after_stale, original, "stale should change the config");
3751
3752        // Clear stale
3753        config.clear_host_stale("do-db");
3754        let after_clear = config.serialize();
3755        assert_eq!(
3756            after_clear, original,
3757            "clearing stale must restore byte-identical config"
3758        );
3759    }
3760
3761    #[test]
3762    fn stale_does_not_accumulate_blank_lines() {
3763        let config_str = "Host web\n  HostName 1.2.3.4\n\nHost db\n  HostName 5.6.7.8\n";
3764        let mut config = parse_str(config_str);
3765
3766        // Set and clear stale 10 times
3767        for _ in 0..10 {
3768            config.set_host_stale("web", 1711900000);
3769            config.clear_host_stale("web");
3770        }
3771
3772        let output = config.serialize();
3773        assert_eq!(
3774            output, config_str,
3775            "repeated set/clear must not accumulate blank lines"
3776        );
3777    }
3778
3779    #[test]
3780    fn stale_preserves_all_directives_and_comments() {
3781        let config_str = "\
3782Host complex
3783  HostName 1.2.3.4
3784  User deploy
3785  Port 2222
3786  IdentityFile ~/.ssh/id_ed25519
3787  ProxyJump bastion
3788  LocalForward 8080 localhost:80
3789  # purple:provider digitalocean:999
3790  # purple:tags prod,us-east
3791  # purple:provider_tags web-tier
3792  # purple:askpass keychain
3793  # purple:meta region=nyc3,plan=s-1vcpu-1gb
3794  # This is a user comment
3795";
3796        let mut config = parse_str(config_str);
3797        let entry_before = first_block(&config).to_host_entry();
3798
3799        config.set_host_stale("complex", 1711900000);
3800        let entry_after = first_block(&config).to_host_entry();
3801
3802        // Every field must survive stale marking
3803        assert_eq!(entry_after.hostname, entry_before.hostname);
3804        assert_eq!(entry_after.user, entry_before.user);
3805        assert_eq!(entry_after.port, entry_before.port);
3806        assert_eq!(entry_after.identity_file, entry_before.identity_file);
3807        assert_eq!(entry_after.proxy_jump, entry_before.proxy_jump);
3808        assert_eq!(entry_after.tags, entry_before.tags);
3809        assert_eq!(entry_after.provider_tags, entry_before.provider_tags);
3810        assert_eq!(entry_after.provider, entry_before.provider);
3811        assert_eq!(entry_after.askpass, entry_before.askpass);
3812        assert_eq!(entry_after.provider_meta, entry_before.provider_meta);
3813        assert_eq!(entry_after.tunnel_count, entry_before.tunnel_count);
3814        assert_eq!(entry_after.stale, Some(1711900000));
3815
3816        // Clear stale and verify everything still intact
3817        config.clear_host_stale("complex");
3818        let entry_cleared = first_block(&config).to_host_entry();
3819        assert_eq!(entry_cleared.stale, None);
3820        assert_eq!(entry_cleared.hostname, entry_before.hostname);
3821        assert_eq!(entry_cleared.tags, entry_before.tags);
3822        assert_eq!(entry_cleared.provider, entry_before.provider);
3823        assert_eq!(entry_cleared.askpass, entry_before.askpass);
3824        assert_eq!(entry_cleared.provider_meta, entry_before.provider_meta);
3825
3826        // User comment must survive
3827        assert!(config.serialize().contains("# This is a user comment"));
3828    }
3829
3830    #[test]
3831    fn stale_on_last_host_preserves_trailing_newline() {
3832        let config_str = "Host web\n  HostName 1.2.3.4\n";
3833        let mut config = parse_str(config_str);
3834        config.set_host_stale("web", 1711900000);
3835        let output = config.serialize();
3836        assert!(output.ends_with('\n'), "config must end with newline");
3837
3838        config.clear_host_stale("web");
3839        let output2 = config.serialize();
3840        assert_eq!(output2, config_str);
3841    }
3842
3843    #[test]
3844    fn stale_with_crlf_preserves_line_endings() {
3845        let config_str = "Host web\r\n  HostName 1.2.3.4\r\n";
3846        let config = SshConfigFile {
3847            elements: SshConfigFile::parse_content(config_str),
3848            path: std::path::PathBuf::from("/tmp/test"),
3849            crlf: true,
3850            bom: false,
3851        };
3852        let mut config = config;
3853        config.set_host_stale("web", 1711900000);
3854        let output = config.serialize();
3855        // All lines must use CRLF
3856        for line in output.split('\n') {
3857            if !line.is_empty() {
3858                assert!(
3859                    line.ends_with('\r'),
3860                    "CRLF lost after set_stale. Line: {:?}",
3861                    line
3862                );
3863            }
3864        }
3865
3866        config.clear_host_stale("web");
3867        assert_eq!(config.serialize(), config_str);
3868    }
3869
3870    #[test]
3871    fn pattern_match_star_wildcard() {
3872        assert!(ssh_pattern_match("*", "anything"));
3873        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.5"));
3874        assert!(ssh_pattern_match("10.30.0.*", "10.30.0.100"));
3875        assert!(!ssh_pattern_match("10.30.0.*", "10.30.1.5"));
3876        assert!(ssh_pattern_match("*.example.com", "web.example.com"));
3877        assert!(!ssh_pattern_match("*.example.com", "example.com"));
3878        assert!(ssh_pattern_match("prod-*-web", "prod-us-web"));
3879        assert!(!ssh_pattern_match("prod-*-web", "prod-us-api"));
3880    }
3881
3882    #[test]
3883    fn pattern_match_question_mark() {
3884        assert!(ssh_pattern_match("server-?", "server-1"));
3885        assert!(ssh_pattern_match("server-?", "server-a"));
3886        assert!(!ssh_pattern_match("server-?", "server-10"));
3887        assert!(!ssh_pattern_match("server-?", "server-"));
3888    }
3889
3890    #[test]
3891    fn pattern_match_character_class() {
3892        assert!(ssh_pattern_match("server-[abc]", "server-a"));
3893        assert!(ssh_pattern_match("server-[abc]", "server-c"));
3894        assert!(!ssh_pattern_match("server-[abc]", "server-d"));
3895        assert!(ssh_pattern_match("server-[0-9]", "server-5"));
3896        assert!(!ssh_pattern_match("server-[0-9]", "server-a"));
3897        assert!(ssh_pattern_match("server-[!abc]", "server-d"));
3898        assert!(!ssh_pattern_match("server-[!abc]", "server-a"));
3899        assert!(ssh_pattern_match("server-[^abc]", "server-d"));
3900        assert!(!ssh_pattern_match("server-[^abc]", "server-a"));
3901    }
3902
3903    #[test]
3904    fn pattern_match_negation() {
3905        assert!(!ssh_pattern_match("!prod-*", "prod-web"));
3906        assert!(ssh_pattern_match("!prod-*", "staging-web"));
3907    }
3908
3909    #[test]
3910    fn pattern_match_exact() {
3911        assert!(ssh_pattern_match("myserver", "myserver"));
3912        assert!(!ssh_pattern_match("myserver", "myserver2"));
3913        assert!(!ssh_pattern_match("myserver", "other"));
3914    }
3915
3916    #[test]
3917    fn pattern_match_empty() {
3918        assert!(!ssh_pattern_match("", "anything"));
3919        assert!(!ssh_pattern_match("*", ""));
3920        assert!(ssh_pattern_match("", ""));
3921    }
3922
3923    #[test]
3924    fn host_pattern_matches_multi_pattern() {
3925        assert!(host_pattern_matches("prod staging", "prod"));
3926        assert!(host_pattern_matches("prod staging", "staging"));
3927        assert!(!host_pattern_matches("prod staging", "dev"));
3928    }
3929
3930    #[test]
3931    fn host_pattern_matches_with_negation() {
3932        assert!(host_pattern_matches(
3933            "*.example.com !internal.example.com",
3934            "web.example.com",
3935        ));
3936        assert!(!host_pattern_matches(
3937            "*.example.com !internal.example.com",
3938            "internal.example.com",
3939        ));
3940    }
3941
3942    #[test]
3943    fn host_pattern_matches_alias_only() {
3944        // OpenSSH Host keyword matches only against alias, not HostName
3945        assert!(!host_pattern_matches("10.30.0.*", "production"));
3946        assert!(host_pattern_matches("prod*", "production"));
3947        assert!(!host_pattern_matches("staging*", "production"));
3948    }
3949
3950    #[test]
3951    fn pattern_entries_collects_wildcards() {
3952        let config = parse_str(
3953            "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",
3954        );
3955        let patterns = config.pattern_entries();
3956        assert_eq!(patterns.len(), 2);
3957        assert_eq!(patterns[0].pattern, "10.30.0.*");
3958        assert_eq!(patterns[0].user, "debian");
3959        assert_eq!(patterns[0].proxy_jump, "bastion");
3960        assert_eq!(patterns[1].pattern, "*");
3961        assert!(
3962            patterns[1]
3963                .directives
3964                .iter()
3965                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
3966        );
3967    }
3968
3969    #[test]
3970    fn pattern_entries_empty_when_no_patterns() {
3971        let config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
3972        let patterns = config.pattern_entries();
3973        assert!(patterns.is_empty());
3974    }
3975
3976    #[test]
3977    fn matching_patterns_returns_in_config_order() {
3978        let config = parse_str(
3979            "Host 10.30.0.*\n  User debian\n\nHost myserver\n  Hostname 10.30.0.5\n\nHost *\n  ServerAliveInterval 60\n",
3980        );
3981        // "myserver" matches "*" but not "10.30.0.*" (alias-only matching)
3982        let matches = config.matching_patterns("myserver");
3983        assert_eq!(matches.len(), 1);
3984        assert_eq!(matches[0].pattern, "*");
3985    }
3986
3987    #[test]
3988    fn matching_patterns_negation_excludes() {
3989        let config = parse_str(
3990            "Host * !bastion\n  ServerAliveInterval 60\n\nHost bastion\n  Hostname 10.0.0.1\n",
3991        );
3992        let matches = config.matching_patterns("bastion");
3993        assert!(matches.is_empty());
3994    }
3995
3996    #[test]
3997    fn pattern_entries_and_host_entries_are_disjoint() {
3998        let config = parse_str(
3999            "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n",
4000        );
4001        let hosts = config.host_entries();
4002        let patterns = config.pattern_entries();
4003        assert_eq!(hosts.len(), 1);
4004        assert_eq!(hosts[0].alias, "myserver");
4005        assert_eq!(patterns.len(), 2);
4006        assert_eq!(patterns[0].pattern, "10.30.0.*");
4007        assert_eq!(patterns[1].pattern, "*");
4008    }
4009
4010    #[test]
4011    fn pattern_crud_round_trip() {
4012        let mut config = parse_str("Host myserver\n  Hostname 10.0.0.1\n");
4013        // Add a pattern via HostEntry (the form uses HostEntry for submission)
4014        let entry = HostEntry {
4015            alias: "10.30.0.*".to_string(),
4016            user: "debian".to_string(),
4017            ..Default::default()
4018        };
4019        config.add_host(&entry);
4020        let output = config.serialize();
4021        assert!(output.contains("Host 10.30.0.*"));
4022        assert!(output.contains("User debian"));
4023        // Verify it appears in pattern_entries, not host_entries
4024        let reparsed = parse_str(&output);
4025        assert_eq!(reparsed.host_entries().len(), 1);
4026        assert_eq!(reparsed.pattern_entries().len(), 1);
4027        assert_eq!(reparsed.pattern_entries()[0].pattern, "10.30.0.*");
4028    }
4029
4030    #[test]
4031    fn matching_patterns_full_ssh_semantics() {
4032        let config = parse_str(
4033            "Host 10.30.0.*\n  User debian\n  IdentityFile ~/.ssh/id_bootstrap\n  ProxyJump bastion\n\n\
4034             Host *.internal !secret.internal\n  ForwardAgent yes\n\n\
4035             Host myserver\n  Hostname 10.30.0.5\n\n\
4036             Host *\n  ServerAliveInterval 60\n",
4037        );
4038        // "myserver" only matches "*" (alias-only, not hostname)
4039        let matches = config.matching_patterns("myserver");
4040        assert_eq!(matches.len(), 1);
4041        assert_eq!(matches[0].pattern, "*");
4042        assert!(
4043            matches[0]
4044                .directives
4045                .iter()
4046                .any(|(k, v)| k == "ServerAliveInterval" && v == "60")
4047        );
4048    }
4049
4050    #[test]
4051    fn pattern_entries_preserve_all_directives() {
4052        let config = parse_str(
4053            "Host *.example.com\n  User admin\n  Port 2222\n  IdentityFile ~/.ssh/id_example\n  ProxyJump gateway\n  ServerAliveInterval 30\n  ForwardAgent yes\n",
4054        );
4055        let patterns = config.pattern_entries();
4056        assert_eq!(patterns.len(), 1);
4057        let p = &patterns[0];
4058        assert_eq!(p.pattern, "*.example.com");
4059        assert_eq!(p.user, "admin");
4060        assert_eq!(p.port, 2222);
4061        assert_eq!(p.identity_file, "~/.ssh/id_example");
4062        assert_eq!(p.proxy_jump, "gateway");
4063        // All directives should be in the directives vec
4064        assert_eq!(p.directives.len(), 6);
4065        assert!(
4066            p.directives
4067                .iter()
4068                .any(|(k, v)| k == "ForwardAgent" && v == "yes")
4069        );
4070        assert!(
4071            p.directives
4072                .iter()
4073                .any(|(k, v)| k == "ServerAliveInterval" && v == "30")
4074        );
4075    }
4076
4077    // --- Pattern visibility tests ---
4078
4079    #[test]
4080    fn roundtrip_pattern_blocks_preserved() {
4081        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";
4082        let config = parse_str(input);
4083        let output = config.serialize();
4084        assert_eq!(
4085            input, output,
4086            "Pattern blocks must survive round-trip exactly"
4087        );
4088    }
4089
4090    #[test]
4091    fn add_pattern_preserves_existing_config() {
4092        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";
4093        let mut config = parse_str(input);
4094        let entry = HostEntry {
4095            alias: "10.30.0.*".to_string(),
4096            user: "debian".to_string(),
4097            ..Default::default()
4098        };
4099        config.add_host(&entry);
4100        let output = config.serialize();
4101        // Original hosts must still be there
4102        assert!(output.contains("Host myserver"));
4103        assert!(output.contains("Hostname 10.0.0.1"));
4104        assert!(output.contains("Host otherserver"));
4105        assert!(output.contains("Hostname 10.0.0.2"));
4106        // New pattern must be present
4107        assert!(output.contains("Host 10.30.0.*"));
4108        assert!(output.contains("User debian"));
4109        // Host * must still be at the end
4110        assert!(output.contains("Host *"));
4111        // New pattern must be BEFORE Host * (SSH first-match-wins)
4112        let new_pos = output.find("Host 10.30.0.*").unwrap();
4113        let star_pos = output.find("Host *").unwrap();
4114        assert!(new_pos < star_pos, "New pattern must be before Host *");
4115        // Reparse and verify counts
4116        let reparsed = parse_str(&output);
4117        assert_eq!(reparsed.host_entries().len(), 2);
4118        assert_eq!(reparsed.pattern_entries().len(), 2); // 10.30.0.* and *
4119    }
4120
4121    #[test]
4122    fn update_pattern_preserves_other_blocks() {
4123        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
4124        let mut config = parse_str(input);
4125        let updated = HostEntry {
4126            alias: "10.30.0.*".to_string(),
4127            user: "admin".to_string(),
4128            ..Default::default()
4129        };
4130        config.update_host("10.30.0.*", &updated);
4131        let output = config.serialize();
4132        // Pattern updated
4133        assert!(output.contains("User admin"));
4134        assert!(!output.contains("User debian"));
4135        // Other blocks unchanged
4136        assert!(output.contains("Host myserver"));
4137        assert!(output.contains("Hostname 10.0.0.1"));
4138        assert!(output.contains("Host *"));
4139        assert!(output.contains("ServerAliveInterval 60"));
4140    }
4141
4142    #[test]
4143    fn delete_pattern_preserves_other_blocks() {
4144        let input = "Host myserver\n  Hostname 10.0.0.1\n\nHost 10.30.0.*\n  User debian\n\nHost *\n  ServerAliveInterval 60\n";
4145        let mut config = parse_str(input);
4146        config.delete_host("10.30.0.*");
4147        let output = config.serialize();
4148        assert!(!output.contains("Host 10.30.0.*"));
4149        assert!(!output.contains("User debian"));
4150        assert!(output.contains("Host myserver"));
4151        assert!(output.contains("Hostname 10.0.0.1"));
4152        assert!(output.contains("Host *"));
4153        assert!(output.contains("ServerAliveInterval 60"));
4154        let reparsed = parse_str(&output);
4155        assert_eq!(reparsed.host_entries().len(), 1);
4156        assert_eq!(reparsed.pattern_entries().len(), 1); // only Host *
4157    }
4158
4159    #[test]
4160    fn update_pattern_rename() {
4161        let input = "Host *.example.com\n  User admin\n\nHost myserver\n  Hostname 10.0.0.1\n";
4162        let mut config = parse_str(input);
4163        let renamed = HostEntry {
4164            alias: "*.prod.example.com".to_string(),
4165            user: "admin".to_string(),
4166            ..Default::default()
4167        };
4168        config.update_host("*.example.com", &renamed);
4169        let output = config.serialize();
4170        assert!(
4171            !output.contains("Host *.example.com\n"),
4172            "Old pattern removed"
4173        );
4174        assert!(
4175            output.contains("Host *.prod.example.com"),
4176            "New pattern present"
4177        );
4178        assert!(output.contains("Host myserver"), "Other host preserved");
4179    }
4180
4181    #[test]
4182    fn config_with_only_patterns() {
4183        let input = "Host *.example.com\n  User admin\n\nHost *\n  ServerAliveInterval 60\n";
4184        let config = parse_str(input);
4185        assert!(config.host_entries().is_empty());
4186        assert_eq!(config.pattern_entries().len(), 2);
4187        // Round-trip
4188        let output = config.serialize();
4189        assert_eq!(input, output);
4190    }
4191
4192    #[test]
4193    fn host_pattern_matches_all_negative_returns_false() {
4194        assert!(!host_pattern_matches("!prod !staging", "anything"));
4195        assert!(!host_pattern_matches("!prod !staging", "dev"));
4196    }
4197
4198    #[test]
4199    fn host_pattern_matches_negation_only_checks_alias() {
4200        // Negation matches against alias only
4201        assert!(host_pattern_matches("* !10.0.0.1", "myserver"));
4202        assert!(!host_pattern_matches("* !myserver", "myserver"));
4203    }
4204
4205    #[test]
4206    fn pattern_match_malformed_char_class() {
4207        // Unmatched bracket: should not panic, treat as no-match
4208        assert!(!ssh_pattern_match("[abc", "a"));
4209        assert!(!ssh_pattern_match("[", "a"));
4210        // Empty class body before ]
4211        assert!(!ssh_pattern_match("[]", "a"));
4212    }
4213
4214    #[test]
4215    fn host_pattern_matches_whitespace_edge_cases() {
4216        assert!(host_pattern_matches("prod  staging", "prod"));
4217        assert!(host_pattern_matches("  prod  ", "prod"));
4218        assert!(host_pattern_matches("prod\tstaging", "prod"));
4219        assert!(!host_pattern_matches("   ", "anything"));
4220        assert!(!host_pattern_matches("", "anything"));
4221    }
4222
4223    #[test]
4224    fn pattern_with_metadata_roundtrip() {
4225        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";
4226        let config = parse_str(input);
4227        let patterns = config.pattern_entries();
4228        assert_eq!(patterns.len(), 1);
4229        assert_eq!(patterns[0].tags, vec!["internal", "vpn"]);
4230        assert_eq!(patterns[0].askpass.as_deref(), Some("keychain"));
4231        // Round-trip
4232        let output = config.serialize();
4233        assert_eq!(input, output);
4234    }
4235
4236    #[test]
4237    fn matching_patterns_multiple_in_config_order() {
4238        // Use alias-based patterns that match the alias "my-10-server"
4239        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";
4240        let config = parse_str(input);
4241        let matches = config.matching_patterns("my-10-server");
4242        assert_eq!(matches.len(), 4);
4243        assert_eq!(matches[0].pattern, "my-*");
4244        assert_eq!(matches[1].pattern, "my-10*");
4245        assert_eq!(matches[2].pattern, "my-10-*");
4246        assert_eq!(matches[3].pattern, "*");
4247    }
4248
4249    #[test]
4250    fn add_pattern_to_empty_config() {
4251        let mut config = parse_str("");
4252        let entry = HostEntry {
4253            alias: "*.example.com".to_string(),
4254            user: "admin".to_string(),
4255            ..Default::default()
4256        };
4257        config.add_host(&entry);
4258        let output = config.serialize();
4259        assert!(output.contains("Host *.example.com"));
4260        assert!(output.contains("User admin"));
4261        let reparsed = parse_str(&output);
4262        assert!(reparsed.host_entries().is_empty());
4263        assert_eq!(reparsed.pattern_entries().len(), 1);
4264    }
4265}