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