Skip to main content

purple_ssh/ssh_config/
model.rs

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