Skip to main content

purple_ssh/ssh_config/
model.rs

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