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        other => other,
19    }
20}
21
22/// Represents the entire SSH config file as a sequence of elements.
23/// Preserves the original structure for round-trip fidelity.
24#[derive(Debug, Clone)]
25pub struct SshConfigFile {
26    pub elements: Vec<ConfigElement>,
27    pub path: PathBuf,
28    /// Whether the original file used CRLF line endings.
29    pub crlf: bool,
30    /// Whether the original file started with a UTF-8 BOM.
31    pub bom: bool,
32}
33
34/// An Include directive that references other config files.
35#[derive(Debug, Clone)]
36#[allow(dead_code)]
37pub struct IncludeDirective {
38    pub raw_line: String,
39    pub pattern: String,
40    pub resolved_files: Vec<IncludedFile>,
41}
42
43/// A file resolved from an Include directive.
44#[derive(Debug, Clone)]
45pub struct IncludedFile {
46    pub path: PathBuf,
47    pub elements: Vec<ConfigElement>,
48}
49
50/// A single element in the config file.
51#[derive(Debug, Clone)]
52pub enum ConfigElement {
53    /// A Host block: the "Host <pattern>" line plus all indented directives.
54    HostBlock(HostBlock),
55    /// A comment, blank line, or global directive not inside a Host block.
56    GlobalLine(String),
57    /// An Include directive referencing other config files (read-only).
58    Include(IncludeDirective),
59}
60
61/// A parsed Host block with its directives.
62#[derive(Debug, Clone)]
63pub struct HostBlock {
64    /// The host alias/pattern (the value after "Host").
65    pub host_pattern: String,
66    /// The original raw "Host ..." line for faithful reproduction.
67    pub raw_host_line: String,
68    /// Parsed directives inside this block.
69    pub directives: Vec<Directive>,
70}
71
72/// A directive line inside a Host block.
73#[derive(Debug, Clone)]
74pub struct Directive {
75    /// The directive key (e.g., "HostName", "User", "Port").
76    pub key: String,
77    /// The directive value.
78    pub value: String,
79    /// The original raw line (preserves indentation, inline comments).
80    pub raw_line: String,
81    /// Whether this is a comment-only or blank line inside a host block.
82    pub is_non_directive: bool,
83}
84
85/// Convenience view for the TUI — extracted from a HostBlock.
86#[derive(Debug, Clone)]
87pub struct HostEntry {
88    pub alias: String,
89    pub hostname: String,
90    pub user: String,
91    pub port: u16,
92    pub identity_file: String,
93    pub proxy_jump: String,
94    /// If this host comes from an included file, the file path.
95    pub source_file: Option<PathBuf>,
96    /// Tags from purple:tags comment.
97    pub tags: Vec<String>,
98    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
99    pub provider: Option<String>,
100    /// Number of tunnel forwarding directives.
101    pub tunnel_count: u16,
102    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
103    pub askpass: Option<String>,
104    /// Provider metadata from purple:meta comment (region, plan, etc.).
105    pub provider_meta: Vec<(String, String)>,
106}
107
108impl Default for HostEntry {
109    fn default() -> Self {
110        Self {
111            alias: String::new(),
112            hostname: String::new(),
113            user: String::new(),
114            port: 22,
115            identity_file: String::new(),
116            proxy_jump: String::new(),
117            source_file: None,
118            tags: Vec::new(),
119            provider: None,
120            tunnel_count: 0,
121            askpass: None,
122            provider_meta: Vec::new(),
123        }
124    }
125}
126
127impl HostEntry {
128    /// Build the SSH command string for this host.
129    /// Includes `-F <config_path>` when the config is non-default so the alias
130    /// resolves correctly when pasted into a terminal.
131    /// Shell-quotes both the config path and alias to prevent injection.
132    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
133        let escaped = self.alias.replace('\'', "'\\''");
134        let default = dirs::home_dir()
135            .map(|h| h.join(".ssh/config"))
136            .unwrap_or_default();
137        if config_path == default {
138            format!("ssh -- '{}'", escaped)
139        } else {
140            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
141            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
142        }
143    }
144}
145
146/// Returns true if the host pattern contains wildcards, character classes,
147/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
148/// These are SSH match patterns, not concrete hosts.
149pub fn is_host_pattern(pattern: &str) -> bool {
150    pattern.contains('*')
151        || pattern.contains('?')
152        || pattern.contains('[')
153        || pattern.starts_with('!')
154        || pattern.contains(' ')
155        || pattern.contains('\t')
156}
157
158impl HostBlock {
159    /// Index of the first trailing blank line (for inserting content before separators).
160    fn content_end(&self) -> usize {
161        let mut pos = self.directives.len();
162        while pos > 0 {
163            if self.directives[pos - 1].is_non_directive
164                && self.directives[pos - 1].raw_line.trim().is_empty()
165            {
166                pos -= 1;
167            } else {
168                break;
169            }
170        }
171        pos
172    }
173
174    /// Remove and return trailing blank lines.
175    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
176        let end = self.content_end();
177        self.directives.drain(end..).collect()
178    }
179
180    /// Ensure exactly one trailing blank line.
181    fn ensure_trailing_blank(&mut self) {
182        self.pop_trailing_blanks();
183        self.directives.push(Directive {
184            key: String::new(),
185            value: String::new(),
186            raw_line: String::new(),
187            is_non_directive: true,
188        });
189    }
190
191    /// Detect indentation used by existing directives (falls back to "  ").
192    fn detect_indent(&self) -> String {
193        for d in &self.directives {
194            if !d.is_non_directive && !d.raw_line.is_empty() {
195                let trimmed = d.raw_line.trim_start();
196                let indent_len = d.raw_line.len() - trimmed.len();
197                if indent_len > 0 {
198                    return d.raw_line[..indent_len].to_string();
199                }
200            }
201        }
202        "  ".to_string()
203    }
204
205    /// Extract tags from purple:tags comment in directives.
206    pub fn tags(&self) -> Vec<String> {
207        for d in &self.directives {
208            if d.is_non_directive {
209                let trimmed = d.raw_line.trim();
210                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
211                    return rest
212                        .split(',')
213                        .map(|t| t.trim().to_string())
214                        .filter(|t| !t.is_empty())
215                        .collect();
216                }
217            }
218        }
219        Vec::new()
220    }
221
222    /// Extract provider info from purple:provider comment in directives.
223    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
224    pub fn provider(&self) -> Option<(String, String)> {
225        for d in &self.directives {
226            if d.is_non_directive {
227                let trimmed = d.raw_line.trim();
228                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
229                    if let Some((name, id)) = rest.split_once(':') {
230                        return Some((name.trim().to_string(), id.trim().to_string()));
231                    }
232                }
233            }
234        }
235        None
236    }
237
238    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
239    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
240        let indent = self.detect_indent();
241        self.directives.retain(|d| {
242            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
243        });
244        let pos = self.content_end();
245        self.directives.insert(
246            pos,
247            Directive {
248                key: String::new(),
249                value: String::new(),
250                raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
251                is_non_directive: true,
252            },
253        );
254    }
255
256    /// Extract askpass source from purple:askpass comment in directives.
257    pub fn askpass(&self) -> Option<String> {
258        for d in &self.directives {
259            if d.is_non_directive {
260                let trimmed = d.raw_line.trim();
261                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
262                    let val = rest.trim();
263                    if !val.is_empty() {
264                        return Some(val.to_string());
265                    }
266                }
267            }
268        }
269        None
270    }
271
272    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
273    /// Pass an empty string to remove the comment.
274    pub fn set_askpass(&mut self, source: &str) {
275        let indent = self.detect_indent();
276        self.directives.retain(|d| {
277            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:askpass"))
278        });
279        if !source.is_empty() {
280            let pos = self.content_end();
281            self.directives.insert(
282                pos,
283                Directive {
284                    key: String::new(),
285                    value: String::new(),
286                    raw_line: format!("{}# purple:askpass {}", indent, source),
287                    is_non_directive: true,
288                },
289            );
290        }
291    }
292
293    /// Extract provider metadata from purple:meta comment in directives.
294    /// Format: `# purple:meta key=value,key=value`
295    pub fn meta(&self) -> Vec<(String, String)> {
296        for d in &self.directives {
297            if d.is_non_directive {
298                let trimmed = d.raw_line.trim();
299                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
300                    return rest
301                        .split(',')
302                        .filter_map(|pair| {
303                            let (k, v) = pair.split_once('=')?;
304                            let k = k.trim();
305                            let v = v.trim();
306                            if k.is_empty() {
307                                None
308                            } else {
309                                Some((k.to_string(), v.to_string()))
310                            }
311                        })
312                        .collect();
313                }
314            }
315        }
316        Vec::new()
317    }
318
319    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
320    /// Pass an empty slice to remove the comment.
321    pub fn set_meta(&mut self, meta: &[(String, String)]) {
322        let indent = self.detect_indent();
323        self.directives.retain(|d| {
324            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:meta"))
325        });
326        if !meta.is_empty() {
327            let encoded: Vec<String> = meta
328                .iter()
329                .map(|(k, v)| {
330                    let clean_k = k.replace([',', '='], "");
331                    let clean_v = v.replace(',', "");
332                    format!("{}={}", clean_k, clean_v)
333                })
334                .collect();
335            let pos = self.content_end();
336            self.directives.insert(
337                pos,
338                Directive {
339                    key: String::new(),
340                    value: String::new(),
341                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
342                    is_non_directive: true,
343                },
344            );
345        }
346    }
347
348    /// Set tags on a host block. Replaces existing purple:tags comment or adds one.
349    pub fn set_tags(&mut self, tags: &[String]) {
350        let indent = self.detect_indent();
351        self.directives.retain(|d| {
352            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
353        });
354        if !tags.is_empty() {
355            let pos = self.content_end();
356            self.directives.insert(
357                pos,
358                Directive {
359                    key: String::new(),
360                    value: String::new(),
361                    raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
362                    is_non_directive: true,
363                },
364            );
365        }
366    }
367
368    /// Extract a convenience HostEntry view from this block.
369    pub fn to_host_entry(&self) -> HostEntry {
370        let mut entry = HostEntry {
371            alias: self.host_pattern.clone(),
372            port: 22,
373            ..Default::default()
374        };
375        for d in &self.directives {
376            if d.is_non_directive {
377                continue;
378            }
379            if d.key.eq_ignore_ascii_case("hostname") {
380                entry.hostname = d.value.clone();
381            } else if d.key.eq_ignore_ascii_case("user") {
382                entry.user = d.value.clone();
383            } else if d.key.eq_ignore_ascii_case("port") {
384                entry.port = d.value.parse().unwrap_or(22);
385            } else if d.key.eq_ignore_ascii_case("identityfile") {
386                if entry.identity_file.is_empty() {
387                    entry.identity_file = d.value.clone();
388                }
389            } else if d.key.eq_ignore_ascii_case("proxyjump") {
390                entry.proxy_jump = d.value.clone();
391            }
392        }
393        entry.tags = self.tags();
394        entry.provider = self.provider().map(|(name, _)| name);
395        entry.tunnel_count = self.tunnel_count();
396        entry.askpass = self.askpass();
397        entry.provider_meta = self.meta();
398        entry
399    }
400
401    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
402    pub fn tunnel_count(&self) -> u16 {
403        let count = self
404            .directives
405            .iter()
406            .filter(|d| {
407                !d.is_non_directive
408                    && (d.key.eq_ignore_ascii_case("localforward")
409                        || d.key.eq_ignore_ascii_case("remoteforward")
410                        || d.key.eq_ignore_ascii_case("dynamicforward"))
411            })
412            .count();
413        count.min(u16::MAX as usize) as u16
414    }
415
416    /// Check if this block has any tunnel forwarding directives.
417    #[allow(dead_code)]
418    pub fn has_tunnels(&self) -> bool {
419        self.directives.iter().any(|d| {
420            !d.is_non_directive
421                && (d.key.eq_ignore_ascii_case("localforward")
422                    || d.key.eq_ignore_ascii_case("remoteforward")
423                    || d.key.eq_ignore_ascii_case("dynamicforward"))
424        })
425    }
426
427    /// Extract tunnel rules from forwarding directives.
428    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
429        self.directives
430            .iter()
431            .filter(|d| !d.is_non_directive)
432            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
433            .collect()
434    }
435}
436
437impl SshConfigFile {
438    /// Get all host entries as convenience views (including from Include files).
439    pub fn host_entries(&self) -> Vec<HostEntry> {
440        let mut entries = Vec::new();
441        Self::collect_host_entries(&self.elements, &mut entries);
442        entries
443    }
444
445    /// Collect all resolved Include file paths (recursively).
446    pub fn include_paths(&self) -> Vec<PathBuf> {
447        let mut paths = Vec::new();
448        Self::collect_include_paths(&self.elements, &mut paths);
449        paths
450    }
451
452    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
453        for e in elements {
454            if let ConfigElement::Include(include) = e {
455                for file in &include.resolved_files {
456                    paths.push(file.path.clone());
457                    Self::collect_include_paths(&file.elements, paths);
458                }
459            }
460        }
461    }
462
463    /// Collect parent directories of Include glob patterns.
464    /// When a file is added/removed under a glob dir, the directory's mtime changes.
465    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
466        let config_dir = self.path.parent();
467        let mut seen = std::collections::HashSet::new();
468        let mut dirs = Vec::new();
469        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
470        dirs
471    }
472
473    fn collect_include_glob_dirs(
474        elements: &[ConfigElement],
475        config_dir: Option<&std::path::Path>,
476        seen: &mut std::collections::HashSet<PathBuf>,
477        dirs: &mut Vec<PathBuf>,
478    ) {
479        for e in elements {
480            if let ConfigElement::Include(include) = e {
481                // Split respecting quoted paths (same as resolve_include does)
482                for single in Self::split_include_patterns(&include.pattern) {
483                    let expanded = Self::expand_env_vars(&Self::expand_tilde(single));
484                    let resolved = if expanded.starts_with('/') {
485                        PathBuf::from(&expanded)
486                    } else if let Some(dir) = config_dir {
487                        dir.join(&expanded)
488                    } else {
489                        continue;
490                    };
491                    if let Some(parent) = resolved.parent() {
492                        let parent = parent.to_path_buf();
493                        if seen.insert(parent.clone()) {
494                            dirs.push(parent);
495                        }
496                    }
497                }
498                // Recurse into resolved files
499                for file in &include.resolved_files {
500                    Self::collect_include_glob_dirs(
501                        &file.elements,
502                        file.path.parent(),
503                        seen,
504                        dirs,
505                    );
506                }
507            }
508        }
509    }
510
511    /// Remove `# purple:group <Name>` headers that have no corresponding
512    /// provider hosts. Returns the number of headers removed.
513    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
514        // Collect all provider display names that have at least one host.
515        let active_providers: std::collections::HashSet<String> = self
516            .elements
517            .iter()
518            .filter_map(|e| {
519                if let ConfigElement::HostBlock(block) = e {
520                    block
521                        .provider()
522                        .map(|(name, _)| provider_group_display_name(&name).to_string())
523                } else {
524                    None
525                }
526            })
527            .collect();
528
529        let mut removed = 0;
530        self.elements.retain(|e| {
531            if let ConfigElement::GlobalLine(line) = e {
532                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
533                    if !active_providers.contains(rest.trim()) {
534                        removed += 1;
535                        return false;
536                    }
537                }
538            }
539            true
540        });
541        removed
542    }
543
544    /// Repair configs where `# purple:group` comments were absorbed into the
545    /// preceding host block's directives instead of being stored as GlobalLines.
546    /// Returns the number of blocks that were repaired.
547    pub fn repair_absorbed_group_comments(&mut self) -> usize {
548        let mut repaired = 0;
549        let mut idx = 0;
550        while idx < self.elements.len() {
551            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
552                block.directives.iter().any(|d| {
553                    d.is_non_directive
554                        && d.raw_line.trim().starts_with("# purple:group ")
555                })
556            } else {
557                false
558            };
559
560            if !needs_repair {
561                idx += 1;
562                continue;
563            }
564
565            // Find the index of the first absorbed group comment in this block's directives.
566            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
567                block
568            } else {
569                unreachable!()
570            };
571
572            let group_idx = block
573                .directives
574                .iter()
575                .position(|d| {
576                    d.is_non_directive
577                        && d.raw_line.trim().starts_with("# purple:group ")
578                })
579                .unwrap();
580
581            // Find where trailing blanks before the group comment start.
582            let mut keep_end = group_idx;
583            while keep_end > 0
584                && block.directives[keep_end - 1].is_non_directive
585                && block.directives[keep_end - 1].raw_line.trim().is_empty()
586            {
587                keep_end -= 1;
588            }
589
590            // Collect everything from keep_end onward as GlobalLines.
591            let extracted: Vec<ConfigElement> = block
592                .directives
593                .drain(keep_end..)
594                .map(|d| ConfigElement::GlobalLine(d.raw_line))
595                .collect();
596
597            // Insert extracted GlobalLines right after this HostBlock.
598            let insert_at = idx + 1;
599            for (i, elem) in extracted.into_iter().enumerate() {
600                self.elements.insert(insert_at + i, elem);
601            }
602
603            repaired += 1;
604            // Advance past the inserted elements.
605            idx = insert_at;
606            // Skip the inserted elements to continue scanning.
607            while idx < self.elements.len() {
608                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
609                    break;
610                }
611                idx += 1;
612            }
613        }
614        repaired
615    }
616
617    /// Recursively collect host entries from a list of elements.
618    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
619        for e in elements {
620            match e {
621                ConfigElement::HostBlock(block) => {
622                    if is_host_pattern(&block.host_pattern) {
623                        continue;
624                    }
625                    entries.push(block.to_host_entry());
626                }
627                ConfigElement::Include(include) => {
628                    for file in &include.resolved_files {
629                        let start = entries.len();
630                        Self::collect_host_entries(&file.elements, entries);
631                        for entry in &mut entries[start..] {
632                            if entry.source_file.is_none() {
633                                entry.source_file = Some(file.path.clone());
634                            }
635                        }
636                    }
637                }
638                ConfigElement::GlobalLine(_) => {}
639            }
640        }
641    }
642
643    /// Check if a host alias already exists (including in Include files).
644    /// Walks the element tree directly without building HostEntry structs.
645    pub fn has_host(&self, alias: &str) -> bool {
646        Self::has_host_in_elements(&self.elements, alias)
647    }
648
649    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
650        for e in elements {
651            match e {
652                ConfigElement::HostBlock(block) => {
653                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
654                        return true;
655                    }
656                }
657                ConfigElement::Include(include) => {
658                    for file in &include.resolved_files {
659                        if Self::has_host_in_elements(&file.elements, alias) {
660                            return true;
661                        }
662                    }
663                }
664                ConfigElement::GlobalLine(_) => {}
665            }
666        }
667        false
668    }
669
670    /// Check if a host alias is from an included file (read-only).
671    /// Handles multi-pattern Host lines by splitting on whitespace.
672    pub fn is_included_host(&self, alias: &str) -> bool {
673        // Not in top-level elements → must be in an Include
674        for e in &self.elements {
675            match e {
676                ConfigElement::HostBlock(block) => {
677                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
678                        return false;
679                    }
680                }
681                ConfigElement::Include(include) => {
682                    for file in &include.resolved_files {
683                        if Self::has_host_in_elements(&file.elements, alias) {
684                            return true;
685                        }
686                    }
687                }
688                ConfigElement::GlobalLine(_) => {}
689            }
690        }
691        false
692    }
693
694    /// Add a new host entry to the config.
695    /// Inserts before any trailing wildcard/pattern Host blocks (e.g. `Host *`)
696    /// so that SSH "first match wins" semantics are preserved. If wildcards are
697    /// only at the top of the file (acting as global defaults), appends at end.
698    pub fn add_host(&mut self, entry: &HostEntry) {
699        let block = Self::entry_to_block(entry);
700        let insert_pos = self.find_trailing_pattern_start();
701
702        if let Some(pos) = insert_pos {
703            // Insert before the trailing pattern group, with blank separators
704            let needs_blank_before = pos > 0
705                && !matches!(
706                    self.elements.get(pos - 1),
707                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
708                );
709            let mut idx = pos;
710            if needs_blank_before {
711                self.elements
712                    .insert(idx, ConfigElement::GlobalLine(String::new()));
713                idx += 1;
714            }
715            self.elements.insert(idx, ConfigElement::HostBlock(block));
716            // Ensure a blank separator after the new block (before the wildcard group)
717            let after = idx + 1;
718            if after < self.elements.len()
719                && !matches!(
720                    self.elements.get(after),
721                    Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
722                )
723            {
724                self.elements
725                    .insert(after, ConfigElement::GlobalLine(String::new()));
726            }
727        } else {
728            // No trailing patterns: append at end
729            if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
730                self.elements
731                    .push(ConfigElement::GlobalLine(String::new()));
732            }
733            self.elements.push(ConfigElement::HostBlock(block));
734        }
735    }
736
737    /// Find the start of a trailing group of wildcard/pattern Host blocks.
738    /// Scans backwards from the end, skipping GlobalLines (blanks/comments/Match).
739    /// Returns `None` if no trailing patterns exist (or if ALL hosts are patterns,
740    /// i.e. patterns start at position 0 — in that case we append at end).
741    fn find_trailing_pattern_start(&self) -> Option<usize> {
742        let mut first_pattern_pos = None;
743        for i in (0..self.elements.len()).rev() {
744            match &self.elements[i] {
745                ConfigElement::HostBlock(block) => {
746                    if is_host_pattern(&block.host_pattern) {
747                        first_pattern_pos = Some(i);
748                    } else {
749                        // Found a concrete host: the trailing group starts after this
750                        break;
751                    }
752                }
753                ConfigElement::GlobalLine(_) => {
754                    // Blank lines, comments, Match blocks between patterns: keep scanning
755                    if first_pattern_pos.is_some() {
756                        first_pattern_pos = Some(i);
757                    }
758                }
759                ConfigElement::Include(_) => break,
760            }
761        }
762        // Don't return position 0 — that means everything is patterns (or patterns at top)
763        first_pattern_pos.filter(|&pos| pos > 0)
764    }
765
766    /// Check if the last element already ends with a blank line.
767    pub fn last_element_has_trailing_blank(&self) -> bool {
768        match self.elements.last() {
769            Some(ConfigElement::HostBlock(block)) => block
770                .directives
771                .last()
772                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
773            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
774            _ => false,
775        }
776    }
777
778    /// Update an existing host entry by alias.
779    /// Merges changes into the existing block, preserving unknown directives.
780    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
781        for element in &mut self.elements {
782            if let ConfigElement::HostBlock(block) = element {
783                if block.host_pattern == old_alias {
784                    // Update host pattern (preserve raw_host_line when alias unchanged)
785                    if entry.alias != block.host_pattern {
786                        block.host_pattern = entry.alias.clone();
787                        block.raw_host_line = format!("Host {}", entry.alias);
788                    }
789
790                    // Merge known directives (update existing, add missing, remove empty)
791                    Self::upsert_directive(block, "HostName", &entry.hostname);
792                    Self::upsert_directive(block, "User", &entry.user);
793                    if entry.port != 22 {
794                        Self::upsert_directive(block, "Port", &entry.port.to_string());
795                    } else {
796                        // Remove explicit Port 22 (it's the default)
797                        block
798                            .directives
799                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
800                    }
801                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
802                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
803                    return;
804                }
805            }
806        }
807    }
808
809    /// Update a directive in-place, add it if missing, or remove it if value is empty.
810    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
811        if value.is_empty() {
812            block
813                .directives
814                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
815            return;
816        }
817        let indent = block.detect_indent();
818        for d in &mut block.directives {
819            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
820                // Only rebuild raw_line when value actually changed (preserves inline comments)
821                if d.value != value {
822                    d.value = value.to_string();
823                    // Detect separator style from original raw_line and preserve it.
824                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
825                    // Only considers '=' as separator if it appears before any
826                    // non-whitespace content (avoids matching '=' inside values
827                    // like "IdentityFile ~/.ssh/id=prod").
828                    let trimmed = d.raw_line.trim_start();
829                    let after_key = &trimmed[d.key.len()..];
830                    let sep = if after_key.trim_start().starts_with('=') {
831                        let eq_pos = after_key.find('=').unwrap();
832                        let after_eq = &after_key[eq_pos + 1..];
833                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
834                        after_key[..eq_pos + 1 + trailing_ws].to_string()
835                    } else {
836                        " ".to_string()
837                    };
838                    // Preserve inline comment from original raw_line (e.g. "# production")
839                    let comment_suffix = Self::extract_inline_comment(&d.raw_line, &d.key);
840                    d.raw_line =
841                        format!("{}{}{}{}{}", indent, d.key, sep, value, comment_suffix);
842                }
843                return;
844            }
845        }
846        // Not found — insert before trailing blanks
847        let pos = block.content_end();
848        block.directives.insert(
849            pos,
850            Directive {
851                key: key.to_string(),
852                value: value.to_string(),
853                raw_line: format!("{}{} {}", indent, key, value),
854                is_non_directive: false,
855            },
856        );
857    }
858
859    /// Extract the inline comment suffix from a directive's raw line.
860    /// Returns the trailing portion (e.g. " # production") or empty string.
861    /// Respects double-quoted strings so that `#` inside quotes is not a comment.
862    fn extract_inline_comment(raw_line: &str, key: &str) -> String {
863        let trimmed = raw_line.trim_start();
864        if trimmed.len() <= key.len() {
865            return String::new();
866        }
867        // Skip past key and separator to reach the value portion
868        let after_key = &trimmed[key.len()..];
869        let rest = after_key.trim_start();
870        let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
871        // Scan for inline comment (# preceded by whitespace, outside quotes)
872        let bytes = rest.as_bytes();
873        let mut in_quote = false;
874        for i in 0..bytes.len() {
875            if bytes[i] == b'"' {
876                in_quote = !in_quote;
877            } else if !in_quote
878                && bytes[i] == b'#'
879                && i > 0
880                && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t')
881            {
882                // Found comment start. The clean value ends before the whitespace preceding #.
883                let clean_end = rest[..i].trim_end().len();
884                return rest[clean_end..].to_string();
885            }
886        }
887        String::new()
888    }
889
890    /// Set provider on a host block by alias.
891    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
892        for element in &mut self.elements {
893            if let ConfigElement::HostBlock(block) = element {
894                if block.host_pattern == alias {
895                    block.set_provider(provider_name, server_id);
896                    return;
897                }
898            }
899        }
900    }
901
902    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
903    /// Searches both top-level elements and Include files so that provider hosts
904    /// in included configs are recognized during sync (prevents duplicate additions).
905    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
906        let mut results = Vec::new();
907        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
908        results
909    }
910
911    fn collect_provider_hosts(
912        elements: &[ConfigElement],
913        provider_name: &str,
914        results: &mut Vec<(String, String)>,
915    ) {
916        for element in elements {
917            match element {
918                ConfigElement::HostBlock(block) => {
919                    if let Some((name, id)) = block.provider() {
920                        if name == provider_name {
921                            results.push((block.host_pattern.clone(), id));
922                        }
923                    }
924                }
925                ConfigElement::Include(include) => {
926                    for file in &include.resolved_files {
927                        Self::collect_provider_hosts(&file.elements, provider_name, results);
928                    }
929                }
930                ConfigElement::GlobalLine(_) => {}
931            }
932        }
933    }
934
935    /// Compare two directive values with whitespace normalization.
936    /// Handles hand-edited configs with tabs or multiple spaces.
937    fn values_match(a: &str, b: &str) -> bool {
938        a.split_whitespace().eq(b.split_whitespace())
939    }
940
941    /// Add a forwarding directive to a host block.
942    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
943    /// Uses split_whitespace matching for multi-pattern Host lines.
944    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
945        for element in &mut self.elements {
946            if let ConfigElement::HostBlock(block) = element {
947                if block.host_pattern.split_whitespace().any(|p| p == alias) {
948                    let indent = block.detect_indent();
949                    let pos = block.content_end();
950                    block.directives.insert(
951                        pos,
952                        Directive {
953                            key: directive_key.to_string(),
954                            value: value.to_string(),
955                            raw_line: format!("{}{} {}", indent, directive_key, value),
956                            is_non_directive: false,
957                        },
958                    );
959                    return;
960                }
961            }
962        }
963    }
964
965    /// Remove a specific forwarding directive from a host block.
966    /// Matches key (case-insensitive) and value (whitespace-normalized).
967    /// Uses split_whitespace matching for multi-pattern Host lines.
968    /// Returns true if a directive was actually removed.
969    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
970        for element in &mut self.elements {
971            if let ConfigElement::HostBlock(block) = element {
972                if block.host_pattern.split_whitespace().any(|p| p == alias) {
973                    if let Some(pos) = block.directives.iter().position(|d| {
974                        !d.is_non_directive
975                            && d.key.eq_ignore_ascii_case(directive_key)
976                            && Self::values_match(&d.value, value)
977                    }) {
978                        block.directives.remove(pos);
979                        return true;
980                    }
981                    return false;
982                }
983            }
984        }
985        false
986    }
987
988    /// Check if a host block has a specific forwarding directive.
989    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
990    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
991        for element in &self.elements {
992            if let ConfigElement::HostBlock(block) = element {
993                if block.host_pattern.split_whitespace().any(|p| p == alias) {
994                    return block.directives.iter().any(|d| {
995                        !d.is_non_directive
996                            && d.key.eq_ignore_ascii_case(directive_key)
997                            && Self::values_match(&d.value, value)
998                    });
999                }
1000            }
1001        }
1002        false
1003    }
1004
1005    /// Find tunnel directives for a host alias, searching all elements including
1006    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
1007    /// Host lines.
1008    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
1009        Self::find_tunnel_directives_in(&self.elements, alias)
1010    }
1011
1012    fn find_tunnel_directives_in(
1013        elements: &[ConfigElement],
1014        alias: &str,
1015    ) -> Vec<crate::tunnel::TunnelRule> {
1016        for element in elements {
1017            match element {
1018                ConfigElement::HostBlock(block) => {
1019                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
1020                        return block.tunnel_directives();
1021                    }
1022                }
1023                ConfigElement::Include(include) => {
1024                    for file in &include.resolved_files {
1025                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
1026                        if !rules.is_empty() {
1027                            return rules;
1028                        }
1029                    }
1030                }
1031                ConfigElement::GlobalLine(_) => {}
1032            }
1033        }
1034        Vec::new()
1035    }
1036
1037    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
1038    pub fn deduplicate_alias(&self, base: &str) -> String {
1039        self.deduplicate_alias_excluding(base, None)
1040    }
1041
1042    /// Generate a unique alias, optionally excluding one alias from collision detection.
1043    /// Used during rename so the host being renamed doesn't collide with itself.
1044    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
1045        let is_taken = |alias: &str| {
1046            if exclude == Some(alias) {
1047                return false;
1048            }
1049            self.has_host(alias)
1050        };
1051        if !is_taken(base) {
1052            return base.to_string();
1053        }
1054        for n in 2..=9999 {
1055            let candidate = format!("{}-{}", base, n);
1056            if !is_taken(&candidate) {
1057                return candidate;
1058            }
1059        }
1060        // Practically unreachable: fall back to PID-based suffix
1061        format!("{}-{}", base, std::process::id())
1062    }
1063
1064    /// Set tags on a host block by alias.
1065    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
1066        for element in &mut self.elements {
1067            if let ConfigElement::HostBlock(block) = element {
1068                if block.host_pattern == alias {
1069                    block.set_tags(tags);
1070                    return;
1071                }
1072            }
1073        }
1074    }
1075
1076    /// Set askpass source on a host block by alias.
1077    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
1078        for element in &mut self.elements {
1079            if let ConfigElement::HostBlock(block) = element {
1080                if block.host_pattern == alias {
1081                    block.set_askpass(source);
1082                    return;
1083                }
1084            }
1085        }
1086    }
1087
1088    /// Set provider metadata on a host block by alias.
1089    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
1090        for element in &mut self.elements {
1091            if let ConfigElement::HostBlock(block) = element {
1092                if block.host_pattern == alias {
1093                    block.set_meta(meta);
1094                    return;
1095                }
1096            }
1097        }
1098    }
1099
1100    /// Delete a host entry by alias.
1101    #[allow(dead_code)]
1102    pub fn delete_host(&mut self, alias: &str) {
1103        // Before deletion, check if this host belongs to a provider so we can
1104        // clean up an orphaned group header afterwards.
1105        let provider_name = self.elements.iter().find_map(|e| {
1106            if let ConfigElement::HostBlock(b) = e {
1107                if b.host_pattern == alias {
1108                    return b.provider().map(|(name, _)| name);
1109                }
1110            }
1111            None
1112        });
1113
1114        self.elements.retain(|e| match e {
1115            ConfigElement::HostBlock(block) => block.host_pattern != alias,
1116            _ => true,
1117        });
1118
1119        // Remove orphaned group header if no hosts remain for the provider.
1120        if let Some(name) = provider_name {
1121            self.remove_orphaned_group_header(&name);
1122        }
1123
1124        // Collapse consecutive blank lines left by deletion
1125        self.elements.dedup_by(|a, b| {
1126            matches!(
1127                (&*a, &*b),
1128                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
1129                if x.trim().is_empty() && y.trim().is_empty()
1130            )
1131        });
1132    }
1133
1134    /// Delete a host and return the removed element and its position for undo.
1135    /// Does NOT collapse blank lines or remove group headers so the position
1136    /// stays valid for re-insertion via `insert_host_at()`.
1137    /// Orphaned group headers (if any) are cleaned up at next startup.
1138    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
1139        let pos = self.elements.iter().position(|e| {
1140            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
1141        })?;
1142        let element = self.elements.remove(pos);
1143        Some((element, pos))
1144    }
1145
1146    /// Find the position of the `# purple:group <DisplayName>` GlobalLine for a provider.
1147    #[allow(dead_code)]
1148    fn find_group_header_position(&self, provider_name: &str) -> Option<usize> {
1149        let display = provider_group_display_name(provider_name);
1150        let header = format!("# purple:group {}", display);
1151        self.elements
1152            .iter()
1153            .position(|e| matches!(e, ConfigElement::GlobalLine(line) if *line == header))
1154    }
1155
1156    /// Remove the `# purple:group <DisplayName>` GlobalLine for a provider
1157    /// if no remaining HostBlock has a `# purple:provider <name>:` directive.
1158    fn remove_orphaned_group_header(&mut self, provider_name: &str) {
1159        if self.find_hosts_by_provider(provider_name).is_empty() {
1160            let display = provider_group_display_name(provider_name);
1161            let header = format!("# purple:group {}", display);
1162            self.elements
1163                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
1164        }
1165    }
1166
1167    /// Insert a host block at a specific position (for undo).
1168    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
1169        let pos = position.min(self.elements.len());
1170        self.elements.insert(pos, element);
1171    }
1172
1173    /// Find the position after the last HostBlock that belongs to a provider.
1174    /// Returns `None` if no hosts for this provider exist in the config.
1175    /// Used by the sync engine to insert new hosts adjacent to existing provider hosts.
1176    pub fn find_provider_insert_position(&self, provider_name: &str) -> Option<usize> {
1177        let mut last_pos = None;
1178        for (i, element) in self.elements.iter().enumerate() {
1179            if let ConfigElement::HostBlock(block) = element {
1180                if let Some((name, _)) = block.provider() {
1181                    if name == provider_name {
1182                        last_pos = Some(i);
1183                    }
1184                }
1185            }
1186        }
1187        // Return position after the last provider host
1188        last_pos.map(|p| p + 1)
1189    }
1190
1191    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
1192    #[allow(dead_code)]
1193    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
1194        let pos_a = self.elements.iter().position(|e| {
1195            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
1196        });
1197        let pos_b = self.elements.iter().position(|e| {
1198            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
1199        });
1200        if let (Some(a), Some(b)) = (pos_a, pos_b) {
1201            if a == b {
1202                return false;
1203            }
1204            let (first, second) = (a.min(b), a.max(b));
1205
1206            // Strip trailing blanks from both blocks before swap
1207            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1208                block.pop_trailing_blanks();
1209            }
1210            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1211                block.pop_trailing_blanks();
1212            }
1213
1214            // Swap
1215            self.elements.swap(first, second);
1216
1217            // Add trailing blank to first block (separator between the two)
1218            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
1219                block.ensure_trailing_blank();
1220            }
1221
1222            // Add trailing blank to second only if not the last element
1223            if second < self.elements.len() - 1 {
1224                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
1225                    block.ensure_trailing_blank();
1226                }
1227            }
1228
1229            return true;
1230        }
1231        false
1232    }
1233
1234    /// Convert a HostEntry into a new HostBlock with clean formatting.
1235    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
1236        let mut directives = Vec::new();
1237
1238        if !entry.hostname.is_empty() {
1239            directives.push(Directive {
1240                key: "HostName".to_string(),
1241                value: entry.hostname.clone(),
1242                raw_line: format!("  HostName {}", entry.hostname),
1243                is_non_directive: false,
1244            });
1245        }
1246        if !entry.user.is_empty() {
1247            directives.push(Directive {
1248                key: "User".to_string(),
1249                value: entry.user.clone(),
1250                raw_line: format!("  User {}", entry.user),
1251                is_non_directive: false,
1252            });
1253        }
1254        if entry.port != 22 {
1255            directives.push(Directive {
1256                key: "Port".to_string(),
1257                value: entry.port.to_string(),
1258                raw_line: format!("  Port {}", entry.port),
1259                is_non_directive: false,
1260            });
1261        }
1262        if !entry.identity_file.is_empty() {
1263            directives.push(Directive {
1264                key: "IdentityFile".to_string(),
1265                value: entry.identity_file.clone(),
1266                raw_line: format!("  IdentityFile {}", entry.identity_file),
1267                is_non_directive: false,
1268            });
1269        }
1270        if !entry.proxy_jump.is_empty() {
1271            directives.push(Directive {
1272                key: "ProxyJump".to_string(),
1273                value: entry.proxy_jump.clone(),
1274                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
1275                is_non_directive: false,
1276            });
1277        }
1278
1279        HostBlock {
1280            host_pattern: entry.alias.clone(),
1281            raw_host_line: format!("Host {}", entry.alias),
1282            directives,
1283        }
1284    }
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289    use super::*;
1290
1291    fn parse_str(content: &str) -> SshConfigFile {
1292        SshConfigFile {
1293            elements: SshConfigFile::parse_content(content),
1294            path: PathBuf::from("/tmp/test_config"),
1295            crlf: false,
1296            bom: false,
1297        }
1298    }
1299
1300    #[test]
1301    fn tunnel_directives_extracts_forwards() {
1302        let config = parse_str(
1303            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1304        );
1305        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1306            let rules = block.tunnel_directives();
1307            assert_eq!(rules.len(), 3);
1308            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1309            assert_eq!(rules[0].bind_port, 8080);
1310            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1311            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1312        } else {
1313            panic!("Expected HostBlock");
1314        }
1315    }
1316
1317    #[test]
1318    fn tunnel_count_counts_forwards() {
1319        let config = parse_str(
1320            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
1321        );
1322        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1323            assert_eq!(block.tunnel_count(), 2);
1324        } else {
1325            panic!("Expected HostBlock");
1326        }
1327    }
1328
1329    #[test]
1330    fn tunnel_count_zero_for_no_forwards() {
1331        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1332        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1333            assert_eq!(block.tunnel_count(), 0);
1334            assert!(!block.has_tunnels());
1335        } else {
1336            panic!("Expected HostBlock");
1337        }
1338    }
1339
1340    #[test]
1341    fn has_tunnels_true_with_forward() {
1342        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
1343        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1344            assert!(block.has_tunnels());
1345        } else {
1346            panic!("Expected HostBlock");
1347        }
1348    }
1349
1350    #[test]
1351    fn add_forward_inserts_directive() {
1352        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1353        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1354        let output = config.serialize();
1355        assert!(output.contains("LocalForward 8080 localhost:80"));
1356        // Existing directives preserved
1357        assert!(output.contains("HostName 10.0.0.1"));
1358        assert!(output.contains("User admin"));
1359    }
1360
1361    #[test]
1362    fn add_forward_preserves_indentation() {
1363        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1364        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1365        let output = config.serialize();
1366        assert!(output.contains("\tLocalForward 8080 localhost:80"));
1367    }
1368
1369    #[test]
1370    fn add_multiple_forwards_same_type() {
1371        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1372        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1373        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1374        let output = config.serialize();
1375        assert!(output.contains("LocalForward 8080 localhost:80"));
1376        assert!(output.contains("LocalForward 9090 localhost:90"));
1377    }
1378
1379    #[test]
1380    fn remove_forward_removes_exact_match() {
1381        let mut config = parse_str(
1382            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1383        );
1384        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1385        let output = config.serialize();
1386        assert!(!output.contains("8080 localhost:80"));
1387        assert!(output.contains("9090 localhost:90"));
1388    }
1389
1390    #[test]
1391    fn remove_forward_leaves_other_directives() {
1392        let mut config = parse_str(
1393            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
1394        );
1395        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1396        let output = config.serialize();
1397        assert!(!output.contains("LocalForward"));
1398        assert!(output.contains("HostName 10.0.0.1"));
1399        assert!(output.contains("User admin"));
1400    }
1401
1402    #[test]
1403    fn remove_forward_no_match_is_noop() {
1404        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
1405        let mut config = parse_str(original);
1406        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1407        assert_eq!(config.serialize(), original);
1408    }
1409
1410    #[test]
1411    fn host_entry_tunnel_count_populated() {
1412        let config = parse_str(
1413            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
1414        );
1415        let entries = config.host_entries();
1416        assert_eq!(entries.len(), 1);
1417        assert_eq!(entries[0].tunnel_count, 2);
1418    }
1419
1420    #[test]
1421    fn remove_forward_returns_true_on_match() {
1422        let mut config = parse_str(
1423            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1424        );
1425        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1426    }
1427
1428    #[test]
1429    fn remove_forward_returns_false_on_no_match() {
1430        let mut config = parse_str(
1431            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1432        );
1433        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1434    }
1435
1436    #[test]
1437    fn remove_forward_returns_false_for_unknown_host() {
1438        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1439        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1440    }
1441
1442    #[test]
1443    fn has_forward_finds_match() {
1444        let config = parse_str(
1445            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1446        );
1447        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1448    }
1449
1450    #[test]
1451    fn has_forward_no_match() {
1452        let config = parse_str(
1453            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1454        );
1455        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1456        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1457    }
1458
1459    #[test]
1460    fn has_forward_case_insensitive_key() {
1461        let config = parse_str(
1462            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1463        );
1464        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1465    }
1466
1467    #[test]
1468    fn add_forward_to_empty_block() {
1469        let mut config = parse_str("Host myserver\n");
1470        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1471        let output = config.serialize();
1472        assert!(output.contains("LocalForward 8080 localhost:80"));
1473    }
1474
1475    #[test]
1476    fn remove_forward_case_insensitive_key_match() {
1477        let mut config = parse_str(
1478            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1479        );
1480        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1481        assert!(!config.serialize().contains("localforward"));
1482    }
1483
1484    #[test]
1485    fn tunnel_count_case_insensitive() {
1486        let config = parse_str(
1487            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
1488        );
1489        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1490            assert_eq!(block.tunnel_count(), 3);
1491        } else {
1492            panic!("Expected HostBlock");
1493        }
1494    }
1495
1496    #[test]
1497    fn tunnel_directives_extracts_all_types() {
1498        let config = parse_str(
1499            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1500        );
1501        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1502            let rules = block.tunnel_directives();
1503            assert_eq!(rules.len(), 3);
1504            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1505            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1506            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1507        } else {
1508            panic!("Expected HostBlock");
1509        }
1510    }
1511
1512    #[test]
1513    fn tunnel_directives_skips_malformed() {
1514        let config = parse_str(
1515            "Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n",
1516        );
1517        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1518            let rules = block.tunnel_directives();
1519            assert_eq!(rules.len(), 1);
1520            assert_eq!(rules[0].bind_port, 1080);
1521        } else {
1522            panic!("Expected HostBlock");
1523        }
1524    }
1525
1526    #[test]
1527    fn find_tunnel_directives_multi_pattern_host() {
1528        let config = parse_str(
1529            "Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1530        );
1531        let rules = config.find_tunnel_directives("prod");
1532        assert_eq!(rules.len(), 1);
1533        assert_eq!(rules[0].bind_port, 8080);
1534        let rules2 = config.find_tunnel_directives("staging");
1535        assert_eq!(rules2.len(), 1);
1536    }
1537
1538    #[test]
1539    fn find_tunnel_directives_no_match() {
1540        let config = parse_str(
1541            "Host myserver\n  LocalForward 8080 localhost:80\n",
1542        );
1543        let rules = config.find_tunnel_directives("nohost");
1544        assert!(rules.is_empty());
1545    }
1546
1547    #[test]
1548    fn has_forward_exact_match() {
1549        let config = parse_str(
1550            "Host myserver\n  LocalForward 8080 localhost:80\n",
1551        );
1552        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1553        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1554        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1555        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1556    }
1557
1558    #[test]
1559    fn has_forward_whitespace_normalized() {
1560        let config = parse_str(
1561            "Host myserver\n  LocalForward 8080  localhost:80\n",
1562        );
1563        // Extra space in config value vs single space in query — should still match
1564        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1565    }
1566
1567    #[test]
1568    fn has_forward_multi_pattern_host() {
1569        let config = parse_str(
1570            "Host prod staging\n  LocalForward 8080 localhost:80\n",
1571        );
1572        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1573        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1574    }
1575
1576    #[test]
1577    fn add_forward_multi_pattern_host() {
1578        let mut config = parse_str(
1579            "Host prod staging\n  HostName 10.0.0.1\n",
1580        );
1581        config.add_forward("prod", "LocalForward", "8080 localhost:80");
1582        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1583        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1584    }
1585
1586    #[test]
1587    fn remove_forward_multi_pattern_host() {
1588        let mut config = parse_str(
1589            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1590        );
1591        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1592        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1593        // Other forward should remain
1594        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1595    }
1596
1597    #[test]
1598    fn edit_tunnel_detects_duplicate_after_remove() {
1599        // Simulates edit flow: remove old, then check if new value already exists
1600        let mut config = parse_str(
1601            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1602        );
1603        // Edit rule A (8080) toward rule B (9090): remove A first
1604        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1605        // Now check if the target value already exists — should detect duplicate
1606        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1607    }
1608
1609    #[test]
1610    fn has_forward_tab_whitespace_normalized() {
1611        let config = parse_str(
1612            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1613        );
1614        // Tab in config value vs space in query — should match via values_match
1615        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1616    }
1617
1618    #[test]
1619    fn remove_forward_tab_whitespace_normalized() {
1620        let mut config = parse_str(
1621            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1622        );
1623        // Remove with single space should match tab-separated value
1624        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1625        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1626    }
1627
1628    #[test]
1629    fn upsert_preserves_space_separator_when_value_contains_equals() {
1630        let mut config = parse_str(
1631            "Host myserver\n  IdentityFile ~/.ssh/id=prod\n",
1632        );
1633        let entry = HostEntry {
1634            alias: "myserver".to_string(),
1635            hostname: "10.0.0.1".to_string(),
1636            identity_file: "~/.ssh/id=staging".to_string(),
1637            port: 22,
1638            ..Default::default()
1639        };
1640        config.update_host("myserver", &entry);
1641        let output = config.serialize();
1642        // Separator should remain a space, not pick up the = from the value
1643        assert!(output.contains("  IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1644        assert!(!output.contains("IdentityFile="), "got: {}", output);
1645    }
1646
1647    #[test]
1648    fn upsert_preserves_equals_separator() {
1649        let mut config = parse_str(
1650            "Host myserver\n  IdentityFile=~/.ssh/id_rsa\n",
1651        );
1652        let entry = HostEntry {
1653            alias: "myserver".to_string(),
1654            hostname: "10.0.0.1".to_string(),
1655            identity_file: "~/.ssh/id_ed25519".to_string(),
1656            port: 22,
1657            ..Default::default()
1658        };
1659        config.update_host("myserver", &entry);
1660        let output = config.serialize();
1661        assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1662    }
1663
1664    #[test]
1665    fn upsert_preserves_spaced_equals_separator() {
1666        let mut config = parse_str(
1667            "Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n",
1668        );
1669        let entry = HostEntry {
1670            alias: "myserver".to_string(),
1671            hostname: "10.0.0.1".to_string(),
1672            identity_file: "~/.ssh/id_ed25519".to_string(),
1673            port: 22,
1674            ..Default::default()
1675        };
1676        config.update_host("myserver", &entry);
1677        let output = config.serialize();
1678        assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1679    }
1680
1681    #[test]
1682    fn is_included_host_false_for_main_config() {
1683        let config = parse_str(
1684            "Host myserver\n  HostName 10.0.0.1\n",
1685        );
1686        assert!(!config.is_included_host("myserver"));
1687    }
1688
1689    #[test]
1690    fn is_included_host_false_for_nonexistent() {
1691        let config = parse_str(
1692            "Host myserver\n  HostName 10.0.0.1\n",
1693        );
1694        assert!(!config.is_included_host("nohost"));
1695    }
1696
1697    #[test]
1698    fn is_included_host_multi_pattern_main_config() {
1699        let config = parse_str(
1700            "Host prod staging\n  HostName 10.0.0.1\n",
1701        );
1702        assert!(!config.is_included_host("prod"));
1703        assert!(!config.is_included_host("staging"));
1704    }
1705
1706    // =========================================================================
1707    // HostBlock::askpass() and set_askpass() tests
1708    // =========================================================================
1709
1710    fn first_block(config: &SshConfigFile) -> &HostBlock {
1711        match config.elements.first().unwrap() {
1712            ConfigElement::HostBlock(b) => b,
1713            _ => panic!("Expected HostBlock"),
1714        }
1715    }
1716
1717    fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1718        let mut count = 0;
1719        for el in &config.elements {
1720            if let ConfigElement::HostBlock(b) = el {
1721                if count == idx {
1722                    return b;
1723                }
1724                count += 1;
1725            }
1726        }
1727        panic!("No HostBlock at index {}", idx);
1728    }
1729
1730    #[test]
1731    fn askpass_returns_none_when_absent() {
1732        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1733        assert_eq!(first_block(&config).askpass(), None);
1734    }
1735
1736    #[test]
1737    fn askpass_returns_keychain() {
1738        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1739        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1740    }
1741
1742    #[test]
1743    fn askpass_returns_op_uri() {
1744        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/Item/field\n");
1745        assert_eq!(first_block(&config).askpass(), Some("op://Vault/Item/field".to_string()));
1746    }
1747
1748    #[test]
1749    fn askpass_returns_vault_with_field() {
1750        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#password\n");
1751        assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh#password".to_string()));
1752    }
1753
1754    #[test]
1755    fn askpass_returns_bw_source() {
1756        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
1757        assert_eq!(first_block(&config).askpass(), Some("bw:my-item".to_string()));
1758    }
1759
1760    #[test]
1761    fn askpass_returns_pass_source() {
1762        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
1763        assert_eq!(first_block(&config).askpass(), Some("pass:ssh/prod".to_string()));
1764    }
1765
1766    #[test]
1767    fn askpass_returns_custom_command() {
1768        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass get-pass %a %h\n");
1769        assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1770    }
1771
1772    #[test]
1773    fn askpass_ignores_empty_value() {
1774        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass \n");
1775        assert_eq!(first_block(&config).askpass(), None);
1776    }
1777
1778    #[test]
1779    fn askpass_ignores_non_askpass_purple_comments() {
1780        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod\n");
1781        assert_eq!(first_block(&config).askpass(), None);
1782    }
1783
1784    #[test]
1785    fn set_askpass_adds_comment() {
1786        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1787        config.set_host_askpass("myserver", "keychain");
1788        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1789    }
1790
1791    #[test]
1792    fn set_askpass_replaces_existing() {
1793        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1794        config.set_host_askpass("myserver", "op://V/I/p");
1795        assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1796    }
1797
1798    #[test]
1799    fn set_askpass_empty_removes_comment() {
1800        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1801        config.set_host_askpass("myserver", "");
1802        assert_eq!(first_block(&config).askpass(), None);
1803    }
1804
1805    #[test]
1806    fn set_askpass_preserves_other_directives() {
1807        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n  # purple:tags prod\n");
1808        config.set_host_askpass("myserver", "vault:secret/ssh");
1809        assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh".to_string()));
1810        let entry = first_block(&config).to_host_entry();
1811        assert_eq!(entry.user, "admin");
1812        assert!(entry.tags.contains(&"prod".to_string()));
1813    }
1814
1815    #[test]
1816    fn set_askpass_preserves_indent() {
1817        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
1818        config.set_host_askpass("myserver", "keychain");
1819        let raw = first_block(&config).directives.iter()
1820            .find(|d| d.raw_line.contains("purple:askpass"))
1821            .unwrap();
1822        assert!(raw.raw_line.starts_with("    "), "Expected 4-space indent, got: {:?}", raw.raw_line);
1823    }
1824
1825    #[test]
1826    fn set_askpass_on_nonexistent_host() {
1827        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1828        config.set_host_askpass("nohost", "keychain");
1829        assert_eq!(first_block(&config).askpass(), None);
1830    }
1831
1832    #[test]
1833    fn to_entry_includes_askpass() {
1834        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
1835        let entries = config.host_entries();
1836        assert_eq!(entries.len(), 1);
1837        assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
1838    }
1839
1840    #[test]
1841    fn to_entry_askpass_none_when_absent() {
1842        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1843        let entries = config.host_entries();
1844        assert_eq!(entries.len(), 1);
1845        assert_eq!(entries[0].askpass, None);
1846    }
1847
1848    #[test]
1849    fn set_askpass_vault_with_hash_field() {
1850        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1851        config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
1852        assert_eq!(first_block(&config).askpass(), Some("vault:secret/data/team#api_key".to_string()));
1853    }
1854
1855    #[test]
1856    fn set_askpass_custom_command_with_percent() {
1857        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1858        config.set_host_askpass("myserver", "get-pass %a %h");
1859        assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1860    }
1861
1862    #[test]
1863    fn multiple_hosts_independent_askpass() {
1864        let mut config = parse_str("Host alpha\n  HostName a.com\n\nHost beta\n  HostName b.com\n");
1865        config.set_host_askpass("alpha", "keychain");
1866        config.set_host_askpass("beta", "vault:secret/ssh");
1867        assert_eq!(block_by_index(&config, 0).askpass(), Some("keychain".to_string()));
1868        assert_eq!(block_by_index(&config, 1).askpass(), Some("vault:secret/ssh".to_string()));
1869    }
1870
1871    #[test]
1872    fn set_askpass_then_clear_then_set_again() {
1873        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1874        config.set_host_askpass("myserver", "keychain");
1875        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1876        config.set_host_askpass("myserver", "");
1877        assert_eq!(first_block(&config).askpass(), None);
1878        config.set_host_askpass("myserver", "op://V/I/p");
1879        assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1880    }
1881
1882    #[test]
1883    fn askpass_tab_indent_preserved() {
1884        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1885        config.set_host_askpass("myserver", "pass:ssh/prod");
1886        let raw = first_block(&config).directives.iter()
1887            .find(|d| d.raw_line.contains("purple:askpass"))
1888            .unwrap();
1889        assert!(raw.raw_line.starts_with("\t"), "Expected tab indent, got: {:?}", raw.raw_line);
1890    }
1891
1892    #[test]
1893    fn askpass_coexists_with_provider_comment() {
1894        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:123\n  # purple:askpass keychain\n");
1895        let block = first_block(&config);
1896        assert_eq!(block.askpass(), Some("keychain".to_string()));
1897        assert!(block.provider().is_some());
1898    }
1899
1900    #[test]
1901    fn set_askpass_does_not_remove_tags() {
1902        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod,staging\n");
1903        config.set_host_askpass("myserver", "keychain");
1904        let entry = first_block(&config).to_host_entry();
1905        assert_eq!(entry.askpass, Some("keychain".to_string()));
1906        assert!(entry.tags.contains(&"prod".to_string()));
1907        assert!(entry.tags.contains(&"staging".to_string()));
1908    }
1909
1910    #[test]
1911    fn askpass_idempotent_set_same_value() {
1912        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1913        config.set_host_askpass("myserver", "keychain");
1914        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1915        let serialized = config.serialize();
1916        assert_eq!(serialized.matches("purple:askpass").count(), 1, "Should have exactly one askpass comment");
1917    }
1918
1919    #[test]
1920    fn askpass_with_value_containing_equals() {
1921        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1922        config.set_host_askpass("myserver", "cmd --opt=val %h");
1923        assert_eq!(first_block(&config).askpass(), Some("cmd --opt=val %h".to_string()));
1924    }
1925
1926    #[test]
1927    fn askpass_with_value_containing_hash() {
1928        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:a/b#c\n");
1929        assert_eq!(first_block(&config).askpass(), Some("vault:a/b#c".to_string()));
1930    }
1931
1932    #[test]
1933    fn askpass_with_long_op_uri() {
1934        let uri = "op://My Personal Vault/SSH Production Server/password";
1935        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1936        config.set_host_askpass("myserver", uri);
1937        assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
1938    }
1939
1940    #[test]
1941    fn askpass_does_not_interfere_with_host_matching() {
1942        // askpass is stored as a non-directive comment; it shouldn't affect SSH matching
1943        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User root\n  # purple:askpass keychain\n");
1944        let entry = first_block(&config).to_host_entry();
1945        assert_eq!(entry.user, "root");
1946        assert_eq!(entry.hostname, "10.0.0.1");
1947        assert_eq!(entry.askpass, Some("keychain".to_string()));
1948    }
1949
1950    #[test]
1951    fn set_askpass_on_host_with_many_directives() {
1952        let config_str = "\
1953Host myserver
1954  HostName 10.0.0.1
1955  User admin
1956  Port 2222
1957  IdentityFile ~/.ssh/id_ed25519
1958  ProxyJump bastion
1959  # purple:tags prod,us-east
1960";
1961        let mut config = parse_str(config_str);
1962        config.set_host_askpass("myserver", "pass:ssh/prod");
1963        let entry = first_block(&config).to_host_entry();
1964        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1965        assert_eq!(entry.user, "admin");
1966        assert_eq!(entry.port, 2222);
1967        assert!(entry.tags.contains(&"prod".to_string()));
1968    }
1969
1970    #[test]
1971    fn askpass_with_crlf_line_endings() {
1972        let config = parse_str("Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n");
1973        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1974    }
1975
1976    #[test]
1977    fn askpass_only_on_first_matching_host() {
1978        // If two Host blocks have the same alias (unusual), askpass comes from first
1979        let config = parse_str("Host dup\n  HostName a.com\n  # purple:askpass keychain\n\nHost dup\n  HostName b.com\n  # purple:askpass vault:x\n");
1980        let entries = config.host_entries();
1981        // First match
1982        assert_eq!(entries[0].askpass, Some("keychain".to_string()));
1983    }
1984
1985    #[test]
1986    fn set_askpass_preserves_other_non_directive_comments() {
1987        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";
1988        let mut config = parse_str(config_str);
1989        config.set_host_askpass("myserver", "new-source");
1990        let serialized = config.serialize();
1991        assert!(serialized.contains("# This is a user comment"));
1992        assert!(serialized.contains("# Another comment"));
1993        assert!(serialized.contains("# purple:askpass new-source"));
1994        assert!(!serialized.contains("# purple:askpass old"));
1995    }
1996
1997    #[test]
1998    fn askpass_mixed_with_tunnel_directives() {
1999        let config_str = "\
2000Host myserver
2001  HostName 10.0.0.1
2002  LocalForward 8080 localhost:80
2003  # purple:askpass bw:item
2004  RemoteForward 9090 localhost:9090
2005";
2006        let config = parse_str(config_str);
2007        let entry = first_block(&config).to_host_entry();
2008        assert_eq!(entry.askpass, Some("bw:item".to_string()));
2009        assert_eq!(entry.tunnel_count, 2);
2010    }
2011
2012    // =========================================================================
2013    // askpass: set_askpass idempotent (same value)
2014    // =========================================================================
2015
2016    #[test]
2017    fn set_askpass_idempotent_same_value() {
2018        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n";
2019        let mut config = parse_str(config_str);
2020        config.set_host_askpass("myserver", "keychain");
2021        let output = config.serialize();
2022        // Should still have exactly one askpass comment
2023        assert_eq!(output.matches("purple:askpass").count(), 1);
2024        assert!(output.contains("# purple:askpass keychain"));
2025    }
2026
2027    #[test]
2028    fn set_askpass_with_equals_in_value() {
2029        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2030        config.set_host_askpass("myserver", "cmd --opt=val");
2031        let entries = config.host_entries();
2032        assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
2033    }
2034
2035    #[test]
2036    fn set_askpass_with_hash_in_value() {
2037        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2038        config.set_host_askpass("myserver", "vault:secret/data#field");
2039        let entries = config.host_entries();
2040        assert_eq!(entries[0].askpass, Some("vault:secret/data#field".to_string()));
2041    }
2042
2043    #[test]
2044    fn set_askpass_long_op_uri() {
2045        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
2046        let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
2047        config.set_host_askpass("myserver", long_uri);
2048        assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
2049    }
2050
2051    #[test]
2052    fn askpass_host_with_multi_pattern_is_skipped() {
2053        // Multi-pattern host blocks ("Host prod staging") are treated as patterns
2054        // and are not included in host_entries(), so set_askpass is a no-op
2055        let config_str = "Host prod staging\n  HostName 10.0.0.1\n";
2056        let mut config = parse_str(config_str);
2057        config.set_host_askpass("prod", "keychain");
2058        // No entries because multi-pattern hosts are pattern hosts
2059        assert!(config.host_entries().is_empty());
2060    }
2061
2062    #[test]
2063    fn askpass_survives_directive_reorder() {
2064        // askpass should survive even when directives are in unusual order
2065        let config_str = "\
2066Host myserver
2067  # purple:askpass op://V/I/p
2068  HostName 10.0.0.1
2069  User root
2070";
2071        let config = parse_str(config_str);
2072        let entry = first_block(&config).to_host_entry();
2073        assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
2074        assert_eq!(entry.hostname, "10.0.0.1");
2075    }
2076
2077    #[test]
2078    fn askpass_among_many_purple_comments() {
2079        let config_str = "\
2080Host myserver
2081  HostName 10.0.0.1
2082  # purple:tags prod,us-east
2083  # purple:provider do:12345
2084  # purple:askpass pass:ssh/prod
2085";
2086        let config = parse_str(config_str);
2087        let entry = first_block(&config).to_host_entry();
2088        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
2089        assert!(entry.tags.contains(&"prod".to_string()));
2090    }
2091
2092    #[test]
2093    fn meta_empty_when_no_comment() {
2094        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2095        let config = parse_str(config_str);
2096        let meta = first_block(&config).meta();
2097        assert!(meta.is_empty());
2098    }
2099
2100    #[test]
2101    fn meta_parses_key_value_pairs() {
2102        let config_str = "\
2103Host myhost
2104  HostName 1.2.3.4
2105  # purple:meta region=nyc3,plan=s-1vcpu-1gb
2106";
2107        let config = parse_str(config_str);
2108        let meta = first_block(&config).meta();
2109        assert_eq!(meta.len(), 2);
2110        assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
2111        assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2112    }
2113
2114    #[test]
2115    fn meta_round_trip() {
2116        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2117        let mut config = parse_str(config_str);
2118        let meta = vec![
2119            ("region".to_string(), "fra1".to_string()),
2120            ("plan".to_string(), "cx11".to_string()),
2121        ];
2122        config.set_host_meta("myhost", &meta);
2123        let output = config.serialize();
2124        assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
2125
2126        let config2 = parse_str(&output);
2127        let parsed = first_block(&config2).meta();
2128        assert_eq!(parsed, meta);
2129    }
2130
2131    #[test]
2132    fn meta_replaces_existing() {
2133        let config_str = "\
2134Host myhost
2135  HostName 1.2.3.4
2136  # purple:meta region=old
2137";
2138        let mut config = parse_str(config_str);
2139        config.set_host_meta(
2140            "myhost",
2141            &[("region".to_string(), "new".to_string())],
2142        );
2143        let output = config.serialize();
2144        assert!(!output.contains("region=old"));
2145        assert!(output.contains("region=new"));
2146    }
2147
2148    #[test]
2149    fn meta_removed_when_empty() {
2150        let config_str = "\
2151Host myhost
2152  HostName 1.2.3.4
2153  # purple:meta region=nyc3
2154";
2155        let mut config = parse_str(config_str);
2156        config.set_host_meta("myhost", &[]);
2157        let output = config.serialize();
2158        assert!(!output.contains("purple:meta"));
2159    }
2160
2161    #[test]
2162    fn meta_sanitizes_commas_in_values() {
2163        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
2164        let mut config = parse_str(config_str);
2165        let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
2166        config.set_host_meta("myhost", &meta);
2167        let output = config.serialize();
2168        // Comma stripped to prevent parse corruption
2169        assert!(output.contains("plan=s-1vcpu1gb"));
2170
2171        let config2 = parse_str(&output);
2172        let parsed = first_block(&config2).meta();
2173        assert_eq!(parsed[0].1, "s-1vcpu1gb");
2174    }
2175
2176    #[test]
2177    fn meta_in_host_entry() {
2178        let config_str = "\
2179Host myhost
2180  HostName 1.2.3.4
2181  # purple:meta region=nyc3,plan=s-1vcpu-1gb
2182";
2183        let config = parse_str(config_str);
2184        let entry = first_block(&config).to_host_entry();
2185        assert_eq!(entry.provider_meta.len(), 2);
2186        assert_eq!(entry.provider_meta[0].0, "region");
2187        assert_eq!(entry.provider_meta[1].0, "plan");
2188    }
2189
2190    #[test]
2191    fn repair_absorbed_group_comment() {
2192        // Simulate the bug: group comment absorbed into preceding block's directives.
2193        let mut config = SshConfigFile {
2194            elements: vec![ConfigElement::HostBlock(HostBlock {
2195                host_pattern: "myserver".to_string(),
2196                raw_host_line: "Host myserver".to_string(),
2197                directives: vec![
2198                    Directive {
2199                        key: "HostName".to_string(),
2200                        value: "10.0.0.1".to_string(),
2201                        raw_line: "  HostName 10.0.0.1".to_string(),
2202                        is_non_directive: false,
2203                    },
2204                    Directive {
2205                        key: String::new(),
2206                        value: String::new(),
2207                        raw_line: "# purple:group Production".to_string(),
2208                        is_non_directive: true,
2209                    },
2210                ],
2211            })],
2212            path: PathBuf::from("/tmp/test_config"),
2213            crlf: false,
2214            bom: false,
2215        };
2216        let count = config.repair_absorbed_group_comments();
2217        assert_eq!(count, 1);
2218        assert_eq!(config.elements.len(), 2);
2219        // Block should only have the HostName directive.
2220        if let ConfigElement::HostBlock(block) = &config.elements[0] {
2221            assert_eq!(block.directives.len(), 1);
2222            assert_eq!(block.directives[0].key, "HostName");
2223        } else {
2224            panic!("Expected HostBlock");
2225        }
2226        // Group comment should be a GlobalLine.
2227        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2228            assert_eq!(line, "# purple:group Production");
2229        } else {
2230            panic!("Expected GlobalLine for group comment");
2231        }
2232    }
2233
2234    #[test]
2235    fn repair_strips_trailing_blanks_before_group() {
2236        let mut config = SshConfigFile {
2237            elements: vec![ConfigElement::HostBlock(HostBlock {
2238                host_pattern: "myserver".to_string(),
2239                raw_host_line: "Host myserver".to_string(),
2240                directives: vec![
2241                    Directive {
2242                        key: "HostName".to_string(),
2243                        value: "10.0.0.1".to_string(),
2244                        raw_line: "  HostName 10.0.0.1".to_string(),
2245                        is_non_directive: false,
2246                    },
2247                    Directive {
2248                        key: String::new(),
2249                        value: String::new(),
2250                        raw_line: "".to_string(),
2251                        is_non_directive: true,
2252                    },
2253                    Directive {
2254                        key: String::new(),
2255                        value: String::new(),
2256                        raw_line: "# purple:group Staging".to_string(),
2257                        is_non_directive: true,
2258                    },
2259                ],
2260            })],
2261            path: PathBuf::from("/tmp/test_config"),
2262            crlf: false,
2263            bom: false,
2264        };
2265        let count = config.repair_absorbed_group_comments();
2266        assert_eq!(count, 1);
2267        // Block keeps only HostName.
2268        if let ConfigElement::HostBlock(block) = &config.elements[0] {
2269            assert_eq!(block.directives.len(), 1);
2270        } else {
2271            panic!("Expected HostBlock");
2272        }
2273        // Blank line and group comment are now GlobalLines.
2274        assert_eq!(config.elements.len(), 3);
2275        if let ConfigElement::GlobalLine(line) = &config.elements[1] {
2276            assert!(line.trim().is_empty());
2277        } else {
2278            panic!("Expected blank GlobalLine");
2279        }
2280        if let ConfigElement::GlobalLine(line) = &config.elements[2] {
2281            assert!(line.starts_with("# purple:group"));
2282        } else {
2283            panic!("Expected group GlobalLine");
2284        }
2285    }
2286
2287    #[test]
2288    fn repair_clean_config_returns_zero() {
2289        let mut config = parse_str(
2290            "# purple:group Production\nHost myserver\n  HostName 10.0.0.1\n",
2291        );
2292        let count = config.repair_absorbed_group_comments();
2293        assert_eq!(count, 0);
2294    }
2295
2296    #[test]
2297    fn repair_roundtrip_serializes_correctly() {
2298        // Build a corrupted config manually.
2299        let mut config = SshConfigFile {
2300            elements: vec![
2301                ConfigElement::HostBlock(HostBlock {
2302                    host_pattern: "server1".to_string(),
2303                    raw_host_line: "Host server1".to_string(),
2304                    directives: vec![
2305                        Directive {
2306                            key: "HostName".to_string(),
2307                            value: "10.0.0.1".to_string(),
2308                            raw_line: "  HostName 10.0.0.1".to_string(),
2309                            is_non_directive: false,
2310                        },
2311                        Directive {
2312                            key: String::new(),
2313                            value: String::new(),
2314                            raw_line: "".to_string(),
2315                            is_non_directive: true,
2316                        },
2317                        Directive {
2318                            key: String::new(),
2319                            value: String::new(),
2320                            raw_line: "# purple:group Staging".to_string(),
2321                            is_non_directive: true,
2322                        },
2323                    ],
2324                }),
2325                ConfigElement::HostBlock(HostBlock {
2326                    host_pattern: "server2".to_string(),
2327                    raw_host_line: "Host server2".to_string(),
2328                    directives: vec![Directive {
2329                        key: "HostName".to_string(),
2330                        value: "10.0.0.2".to_string(),
2331                        raw_line: "  HostName 10.0.0.2".to_string(),
2332                        is_non_directive: false,
2333                    }],
2334                }),
2335            ],
2336            path: PathBuf::from("/tmp/test_config"),
2337            crlf: false,
2338            bom: false,
2339        };
2340        let count = config.repair_absorbed_group_comments();
2341        assert_eq!(count, 1);
2342        let output = config.serialize();
2343        // The group comment should appear between the two host blocks.
2344        let expected = "\
2345Host server1
2346  HostName 10.0.0.1
2347
2348# purple:group Staging
2349Host server2
2350  HostName 10.0.0.2
2351";
2352        assert_eq!(output, expected);
2353    }
2354
2355    // =========================================================================
2356    // delete_host: orphaned group header cleanup
2357    // =========================================================================
2358
2359    #[test]
2360    fn delete_last_provider_host_removes_group_header() {
2361        let config_str = "\
2362# purple:group DigitalOcean
2363Host do-web
2364  HostName 1.2.3.4
2365  # purple:provider digitalocean:123
2366";
2367        let mut config = parse_str(config_str);
2368        config.delete_host("do-web");
2369        let has_header = config.elements.iter().any(|e| {
2370            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group"))
2371        });
2372        assert!(!has_header, "Group header should be removed when last provider host is deleted");
2373    }
2374
2375    #[test]
2376    fn delete_one_of_multiple_provider_hosts_preserves_group_header() {
2377        let config_str = "\
2378# purple:group DigitalOcean
2379Host do-web
2380  HostName 1.2.3.4
2381  # purple:provider digitalocean:123
2382
2383Host do-db
2384  HostName 5.6.7.8
2385  # purple:provider digitalocean:456
2386";
2387        let mut config = parse_str(config_str);
2388        config.delete_host("do-web");
2389        let has_header = config.elements.iter().any(|e| {
2390            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2391        });
2392        assert!(has_header, "Group header should be preserved when other provider hosts remain");
2393        assert_eq!(config.host_entries().len(), 1);
2394    }
2395
2396    #[test]
2397    fn delete_non_provider_host_leaves_group_headers() {
2398        let config_str = "\
2399Host personal
2400  HostName 10.0.0.1
2401
2402# purple:group DigitalOcean
2403Host do-web
2404  HostName 1.2.3.4
2405  # purple:provider digitalocean:123
2406";
2407        let mut config = parse_str(config_str);
2408        config.delete_host("personal");
2409        let has_header = config.elements.iter().any(|e| {
2410            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group DigitalOcean"))
2411        });
2412        assert!(has_header, "Group header should not be affected by deleting a non-provider host");
2413        assert_eq!(config.host_entries().len(), 1);
2414    }
2415
2416    #[test]
2417    fn delete_host_undoable_keeps_group_header_for_undo() {
2418        // delete_host_undoable does NOT remove orphaned group headers so that
2419        // undo (insert_host_at) can restore the config to its original state.
2420        // Orphaned headers are cleaned up at startup instead.
2421        let config_str = "\
2422# purple:group Vultr
2423Host vultr-web
2424  HostName 2.3.4.5
2425  # purple:provider vultr:789
2426";
2427        let mut config = parse_str(config_str);
2428        let result = config.delete_host_undoable("vultr-web");
2429        assert!(result.is_some());
2430        let has_header = config.elements.iter().any(|e| {
2431            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group"))
2432        });
2433        assert!(has_header, "Group header should be kept for undo");
2434    }
2435
2436    #[test]
2437    fn delete_host_undoable_preserves_header_when_others_remain() {
2438        let config_str = "\
2439# purple:group AWS EC2
2440Host aws-web
2441  HostName 3.4.5.6
2442  # purple:provider aws:i-111
2443
2444Host aws-db
2445  HostName 7.8.9.0
2446  # purple:provider aws:i-222
2447";
2448        let mut config = parse_str(config_str);
2449        let result = config.delete_host_undoable("aws-web");
2450        assert!(result.is_some());
2451        let has_header = config.elements.iter().any(|e| {
2452            matches!(e, ConfigElement::GlobalLine(l) if l.contains("purple:group AWS EC2"))
2453        });
2454        assert!(has_header, "Group header preserved when other provider hosts remain (undoable)");
2455    }
2456
2457    #[test]
2458    fn delete_host_undoable_returns_original_position_for_undo() {
2459        // Group header at index 0, host at index 1. Undo re-inserts at index 1
2460        // which correctly restores the host after the group header.
2461        let config_str = "\
2462# purple:group Vultr
2463Host vultr-web
2464  HostName 2.3.4.5
2465  # purple:provider vultr:789
2466
2467Host manual
2468  HostName 10.0.0.1
2469";
2470        let mut config = parse_str(config_str);
2471        let (element, pos) = config.delete_host_undoable("vultr-web").unwrap();
2472        // Position is the original index (1), not adjusted, since no header was removed
2473        assert_eq!(pos, 1, "Position should be the original host index");
2474        // Undo: re-insert at the original position
2475        config.insert_host_at(element, pos);
2476        // The host should be back, group header intact, manual host accessible
2477        let output = config.serialize();
2478        assert!(output.contains("# purple:group Vultr"), "Group header should be present");
2479        assert!(output.contains("Host vultr-web"), "Host should be restored");
2480        assert!(output.contains("Host manual"), "Manual host should survive");
2481        assert_eq!(config_str, output);
2482    }
2483
2484    // =========================================================================
2485    // add_host: wildcard ordering
2486    // =========================================================================
2487
2488    #[test]
2489    fn add_host_inserts_before_trailing_wildcard() {
2490        let config_str = "\
2491Host existing
2492  HostName 10.0.0.1
2493
2494Host *
2495  ServerAliveInterval 60
2496";
2497        let mut config = parse_str(config_str);
2498        let entry = HostEntry {
2499            alias: "newhost".to_string(),
2500            hostname: "10.0.0.2".to_string(),
2501            port: 22,
2502            ..Default::default()
2503        };
2504        config.add_host(&entry);
2505        let output = config.serialize();
2506        let new_pos = output.find("Host newhost").unwrap();
2507        let wildcard_pos = output.find("Host *").unwrap();
2508        assert!(
2509            new_pos < wildcard_pos,
2510            "New host should appear before Host *: {}",
2511            output
2512        );
2513        let existing_pos = output.find("Host existing").unwrap();
2514        assert!(existing_pos < new_pos);
2515    }
2516
2517    #[test]
2518    fn add_host_appends_when_no_wildcards() {
2519        let config_str = "\
2520Host existing
2521  HostName 10.0.0.1
2522";
2523        let mut config = parse_str(config_str);
2524        let entry = HostEntry {
2525            alias: "newhost".to_string(),
2526            hostname: "10.0.0.2".to_string(),
2527            port: 22,
2528            ..Default::default()
2529        };
2530        config.add_host(&entry);
2531        let output = config.serialize();
2532        let existing_pos = output.find("Host existing").unwrap();
2533        let new_pos = output.find("Host newhost").unwrap();
2534        assert!(existing_pos < new_pos, "New host should be appended at end");
2535    }
2536
2537    #[test]
2538    fn add_host_appends_when_wildcard_at_beginning() {
2539        // Host * at the top acts as global defaults. New hosts go after it.
2540        let config_str = "\
2541Host *
2542  ServerAliveInterval 60
2543
2544Host existing
2545  HostName 10.0.0.1
2546";
2547        let mut config = parse_str(config_str);
2548        let entry = HostEntry {
2549            alias: "newhost".to_string(),
2550            hostname: "10.0.0.2".to_string(),
2551            port: 22,
2552            ..Default::default()
2553        };
2554        config.add_host(&entry);
2555        let output = config.serialize();
2556        let existing_pos = output.find("Host existing").unwrap();
2557        let new_pos = output.find("Host newhost").unwrap();
2558        assert!(
2559            existing_pos < new_pos,
2560            "New host should be appended at end when wildcard is at top: {}",
2561            output
2562        );
2563    }
2564
2565    #[test]
2566    fn add_host_inserts_before_trailing_pattern_host() {
2567        let config_str = "\
2568Host existing
2569  HostName 10.0.0.1
2570
2571Host *.example.com
2572  ProxyJump bastion
2573";
2574        let mut config = parse_str(config_str);
2575        let entry = HostEntry {
2576            alias: "newhost".to_string(),
2577            hostname: "10.0.0.2".to_string(),
2578            port: 22,
2579            ..Default::default()
2580        };
2581        config.add_host(&entry);
2582        let output = config.serialize();
2583        let new_pos = output.find("Host newhost").unwrap();
2584        let pattern_pos = output.find("Host *.example.com").unwrap();
2585        assert!(
2586            new_pos < pattern_pos,
2587            "New host should appear before pattern host: {}",
2588            output
2589        );
2590    }
2591
2592    #[test]
2593    fn add_host_no_triple_blank_lines() {
2594        let config_str = "\
2595Host existing
2596  HostName 10.0.0.1
2597
2598Host *
2599  ServerAliveInterval 60
2600";
2601        let mut config = parse_str(config_str);
2602        let entry = HostEntry {
2603            alias: "newhost".to_string(),
2604            hostname: "10.0.0.2".to_string(),
2605            port: 22,
2606            ..Default::default()
2607        };
2608        config.add_host(&entry);
2609        let output = config.serialize();
2610        assert!(
2611            !output.contains("\n\n\n"),
2612            "Should not have triple blank lines: {}",
2613            output
2614        );
2615    }
2616
2617    #[test]
2618    fn provider_group_display_name_matches_providers_mod() {
2619        // Ensure the duplicated display name function in model.rs stays in sync
2620        // with providers::provider_display_name(). If these diverge, group header
2621        // cleanup (remove_orphaned_group_header) will fail to match headers
2622        // written by the sync engine.
2623        let providers = [
2624            "digitalocean", "vultr", "linode", "hetzner", "upcloud",
2625            "proxmox", "aws", "scaleway", "gcp", "azure", "tailscale",
2626        ];
2627        for name in &providers {
2628            assert_eq!(
2629                provider_group_display_name(name),
2630                crate::providers::provider_display_name(name),
2631                "Display name mismatch for provider '{}': model.rs has '{}' but providers/mod.rs has '{}'",
2632                name,
2633                provider_group_display_name(name),
2634                crate::providers::provider_display_name(name),
2635            );
2636        }
2637    }
2638
2639}