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