Skip to main content

purple_ssh/ssh_config/
model.rs

1use std::path::PathBuf;
2
3/// Represents the entire SSH config file as a sequence of elements.
4/// Preserves the original structure for round-trip fidelity.
5#[derive(Debug, Clone)]
6pub struct SshConfigFile {
7    pub elements: Vec<ConfigElement>,
8    pub path: PathBuf,
9    /// Whether the original file used CRLF line endings.
10    pub crlf: bool,
11}
12
13/// An Include directive that references other config files.
14#[derive(Debug, Clone)]
15#[allow(dead_code)]
16pub struct IncludeDirective {
17    pub raw_line: String,
18    pub pattern: String,
19    pub resolved_files: Vec<IncludedFile>,
20}
21
22/// A file resolved from an Include directive.
23#[derive(Debug, Clone)]
24pub struct IncludedFile {
25    pub path: PathBuf,
26    pub elements: Vec<ConfigElement>,
27}
28
29/// A single element in the config file.
30#[derive(Debug, Clone)]
31pub enum ConfigElement {
32    /// A Host block: the "Host <pattern>" line plus all indented directives.
33    HostBlock(HostBlock),
34    /// A comment, blank line, or global directive not inside a Host block.
35    GlobalLine(String),
36    /// An Include directive referencing other config files (read-only).
37    Include(IncludeDirective),
38}
39
40/// A parsed Host block with its directives.
41#[derive(Debug, Clone)]
42pub struct HostBlock {
43    /// The host alias/pattern (the value after "Host").
44    pub host_pattern: String,
45    /// The original raw "Host ..." line for faithful reproduction.
46    pub raw_host_line: String,
47    /// Parsed directives inside this block.
48    pub directives: Vec<Directive>,
49}
50
51/// A directive line inside a Host block.
52#[derive(Debug, Clone)]
53pub struct Directive {
54    /// The directive key (e.g., "HostName", "User", "Port").
55    pub key: String,
56    /// The directive value.
57    pub value: String,
58    /// The original raw line (preserves indentation, inline comments).
59    pub raw_line: String,
60    /// Whether this is a comment-only or blank line inside a host block.
61    pub is_non_directive: bool,
62}
63
64/// Convenience view for the TUI — extracted from a HostBlock.
65#[derive(Debug, Clone)]
66pub struct HostEntry {
67    pub alias: String,
68    pub hostname: String,
69    pub user: String,
70    pub port: u16,
71    pub identity_file: String,
72    pub proxy_jump: String,
73    /// If this host comes from an included file, the file path.
74    pub source_file: Option<PathBuf>,
75    /// Tags from purple:tags comment.
76    pub tags: Vec<String>,
77    /// Cloud provider label from purple:provider comment (e.g. "do", "vultr").
78    pub provider: Option<String>,
79    /// Number of tunnel forwarding directives.
80    pub tunnel_count: u16,
81    /// Password source from purple:askpass comment (e.g. "keychain", "op://...", "pass:...").
82    pub askpass: Option<String>,
83    /// Provider metadata from purple:meta comment (region, plan, etc.).
84    pub provider_meta: Vec<(String, String)>,
85}
86
87impl Default for HostEntry {
88    fn default() -> Self {
89        Self {
90            alias: String::new(),
91            hostname: String::new(),
92            user: String::new(),
93            port: 22,
94            identity_file: String::new(),
95            proxy_jump: String::new(),
96            source_file: None,
97            tags: Vec::new(),
98            provider: None,
99            tunnel_count: 0,
100            askpass: None,
101            provider_meta: Vec::new(),
102        }
103    }
104}
105
106impl HostEntry {
107    /// Build the SSH command string for this host.
108    /// Includes `-F <config_path>` when the config is non-default so the alias
109    /// resolves correctly when pasted into a terminal.
110    /// Shell-quotes both the config path and alias to prevent injection.
111    pub fn ssh_command(&self, config_path: &std::path::Path) -> String {
112        let escaped = self.alias.replace('\'', "'\\''");
113        let default = dirs::home_dir()
114            .map(|h| h.join(".ssh/config"))
115            .unwrap_or_default();
116        if config_path == default {
117            format!("ssh -- '{}'", escaped)
118        } else {
119            let config_escaped = config_path.display().to_string().replace('\'', "'\\''");
120            format!("ssh -F '{}' -- '{}'", config_escaped, escaped)
121        }
122    }
123}
124
125/// Returns true if the host pattern contains wildcards, character classes,
126/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
127/// These are SSH match patterns, not concrete hosts.
128pub fn is_host_pattern(pattern: &str) -> bool {
129    pattern.contains('*')
130        || pattern.contains('?')
131        || pattern.contains('[')
132        || pattern.starts_with('!')
133        || pattern.contains(' ')
134        || pattern.contains('\t')
135}
136
137impl HostBlock {
138    /// Index of the first trailing blank line (for inserting content before separators).
139    fn content_end(&self) -> usize {
140        let mut pos = self.directives.len();
141        while pos > 0 {
142            if self.directives[pos - 1].is_non_directive
143                && self.directives[pos - 1].raw_line.trim().is_empty()
144            {
145                pos -= 1;
146            } else {
147                break;
148            }
149        }
150        pos
151    }
152
153    /// Remove and return trailing blank lines.
154    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
155        let end = self.content_end();
156        self.directives.drain(end..).collect()
157    }
158
159    /// Ensure exactly one trailing blank line.
160    fn ensure_trailing_blank(&mut self) {
161        self.pop_trailing_blanks();
162        self.directives.push(Directive {
163            key: String::new(),
164            value: String::new(),
165            raw_line: String::new(),
166            is_non_directive: true,
167        });
168    }
169
170    /// Detect indentation used by existing directives (falls back to "  ").
171    fn detect_indent(&self) -> String {
172        for d in &self.directives {
173            if !d.is_non_directive && !d.raw_line.is_empty() {
174                let trimmed = d.raw_line.trim_start();
175                let indent_len = d.raw_line.len() - trimmed.len();
176                if indent_len > 0 {
177                    return d.raw_line[..indent_len].to_string();
178                }
179            }
180        }
181        "  ".to_string()
182    }
183
184    /// Extract tags from purple:tags comment in directives.
185    pub fn tags(&self) -> Vec<String> {
186        for d in &self.directives {
187            if d.is_non_directive {
188                let trimmed = d.raw_line.trim();
189                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
190                    return rest
191                        .split(',')
192                        .map(|t| t.trim().to_string())
193                        .filter(|t| !t.is_empty())
194                        .collect();
195                }
196            }
197        }
198        Vec::new()
199    }
200
201    /// Extract provider info from purple:provider comment in directives.
202    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
203    pub fn provider(&self) -> Option<(String, String)> {
204        for d in &self.directives {
205            if d.is_non_directive {
206                let trimmed = d.raw_line.trim();
207                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
208                    if let Some((name, id)) = rest.split_once(':') {
209                        return Some((name.trim().to_string(), id.trim().to_string()));
210                    }
211                }
212            }
213        }
214        None
215    }
216
217    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
218    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
219        let indent = self.detect_indent();
220        self.directives.retain(|d| {
221            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
222        });
223        let pos = self.content_end();
224        self.directives.insert(
225            pos,
226            Directive {
227                key: String::new(),
228                value: String::new(),
229                raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
230                is_non_directive: true,
231            },
232        );
233    }
234
235    /// Extract askpass source from purple:askpass comment in directives.
236    pub fn askpass(&self) -> Option<String> {
237        for d in &self.directives {
238            if d.is_non_directive {
239                let trimmed = d.raw_line.trim();
240                if let Some(rest) = trimmed.strip_prefix("# purple:askpass ") {
241                    let val = rest.trim();
242                    if !val.is_empty() {
243                        return Some(val.to_string());
244                    }
245                }
246            }
247        }
248        None
249    }
250
251    /// Set askpass source on a host block. Replaces existing purple:askpass comment or adds one.
252    /// Pass an empty string to remove the comment.
253    pub fn set_askpass(&mut self, source: &str) {
254        let indent = self.detect_indent();
255        self.directives.retain(|d| {
256            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:askpass"))
257        });
258        if !source.is_empty() {
259            let pos = self.content_end();
260            self.directives.insert(
261                pos,
262                Directive {
263                    key: String::new(),
264                    value: String::new(),
265                    raw_line: format!("{}# purple:askpass {}", indent, source),
266                    is_non_directive: true,
267                },
268            );
269        }
270    }
271
272    /// Extract provider metadata from purple:meta comment in directives.
273    /// Format: `# purple:meta key=value,key=value`
274    pub fn meta(&self) -> Vec<(String, String)> {
275        for d in &self.directives {
276            if d.is_non_directive {
277                let trimmed = d.raw_line.trim();
278                if let Some(rest) = trimmed.strip_prefix("# purple:meta ") {
279                    return rest
280                        .split(',')
281                        .filter_map(|pair| {
282                            let (k, v) = pair.split_once('=')?;
283                            let k = k.trim();
284                            let v = v.trim();
285                            if k.is_empty() {
286                                None
287                            } else {
288                                Some((k.to_string(), v.to_string()))
289                            }
290                        })
291                        .collect();
292                }
293            }
294        }
295        Vec::new()
296    }
297
298    /// Set provider metadata on a host block. Replaces existing purple:meta comment or adds one.
299    /// Pass an empty slice to remove the comment.
300    pub fn set_meta(&mut self, meta: &[(String, String)]) {
301        let indent = self.detect_indent();
302        self.directives.retain(|d| {
303            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:meta"))
304        });
305        if !meta.is_empty() {
306            let encoded: Vec<String> = meta
307                .iter()
308                .map(|(k, v)| {
309                    let clean_k = k.replace([',', '='], "");
310                    let clean_v = v.replace(',', "");
311                    format!("{}={}", clean_k, clean_v)
312                })
313                .collect();
314            let pos = self.content_end();
315            self.directives.insert(
316                pos,
317                Directive {
318                    key: String::new(),
319                    value: String::new(),
320                    raw_line: format!("{}# purple:meta {}", indent, encoded.join(",")),
321                    is_non_directive: true,
322                },
323            );
324        }
325    }
326
327    /// Set tags on a host block. Replaces existing purple:tags comment or adds one.
328    pub fn set_tags(&mut self, tags: &[String]) {
329        let indent = self.detect_indent();
330        self.directives.retain(|d| {
331            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
332        });
333        if !tags.is_empty() {
334            let pos = self.content_end();
335            self.directives.insert(
336                pos,
337                Directive {
338                    key: String::new(),
339                    value: String::new(),
340                    raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
341                    is_non_directive: true,
342                },
343            );
344        }
345    }
346
347    /// Extract a convenience HostEntry view from this block.
348    pub fn to_host_entry(&self) -> HostEntry {
349        let mut entry = HostEntry {
350            alias: self.host_pattern.clone(),
351            port: 22,
352            ..Default::default()
353        };
354        for d in &self.directives {
355            if d.is_non_directive {
356                continue;
357            }
358            if d.key.eq_ignore_ascii_case("hostname") {
359                entry.hostname = d.value.clone();
360            } else if d.key.eq_ignore_ascii_case("user") {
361                entry.user = d.value.clone();
362            } else if d.key.eq_ignore_ascii_case("port") {
363                entry.port = d.value.parse().unwrap_or(22);
364            } else if d.key.eq_ignore_ascii_case("identityfile") {
365                if entry.identity_file.is_empty() {
366                    entry.identity_file = d.value.clone();
367                }
368            } else if d.key.eq_ignore_ascii_case("proxyjump") {
369                entry.proxy_jump = d.value.clone();
370            }
371        }
372        entry.tags = self.tags();
373        entry.provider = self.provider().map(|(name, _)| name);
374        entry.tunnel_count = self.tunnel_count();
375        entry.askpass = self.askpass();
376        entry.provider_meta = self.meta();
377        entry
378    }
379
380    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
381    pub fn tunnel_count(&self) -> u16 {
382        let count = self
383            .directives
384            .iter()
385            .filter(|d| {
386                !d.is_non_directive
387                    && (d.key.eq_ignore_ascii_case("localforward")
388                        || d.key.eq_ignore_ascii_case("remoteforward")
389                        || d.key.eq_ignore_ascii_case("dynamicforward"))
390            })
391            .count();
392        count.min(u16::MAX as usize) as u16
393    }
394
395    /// Check if this block has any tunnel forwarding directives.
396    #[allow(dead_code)]
397    pub fn has_tunnels(&self) -> bool {
398        self.directives.iter().any(|d| {
399            !d.is_non_directive
400                && (d.key.eq_ignore_ascii_case("localforward")
401                    || d.key.eq_ignore_ascii_case("remoteforward")
402                    || d.key.eq_ignore_ascii_case("dynamicforward"))
403        })
404    }
405
406    /// Extract tunnel rules from forwarding directives.
407    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
408        self.directives
409            .iter()
410            .filter(|d| !d.is_non_directive)
411            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
412            .collect()
413    }
414}
415
416impl SshConfigFile {
417    /// Get all host entries as convenience views (including from Include files).
418    pub fn host_entries(&self) -> Vec<HostEntry> {
419        let mut entries = Vec::new();
420        Self::collect_host_entries(&self.elements, &mut entries);
421        entries
422    }
423
424    /// Collect all resolved Include file paths (recursively).
425    pub fn include_paths(&self) -> Vec<PathBuf> {
426        let mut paths = Vec::new();
427        Self::collect_include_paths(&self.elements, &mut paths);
428        paths
429    }
430
431    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
432        for e in elements {
433            if let ConfigElement::Include(include) = e {
434                for file in &include.resolved_files {
435                    paths.push(file.path.clone());
436                    Self::collect_include_paths(&file.elements, paths);
437                }
438            }
439        }
440    }
441
442    /// Collect parent directories of Include glob patterns.
443    /// When a file is added/removed under a glob dir, the directory's mtime changes.
444    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
445        let config_dir = self.path.parent();
446        let mut seen = std::collections::HashSet::new();
447        let mut dirs = Vec::new();
448        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
449        dirs
450    }
451
452    fn collect_include_glob_dirs(
453        elements: &[ConfigElement],
454        config_dir: Option<&std::path::Path>,
455        seen: &mut std::collections::HashSet<PathBuf>,
456        dirs: &mut Vec<PathBuf>,
457    ) {
458        for e in elements {
459            if let ConfigElement::Include(include) = e {
460                // Split on whitespace to handle multi-pattern Includes
461                // (same as resolve_include does)
462                for single in include.pattern.split_whitespace() {
463                    let expanded = Self::expand_tilde(single);
464                    let resolved = if expanded.starts_with('/') {
465                        PathBuf::from(&expanded)
466                    } else if let Some(dir) = config_dir {
467                        dir.join(&expanded)
468                    } else {
469                        continue;
470                    };
471                    if let Some(parent) = resolved.parent() {
472                        let parent = parent.to_path_buf();
473                        if seen.insert(parent.clone()) {
474                            dirs.push(parent);
475                        }
476                    }
477                }
478                // Recurse into resolved files
479                for file in &include.resolved_files {
480                    Self::collect_include_glob_dirs(
481                        &file.elements,
482                        file.path.parent(),
483                        seen,
484                        dirs,
485                    );
486                }
487            }
488        }
489    }
490
491
492    /// Recursively collect host entries from a list of elements.
493    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
494        for e in elements {
495            match e {
496                ConfigElement::HostBlock(block) => {
497                    if is_host_pattern(&block.host_pattern) {
498                        continue;
499                    }
500                    entries.push(block.to_host_entry());
501                }
502                ConfigElement::Include(include) => {
503                    for file in &include.resolved_files {
504                        let start = entries.len();
505                        Self::collect_host_entries(&file.elements, entries);
506                        for entry in &mut entries[start..] {
507                            if entry.source_file.is_none() {
508                                entry.source_file = Some(file.path.clone());
509                            }
510                        }
511                    }
512                }
513                ConfigElement::GlobalLine(_) => {}
514            }
515        }
516    }
517
518    /// Check if a host alias already exists (including in Include files).
519    /// Walks the element tree directly without building HostEntry structs.
520    pub fn has_host(&self, alias: &str) -> bool {
521        Self::has_host_in_elements(&self.elements, alias)
522    }
523
524    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
525        for e in elements {
526            match e {
527                ConfigElement::HostBlock(block) => {
528                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
529                        return true;
530                    }
531                }
532                ConfigElement::Include(include) => {
533                    for file in &include.resolved_files {
534                        if Self::has_host_in_elements(&file.elements, alias) {
535                            return true;
536                        }
537                    }
538                }
539                ConfigElement::GlobalLine(_) => {}
540            }
541        }
542        false
543    }
544
545    /// Check if a host alias is from an included file (read-only).
546    /// Handles multi-pattern Host lines by splitting on whitespace.
547    pub fn is_included_host(&self, alias: &str) -> bool {
548        // Not in top-level elements → must be in an Include
549        for e in &self.elements {
550            match e {
551                ConfigElement::HostBlock(block) => {
552                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
553                        return false;
554                    }
555                }
556                ConfigElement::Include(include) => {
557                    for file in &include.resolved_files {
558                        if Self::has_host_in_elements(&file.elements, alias) {
559                            return true;
560                        }
561                    }
562                }
563                ConfigElement::GlobalLine(_) => {}
564            }
565        }
566        false
567    }
568
569    /// Add a new host entry to the config.
570    pub fn add_host(&mut self, entry: &HostEntry) {
571        let block = Self::entry_to_block(entry);
572        // Add a blank line separator if the file isn't empty and doesn't already end with one
573        if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
574            self.elements
575                .push(ConfigElement::GlobalLine(String::new()));
576        }
577        self.elements.push(ConfigElement::HostBlock(block));
578    }
579
580    /// Check if the last element already ends with a blank line.
581    pub fn last_element_has_trailing_blank(&self) -> bool {
582        match self.elements.last() {
583            Some(ConfigElement::HostBlock(block)) => block
584                .directives
585                .last()
586                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
587            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
588            _ => false,
589        }
590    }
591
592    /// Update an existing host entry by alias.
593    /// Merges changes into the existing block, preserving unknown directives.
594    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
595        for element in &mut self.elements {
596            if let ConfigElement::HostBlock(block) = element {
597                if block.host_pattern == old_alias {
598                    // Update host pattern (preserve raw_host_line when alias unchanged)
599                    if entry.alias != block.host_pattern {
600                        block.host_pattern = entry.alias.clone();
601                        block.raw_host_line = format!("Host {}", entry.alias);
602                    }
603
604                    // Merge known directives (update existing, add missing, remove empty)
605                    Self::upsert_directive(block, "HostName", &entry.hostname);
606                    Self::upsert_directive(block, "User", &entry.user);
607                    if entry.port != 22 {
608                        Self::upsert_directive(block, "Port", &entry.port.to_string());
609                    } else {
610                        // Remove explicit Port 22 (it's the default)
611                        block
612                            .directives
613                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
614                    }
615                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
616                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
617                    return;
618                }
619            }
620        }
621    }
622
623    /// Update a directive in-place, add it if missing, or remove it if value is empty.
624    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
625        if value.is_empty() {
626            block
627                .directives
628                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
629            return;
630        }
631        let indent = block.detect_indent();
632        for d in &mut block.directives {
633            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
634                // Only rebuild raw_line when value actually changed (preserves inline comments)
635                if d.value != value {
636                    d.value = value.to_string();
637                    // Detect separator style from original raw_line and preserve it.
638                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
639                    // Only considers '=' as separator if it appears before any
640                    // non-whitespace content (avoids matching '=' inside values
641                    // like "IdentityFile ~/.ssh/id=prod").
642                    let trimmed = d.raw_line.trim_start();
643                    let after_key = &trimmed[d.key.len()..];
644                    let sep = if after_key.trim_start().starts_with('=') {
645                        let eq_pos = after_key.find('=').unwrap();
646                        let after_eq = &after_key[eq_pos + 1..];
647                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
648                        after_key[..eq_pos + 1 + trailing_ws].to_string()
649                    } else {
650                        " ".to_string()
651                    };
652                    d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
653                }
654                return;
655            }
656        }
657        // Not found — insert before trailing blanks
658        let pos = block.content_end();
659        block.directives.insert(
660            pos,
661            Directive {
662                key: key.to_string(),
663                value: value.to_string(),
664                raw_line: format!("{}{} {}", indent, key, value),
665                is_non_directive: false,
666            },
667        );
668    }
669
670    /// Set provider on a host block by alias.
671    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
672        for element in &mut self.elements {
673            if let ConfigElement::HostBlock(block) = element {
674                if block.host_pattern == alias {
675                    block.set_provider(provider_name, server_id);
676                    return;
677                }
678            }
679        }
680    }
681
682    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
683    /// Searches both top-level elements and Include files so that provider hosts
684    /// in included configs are recognized during sync (prevents duplicate additions).
685    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
686        let mut results = Vec::new();
687        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
688        results
689    }
690
691    fn collect_provider_hosts(
692        elements: &[ConfigElement],
693        provider_name: &str,
694        results: &mut Vec<(String, String)>,
695    ) {
696        for element in elements {
697            match element {
698                ConfigElement::HostBlock(block) => {
699                    if let Some((name, id)) = block.provider() {
700                        if name == provider_name {
701                            results.push((block.host_pattern.clone(), id));
702                        }
703                    }
704                }
705                ConfigElement::Include(include) => {
706                    for file in &include.resolved_files {
707                        Self::collect_provider_hosts(&file.elements, provider_name, results);
708                    }
709                }
710                ConfigElement::GlobalLine(_) => {}
711            }
712        }
713    }
714
715    /// Compare two directive values with whitespace normalization.
716    /// Handles hand-edited configs with tabs or multiple spaces.
717    fn values_match(a: &str, b: &str) -> bool {
718        a.split_whitespace().eq(b.split_whitespace())
719    }
720
721    /// Add a forwarding directive to a host block.
722    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
723    /// Uses split_whitespace matching for multi-pattern Host lines.
724    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
725        for element in &mut self.elements {
726            if let ConfigElement::HostBlock(block) = element {
727                if block.host_pattern.split_whitespace().any(|p| p == alias) {
728                    let indent = block.detect_indent();
729                    let pos = block.content_end();
730                    block.directives.insert(
731                        pos,
732                        Directive {
733                            key: directive_key.to_string(),
734                            value: value.to_string(),
735                            raw_line: format!("{}{} {}", indent, directive_key, value),
736                            is_non_directive: false,
737                        },
738                    );
739                    return;
740                }
741            }
742        }
743    }
744
745    /// Remove a specific forwarding directive from a host block.
746    /// Matches key (case-insensitive) and value (whitespace-normalized).
747    /// Uses split_whitespace matching for multi-pattern Host lines.
748    /// Returns true if a directive was actually removed.
749    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
750        for element in &mut self.elements {
751            if let ConfigElement::HostBlock(block) = element {
752                if block.host_pattern.split_whitespace().any(|p| p == alias) {
753                    if let Some(pos) = block.directives.iter().position(|d| {
754                        !d.is_non_directive
755                            && d.key.eq_ignore_ascii_case(directive_key)
756                            && Self::values_match(&d.value, value)
757                    }) {
758                        block.directives.remove(pos);
759                        return true;
760                    }
761                    return false;
762                }
763            }
764        }
765        false
766    }
767
768    /// Check if a host block has a specific forwarding directive.
769    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
770    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
771        for element in &self.elements {
772            if let ConfigElement::HostBlock(block) = element {
773                if block.host_pattern.split_whitespace().any(|p| p == alias) {
774                    return block.directives.iter().any(|d| {
775                        !d.is_non_directive
776                            && d.key.eq_ignore_ascii_case(directive_key)
777                            && Self::values_match(&d.value, value)
778                    });
779                }
780            }
781        }
782        false
783    }
784
785    /// Find tunnel directives for a host alias, searching all elements including
786    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
787    /// Host lines.
788    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
789        Self::find_tunnel_directives_in(&self.elements, alias)
790    }
791
792    fn find_tunnel_directives_in(
793        elements: &[ConfigElement],
794        alias: &str,
795    ) -> Vec<crate::tunnel::TunnelRule> {
796        for element in elements {
797            match element {
798                ConfigElement::HostBlock(block) => {
799                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
800                        return block.tunnel_directives();
801                    }
802                }
803                ConfigElement::Include(include) => {
804                    for file in &include.resolved_files {
805                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
806                        if !rules.is_empty() {
807                            return rules;
808                        }
809                    }
810                }
811                ConfigElement::GlobalLine(_) => {}
812            }
813        }
814        Vec::new()
815    }
816
817    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
818    pub fn deduplicate_alias(&self, base: &str) -> String {
819        self.deduplicate_alias_excluding(base, None)
820    }
821
822    /// Generate a unique alias, optionally excluding one alias from collision detection.
823    /// Used during rename so the host being renamed doesn't collide with itself.
824    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
825        let is_taken = |alias: &str| {
826            if exclude == Some(alias) {
827                return false;
828            }
829            self.has_host(alias)
830        };
831        if !is_taken(base) {
832            return base.to_string();
833        }
834        for n in 2..=9999 {
835            let candidate = format!("{}-{}", base, n);
836            if !is_taken(&candidate) {
837                return candidate;
838            }
839        }
840        // Practically unreachable: fall back to PID-based suffix
841        format!("{}-{}", base, std::process::id())
842    }
843
844    /// Set tags on a host block by alias.
845    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
846        for element in &mut self.elements {
847            if let ConfigElement::HostBlock(block) = element {
848                if block.host_pattern == alias {
849                    block.set_tags(tags);
850                    return;
851                }
852            }
853        }
854    }
855
856    /// Set askpass source on a host block by alias.
857    pub fn set_host_askpass(&mut self, alias: &str, source: &str) {
858        for element in &mut self.elements {
859            if let ConfigElement::HostBlock(block) = element {
860                if block.host_pattern == alias {
861                    block.set_askpass(source);
862                    return;
863                }
864            }
865        }
866    }
867
868    /// Set provider metadata on a host block by alias.
869    pub fn set_host_meta(&mut self, alias: &str, meta: &[(String, String)]) {
870        for element in &mut self.elements {
871            if let ConfigElement::HostBlock(block) = element {
872                if block.host_pattern == alias {
873                    block.set_meta(meta);
874                    return;
875                }
876            }
877        }
878    }
879
880    /// Delete a host entry by alias.
881    #[allow(dead_code)]
882    pub fn delete_host(&mut self, alias: &str) {
883        self.elements.retain(|e| match e {
884            ConfigElement::HostBlock(block) => block.host_pattern != alias,
885            _ => true,
886        });
887        // Collapse consecutive blank lines left by deletion
888        self.elements.dedup_by(|a, b| {
889            matches!(
890                (&*a, &*b),
891                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
892                if x.trim().is_empty() && y.trim().is_empty()
893            )
894        });
895    }
896
897    /// Delete a host and return the removed element and its position for undo.
898    /// Does NOT collapse blank lines so the position stays valid for re-insertion.
899    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
900        let pos = self.elements.iter().position(|e| {
901            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
902        })?;
903        let element = self.elements.remove(pos);
904        Some((element, pos))
905    }
906
907    /// Insert a host block at a specific position (for undo).
908    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
909        let pos = position.min(self.elements.len());
910        self.elements.insert(pos, element);
911    }
912
913    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
914    #[allow(dead_code)]
915    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
916        let pos_a = self.elements.iter().position(|e| {
917            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
918        });
919        let pos_b = self.elements.iter().position(|e| {
920            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
921        });
922        if let (Some(a), Some(b)) = (pos_a, pos_b) {
923            if a == b {
924                return false;
925            }
926            let (first, second) = (a.min(b), a.max(b));
927
928            // Strip trailing blanks from both blocks before swap
929            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
930                block.pop_trailing_blanks();
931            }
932            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
933                block.pop_trailing_blanks();
934            }
935
936            // Swap
937            self.elements.swap(first, second);
938
939            // Add trailing blank to first block (separator between the two)
940            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
941                block.ensure_trailing_blank();
942            }
943
944            // Add trailing blank to second only if not the last element
945            if second < self.elements.len() - 1 {
946                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
947                    block.ensure_trailing_blank();
948                }
949            }
950
951            return true;
952        }
953        false
954    }
955
956    /// Convert a HostEntry into a new HostBlock with clean formatting.
957    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
958        let mut directives = Vec::new();
959
960        if !entry.hostname.is_empty() {
961            directives.push(Directive {
962                key: "HostName".to_string(),
963                value: entry.hostname.clone(),
964                raw_line: format!("  HostName {}", entry.hostname),
965                is_non_directive: false,
966            });
967        }
968        if !entry.user.is_empty() {
969            directives.push(Directive {
970                key: "User".to_string(),
971                value: entry.user.clone(),
972                raw_line: format!("  User {}", entry.user),
973                is_non_directive: false,
974            });
975        }
976        if entry.port != 22 {
977            directives.push(Directive {
978                key: "Port".to_string(),
979                value: entry.port.to_string(),
980                raw_line: format!("  Port {}", entry.port),
981                is_non_directive: false,
982            });
983        }
984        if !entry.identity_file.is_empty() {
985            directives.push(Directive {
986                key: "IdentityFile".to_string(),
987                value: entry.identity_file.clone(),
988                raw_line: format!("  IdentityFile {}", entry.identity_file),
989                is_non_directive: false,
990            });
991        }
992        if !entry.proxy_jump.is_empty() {
993            directives.push(Directive {
994                key: "ProxyJump".to_string(),
995                value: entry.proxy_jump.clone(),
996                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
997                is_non_directive: false,
998            });
999        }
1000
1001        HostBlock {
1002            host_pattern: entry.alias.clone(),
1003            raw_host_line: format!("Host {}", entry.alias),
1004            directives,
1005        }
1006    }
1007}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    fn parse_str(content: &str) -> SshConfigFile {
1014        SshConfigFile {
1015            elements: SshConfigFile::parse_content(content),
1016            path: PathBuf::from("/tmp/test_config"),
1017            crlf: false,
1018        }
1019    }
1020
1021    #[test]
1022    fn tunnel_directives_extracts_forwards() {
1023        let config = parse_str(
1024            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1025        );
1026        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1027            let rules = block.tunnel_directives();
1028            assert_eq!(rules.len(), 3);
1029            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1030            assert_eq!(rules[0].bind_port, 8080);
1031            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1032            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1033        } else {
1034            panic!("Expected HostBlock");
1035        }
1036    }
1037
1038    #[test]
1039    fn tunnel_count_counts_forwards() {
1040        let config = parse_str(
1041            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
1042        );
1043        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1044            assert_eq!(block.tunnel_count(), 2);
1045        } else {
1046            panic!("Expected HostBlock");
1047        }
1048    }
1049
1050    #[test]
1051    fn tunnel_count_zero_for_no_forwards() {
1052        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1053        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1054            assert_eq!(block.tunnel_count(), 0);
1055            assert!(!block.has_tunnels());
1056        } else {
1057            panic!("Expected HostBlock");
1058        }
1059    }
1060
1061    #[test]
1062    fn has_tunnels_true_with_forward() {
1063        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
1064        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1065            assert!(block.has_tunnels());
1066        } else {
1067            panic!("Expected HostBlock");
1068        }
1069    }
1070
1071    #[test]
1072    fn add_forward_inserts_directive() {
1073        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
1074        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1075        let output = config.serialize();
1076        assert!(output.contains("LocalForward 8080 localhost:80"));
1077        // Existing directives preserved
1078        assert!(output.contains("HostName 10.0.0.1"));
1079        assert!(output.contains("User admin"));
1080    }
1081
1082    #[test]
1083    fn add_forward_preserves_indentation() {
1084        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1085        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1086        let output = config.serialize();
1087        assert!(output.contains("\tLocalForward 8080 localhost:80"));
1088    }
1089
1090    #[test]
1091    fn add_multiple_forwards_same_type() {
1092        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1093        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1094        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
1095        let output = config.serialize();
1096        assert!(output.contains("LocalForward 8080 localhost:80"));
1097        assert!(output.contains("LocalForward 9090 localhost:90"));
1098    }
1099
1100    #[test]
1101    fn remove_forward_removes_exact_match() {
1102        let mut config = parse_str(
1103            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1104        );
1105        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1106        let output = config.serialize();
1107        assert!(!output.contains("8080 localhost:80"));
1108        assert!(output.contains("9090 localhost:90"));
1109    }
1110
1111    #[test]
1112    fn remove_forward_leaves_other_directives() {
1113        let mut config = parse_str(
1114            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
1115        );
1116        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
1117        let output = config.serialize();
1118        assert!(!output.contains("LocalForward"));
1119        assert!(output.contains("HostName 10.0.0.1"));
1120        assert!(output.contains("User admin"));
1121    }
1122
1123    #[test]
1124    fn remove_forward_no_match_is_noop() {
1125        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
1126        let mut config = parse_str(original);
1127        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
1128        assert_eq!(config.serialize(), original);
1129    }
1130
1131    #[test]
1132    fn host_entry_tunnel_count_populated() {
1133        let config = parse_str(
1134            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
1135        );
1136        let entries = config.host_entries();
1137        assert_eq!(entries.len(), 1);
1138        assert_eq!(entries[0].tunnel_count, 2);
1139    }
1140
1141    #[test]
1142    fn remove_forward_returns_true_on_match() {
1143        let mut config = parse_str(
1144            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1145        );
1146        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1147    }
1148
1149    #[test]
1150    fn remove_forward_returns_false_on_no_match() {
1151        let mut config = parse_str(
1152            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1153        );
1154        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1155    }
1156
1157    #[test]
1158    fn remove_forward_returns_false_for_unknown_host() {
1159        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1160        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1161    }
1162
1163    #[test]
1164    fn has_forward_finds_match() {
1165        let config = parse_str(
1166            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1167        );
1168        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1169    }
1170
1171    #[test]
1172    fn has_forward_no_match() {
1173        let config = parse_str(
1174            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1175        );
1176        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1177        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1178    }
1179
1180    #[test]
1181    fn has_forward_case_insensitive_key() {
1182        let config = parse_str(
1183            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1184        );
1185        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1186    }
1187
1188    #[test]
1189    fn add_forward_to_empty_block() {
1190        let mut config = parse_str("Host myserver\n");
1191        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1192        let output = config.serialize();
1193        assert!(output.contains("LocalForward 8080 localhost:80"));
1194    }
1195
1196    #[test]
1197    fn remove_forward_case_insensitive_key_match() {
1198        let mut config = parse_str(
1199            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1200        );
1201        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1202        assert!(!config.serialize().contains("localforward"));
1203    }
1204
1205    #[test]
1206    fn tunnel_count_case_insensitive() {
1207        let config = parse_str(
1208            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
1209        );
1210        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1211            assert_eq!(block.tunnel_count(), 3);
1212        } else {
1213            panic!("Expected HostBlock");
1214        }
1215    }
1216
1217    #[test]
1218    fn tunnel_directives_extracts_all_types() {
1219        let config = parse_str(
1220            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1221        );
1222        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1223            let rules = block.tunnel_directives();
1224            assert_eq!(rules.len(), 3);
1225            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1226            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1227            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1228        } else {
1229            panic!("Expected HostBlock");
1230        }
1231    }
1232
1233    #[test]
1234    fn tunnel_directives_skips_malformed() {
1235        let config = parse_str(
1236            "Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n",
1237        );
1238        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1239            let rules = block.tunnel_directives();
1240            assert_eq!(rules.len(), 1);
1241            assert_eq!(rules[0].bind_port, 1080);
1242        } else {
1243            panic!("Expected HostBlock");
1244        }
1245    }
1246
1247    #[test]
1248    fn find_tunnel_directives_multi_pattern_host() {
1249        let config = parse_str(
1250            "Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1251        );
1252        let rules = config.find_tunnel_directives("prod");
1253        assert_eq!(rules.len(), 1);
1254        assert_eq!(rules[0].bind_port, 8080);
1255        let rules2 = config.find_tunnel_directives("staging");
1256        assert_eq!(rules2.len(), 1);
1257    }
1258
1259    #[test]
1260    fn find_tunnel_directives_no_match() {
1261        let config = parse_str(
1262            "Host myserver\n  LocalForward 8080 localhost:80\n",
1263        );
1264        let rules = config.find_tunnel_directives("nohost");
1265        assert!(rules.is_empty());
1266    }
1267
1268    #[test]
1269    fn has_forward_exact_match() {
1270        let config = parse_str(
1271            "Host myserver\n  LocalForward 8080 localhost:80\n",
1272        );
1273        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1274        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1275        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1276        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1277    }
1278
1279    #[test]
1280    fn has_forward_whitespace_normalized() {
1281        let config = parse_str(
1282            "Host myserver\n  LocalForward 8080  localhost:80\n",
1283        );
1284        // Extra space in config value vs single space in query — should still match
1285        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1286    }
1287
1288    #[test]
1289    fn has_forward_multi_pattern_host() {
1290        let config = parse_str(
1291            "Host prod staging\n  LocalForward 8080 localhost:80\n",
1292        );
1293        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1294        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1295    }
1296
1297    #[test]
1298    fn add_forward_multi_pattern_host() {
1299        let mut config = parse_str(
1300            "Host prod staging\n  HostName 10.0.0.1\n",
1301        );
1302        config.add_forward("prod", "LocalForward", "8080 localhost:80");
1303        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1304        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1305    }
1306
1307    #[test]
1308    fn remove_forward_multi_pattern_host() {
1309        let mut config = parse_str(
1310            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1311        );
1312        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1313        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1314        // Other forward should remain
1315        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1316    }
1317
1318    #[test]
1319    fn edit_tunnel_detects_duplicate_after_remove() {
1320        // Simulates edit flow: remove old, then check if new value already exists
1321        let mut config = parse_str(
1322            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1323        );
1324        // Edit rule A (8080) toward rule B (9090): remove A first
1325        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1326        // Now check if the target value already exists — should detect duplicate
1327        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1328    }
1329
1330    #[test]
1331    fn has_forward_tab_whitespace_normalized() {
1332        let config = parse_str(
1333            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1334        );
1335        // Tab in config value vs space in query — should match via values_match
1336        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1337    }
1338
1339    #[test]
1340    fn remove_forward_tab_whitespace_normalized() {
1341        let mut config = parse_str(
1342            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1343        );
1344        // Remove with single space should match tab-separated value
1345        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1346        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1347    }
1348
1349    #[test]
1350    fn upsert_preserves_space_separator_when_value_contains_equals() {
1351        let mut config = parse_str(
1352            "Host myserver\n  IdentityFile ~/.ssh/id=prod\n",
1353        );
1354        let entry = HostEntry {
1355            alias: "myserver".to_string(),
1356            hostname: "10.0.0.1".to_string(),
1357            identity_file: "~/.ssh/id=staging".to_string(),
1358            port: 22,
1359            ..Default::default()
1360        };
1361        config.update_host("myserver", &entry);
1362        let output = config.serialize();
1363        // Separator should remain a space, not pick up the = from the value
1364        assert!(output.contains("  IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1365        assert!(!output.contains("IdentityFile="), "got: {}", output);
1366    }
1367
1368    #[test]
1369    fn upsert_preserves_equals_separator() {
1370        let mut config = parse_str(
1371            "Host myserver\n  IdentityFile=~/.ssh/id_rsa\n",
1372        );
1373        let entry = HostEntry {
1374            alias: "myserver".to_string(),
1375            hostname: "10.0.0.1".to_string(),
1376            identity_file: "~/.ssh/id_ed25519".to_string(),
1377            port: 22,
1378            ..Default::default()
1379        };
1380        config.update_host("myserver", &entry);
1381        let output = config.serialize();
1382        assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1383    }
1384
1385    #[test]
1386    fn upsert_preserves_spaced_equals_separator() {
1387        let mut config = parse_str(
1388            "Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n",
1389        );
1390        let entry = HostEntry {
1391            alias: "myserver".to_string(),
1392            hostname: "10.0.0.1".to_string(),
1393            identity_file: "~/.ssh/id_ed25519".to_string(),
1394            port: 22,
1395            ..Default::default()
1396        };
1397        config.update_host("myserver", &entry);
1398        let output = config.serialize();
1399        assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1400    }
1401
1402    #[test]
1403    fn is_included_host_false_for_main_config() {
1404        let config = parse_str(
1405            "Host myserver\n  HostName 10.0.0.1\n",
1406        );
1407        assert!(!config.is_included_host("myserver"));
1408    }
1409
1410    #[test]
1411    fn is_included_host_false_for_nonexistent() {
1412        let config = parse_str(
1413            "Host myserver\n  HostName 10.0.0.1\n",
1414        );
1415        assert!(!config.is_included_host("nohost"));
1416    }
1417
1418    #[test]
1419    fn is_included_host_multi_pattern_main_config() {
1420        let config = parse_str(
1421            "Host prod staging\n  HostName 10.0.0.1\n",
1422        );
1423        assert!(!config.is_included_host("prod"));
1424        assert!(!config.is_included_host("staging"));
1425    }
1426
1427    // =========================================================================
1428    // HostBlock::askpass() and set_askpass() tests
1429    // =========================================================================
1430
1431    fn first_block(config: &SshConfigFile) -> &HostBlock {
1432        match config.elements.first().unwrap() {
1433            ConfigElement::HostBlock(b) => b,
1434            _ => panic!("Expected HostBlock"),
1435        }
1436    }
1437
1438    fn block_by_index(config: &SshConfigFile, idx: usize) -> &HostBlock {
1439        let mut count = 0;
1440        for el in &config.elements {
1441            if let ConfigElement::HostBlock(b) = el {
1442                if count == idx {
1443                    return b;
1444                }
1445                count += 1;
1446            }
1447        }
1448        panic!("No HostBlock at index {}", idx);
1449    }
1450
1451    #[test]
1452    fn askpass_returns_none_when_absent() {
1453        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1454        assert_eq!(first_block(&config).askpass(), None);
1455    }
1456
1457    #[test]
1458    fn askpass_returns_keychain() {
1459        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1460        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1461    }
1462
1463    #[test]
1464    fn askpass_returns_op_uri() {
1465        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass op://Vault/Item/field\n");
1466        assert_eq!(first_block(&config).askpass(), Some("op://Vault/Item/field".to_string()));
1467    }
1468
1469    #[test]
1470    fn askpass_returns_vault_with_field() {
1471        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:secret/ssh#password\n");
1472        assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh#password".to_string()));
1473    }
1474
1475    #[test]
1476    fn askpass_returns_bw_source() {
1477        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:my-item\n");
1478        assert_eq!(first_block(&config).askpass(), Some("bw:my-item".to_string()));
1479    }
1480
1481    #[test]
1482    fn askpass_returns_pass_source() {
1483        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass pass:ssh/prod\n");
1484        assert_eq!(first_block(&config).askpass(), Some("pass:ssh/prod".to_string()));
1485    }
1486
1487    #[test]
1488    fn askpass_returns_custom_command() {
1489        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass get-pass %a %h\n");
1490        assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1491    }
1492
1493    #[test]
1494    fn askpass_ignores_empty_value() {
1495        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass \n");
1496        assert_eq!(first_block(&config).askpass(), None);
1497    }
1498
1499    #[test]
1500    fn askpass_ignores_non_askpass_purple_comments() {
1501        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod\n");
1502        assert_eq!(first_block(&config).askpass(), None);
1503    }
1504
1505    #[test]
1506    fn set_askpass_adds_comment() {
1507        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1508        config.set_host_askpass("myserver", "keychain");
1509        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1510    }
1511
1512    #[test]
1513    fn set_askpass_replaces_existing() {
1514        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1515        config.set_host_askpass("myserver", "op://V/I/p");
1516        assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1517    }
1518
1519    #[test]
1520    fn set_askpass_empty_removes_comment() {
1521        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1522        config.set_host_askpass("myserver", "");
1523        assert_eq!(first_block(&config).askpass(), None);
1524    }
1525
1526    #[test]
1527    fn set_askpass_preserves_other_directives() {
1528        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n  # purple:tags prod\n");
1529        config.set_host_askpass("myserver", "vault:secret/ssh");
1530        assert_eq!(first_block(&config).askpass(), Some("vault:secret/ssh".to_string()));
1531        let entry = first_block(&config).to_host_entry();
1532        assert_eq!(entry.user, "admin");
1533        assert!(entry.tags.contains(&"prod".to_string()));
1534    }
1535
1536    #[test]
1537    fn set_askpass_preserves_indent() {
1538        let mut config = parse_str("Host myserver\n    HostName 10.0.0.1\n");
1539        config.set_host_askpass("myserver", "keychain");
1540        let raw = first_block(&config).directives.iter()
1541            .find(|d| d.raw_line.contains("purple:askpass"))
1542            .unwrap();
1543        assert!(raw.raw_line.starts_with("    "), "Expected 4-space indent, got: {:?}", raw.raw_line);
1544    }
1545
1546    #[test]
1547    fn set_askpass_on_nonexistent_host() {
1548        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1549        config.set_host_askpass("nohost", "keychain");
1550        assert_eq!(first_block(&config).askpass(), None);
1551    }
1552
1553    #[test]
1554    fn to_entry_includes_askpass() {
1555        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass bw:item\n");
1556        let entries = config.host_entries();
1557        assert_eq!(entries.len(), 1);
1558        assert_eq!(entries[0].askpass, Some("bw:item".to_string()));
1559    }
1560
1561    #[test]
1562    fn to_entry_askpass_none_when_absent() {
1563        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1564        let entries = config.host_entries();
1565        assert_eq!(entries.len(), 1);
1566        assert_eq!(entries[0].askpass, None);
1567    }
1568
1569    #[test]
1570    fn set_askpass_vault_with_hash_field() {
1571        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1572        config.set_host_askpass("myserver", "vault:secret/data/team#api_key");
1573        assert_eq!(first_block(&config).askpass(), Some("vault:secret/data/team#api_key".to_string()));
1574    }
1575
1576    #[test]
1577    fn set_askpass_custom_command_with_percent() {
1578        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1579        config.set_host_askpass("myserver", "get-pass %a %h");
1580        assert_eq!(first_block(&config).askpass(), Some("get-pass %a %h".to_string()));
1581    }
1582
1583    #[test]
1584    fn multiple_hosts_independent_askpass() {
1585        let mut config = parse_str("Host alpha\n  HostName a.com\n\nHost beta\n  HostName b.com\n");
1586        config.set_host_askpass("alpha", "keychain");
1587        config.set_host_askpass("beta", "vault:secret/ssh");
1588        assert_eq!(block_by_index(&config, 0).askpass(), Some("keychain".to_string()));
1589        assert_eq!(block_by_index(&config, 1).askpass(), Some("vault:secret/ssh".to_string()));
1590    }
1591
1592    #[test]
1593    fn set_askpass_then_clear_then_set_again() {
1594        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1595        config.set_host_askpass("myserver", "keychain");
1596        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1597        config.set_host_askpass("myserver", "");
1598        assert_eq!(first_block(&config).askpass(), None);
1599        config.set_host_askpass("myserver", "op://V/I/p");
1600        assert_eq!(first_block(&config).askpass(), Some("op://V/I/p".to_string()));
1601    }
1602
1603    #[test]
1604    fn askpass_tab_indent_preserved() {
1605        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
1606        config.set_host_askpass("myserver", "pass:ssh/prod");
1607        let raw = first_block(&config).directives.iter()
1608            .find(|d| d.raw_line.contains("purple:askpass"))
1609            .unwrap();
1610        assert!(raw.raw_line.starts_with("\t"), "Expected tab indent, got: {:?}", raw.raw_line);
1611    }
1612
1613    #[test]
1614    fn askpass_coexists_with_provider_comment() {
1615        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:provider do:123\n  # purple:askpass keychain\n");
1616        let block = first_block(&config);
1617        assert_eq!(block.askpass(), Some("keychain".to_string()));
1618        assert!(block.provider().is_some());
1619    }
1620
1621    #[test]
1622    fn set_askpass_does_not_remove_tags() {
1623        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:tags prod,staging\n");
1624        config.set_host_askpass("myserver", "keychain");
1625        let entry = first_block(&config).to_host_entry();
1626        assert_eq!(entry.askpass, Some("keychain".to_string()));
1627        assert!(entry.tags.contains(&"prod".to_string()));
1628        assert!(entry.tags.contains(&"staging".to_string()));
1629    }
1630
1631    #[test]
1632    fn askpass_idempotent_set_same_value() {
1633        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n");
1634        config.set_host_askpass("myserver", "keychain");
1635        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1636        let serialized = config.serialize();
1637        assert_eq!(serialized.matches("purple:askpass").count(), 1, "Should have exactly one askpass comment");
1638    }
1639
1640    #[test]
1641    fn askpass_with_value_containing_equals() {
1642        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1643        config.set_host_askpass("myserver", "cmd --opt=val %h");
1644        assert_eq!(first_block(&config).askpass(), Some("cmd --opt=val %h".to_string()));
1645    }
1646
1647    #[test]
1648    fn askpass_with_value_containing_hash() {
1649        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  # purple:askpass vault:a/b#c\n");
1650        assert_eq!(first_block(&config).askpass(), Some("vault:a/b#c".to_string()));
1651    }
1652
1653    #[test]
1654    fn askpass_with_long_op_uri() {
1655        let uri = "op://My Personal Vault/SSH Production Server/password";
1656        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1657        config.set_host_askpass("myserver", uri);
1658        assert_eq!(first_block(&config).askpass(), Some(uri.to_string()));
1659    }
1660
1661    #[test]
1662    fn askpass_does_not_interfere_with_host_matching() {
1663        // askpass is stored as a non-directive comment; it shouldn't affect SSH matching
1664        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User root\n  # purple:askpass keychain\n");
1665        let entry = first_block(&config).to_host_entry();
1666        assert_eq!(entry.user, "root");
1667        assert_eq!(entry.hostname, "10.0.0.1");
1668        assert_eq!(entry.askpass, Some("keychain".to_string()));
1669    }
1670
1671    #[test]
1672    fn set_askpass_on_host_with_many_directives() {
1673        let config_str = "\
1674Host myserver
1675  HostName 10.0.0.1
1676  User admin
1677  Port 2222
1678  IdentityFile ~/.ssh/id_ed25519
1679  ProxyJump bastion
1680  # purple:tags prod,us-east
1681";
1682        let mut config = parse_str(config_str);
1683        config.set_host_askpass("myserver", "pass:ssh/prod");
1684        let entry = first_block(&config).to_host_entry();
1685        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1686        assert_eq!(entry.user, "admin");
1687        assert_eq!(entry.port, 2222);
1688        assert!(entry.tags.contains(&"prod".to_string()));
1689    }
1690
1691    #[test]
1692    fn askpass_with_crlf_line_endings() {
1693        let config = parse_str("Host myserver\r\n  HostName 10.0.0.1\r\n  # purple:askpass keychain\r\n");
1694        assert_eq!(first_block(&config).askpass(), Some("keychain".to_string()));
1695    }
1696
1697    #[test]
1698    fn askpass_only_on_first_matching_host() {
1699        // If two Host blocks have the same alias (unusual), askpass comes from first
1700        let config = parse_str("Host dup\n  HostName a.com\n  # purple:askpass keychain\n\nHost dup\n  HostName b.com\n  # purple:askpass vault:x\n");
1701        let entries = config.host_entries();
1702        // First match
1703        assert_eq!(entries[0].askpass, Some("keychain".to_string()));
1704    }
1705
1706    #[test]
1707    fn set_askpass_preserves_other_non_directive_comments() {
1708        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";
1709        let mut config = parse_str(config_str);
1710        config.set_host_askpass("myserver", "new-source");
1711        let serialized = config.serialize();
1712        assert!(serialized.contains("# This is a user comment"));
1713        assert!(serialized.contains("# Another comment"));
1714        assert!(serialized.contains("# purple:askpass new-source"));
1715        assert!(!serialized.contains("# purple:askpass old"));
1716    }
1717
1718    #[test]
1719    fn askpass_mixed_with_tunnel_directives() {
1720        let config_str = "\
1721Host myserver
1722  HostName 10.0.0.1
1723  LocalForward 8080 localhost:80
1724  # purple:askpass bw:item
1725  RemoteForward 9090 localhost:9090
1726";
1727        let config = parse_str(config_str);
1728        let entry = first_block(&config).to_host_entry();
1729        assert_eq!(entry.askpass, Some("bw:item".to_string()));
1730        assert_eq!(entry.tunnel_count, 2);
1731    }
1732
1733    // =========================================================================
1734    // askpass: set_askpass idempotent (same value)
1735    // =========================================================================
1736
1737    #[test]
1738    fn set_askpass_idempotent_same_value() {
1739        let config_str = "Host myserver\n  HostName 10.0.0.1\n  # purple:askpass keychain\n";
1740        let mut config = parse_str(config_str);
1741        config.set_host_askpass("myserver", "keychain");
1742        let output = config.serialize();
1743        // Should still have exactly one askpass comment
1744        assert_eq!(output.matches("purple:askpass").count(), 1);
1745        assert!(output.contains("# purple:askpass keychain"));
1746    }
1747
1748    #[test]
1749    fn set_askpass_with_equals_in_value() {
1750        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1751        config.set_host_askpass("myserver", "cmd --opt=val");
1752        let entries = config.host_entries();
1753        assert_eq!(entries[0].askpass, Some("cmd --opt=val".to_string()));
1754    }
1755
1756    #[test]
1757    fn set_askpass_with_hash_in_value() {
1758        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1759        config.set_host_askpass("myserver", "vault:secret/data#field");
1760        let entries = config.host_entries();
1761        assert_eq!(entries[0].askpass, Some("vault:secret/data#field".to_string()));
1762    }
1763
1764    #[test]
1765    fn set_askpass_long_op_uri() {
1766        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1767        let long_uri = "op://My Personal Vault/SSH Production Server Key/password";
1768        config.set_host_askpass("myserver", long_uri);
1769        assert_eq!(config.host_entries()[0].askpass, Some(long_uri.to_string()));
1770    }
1771
1772    #[test]
1773    fn askpass_host_with_multi_pattern_is_skipped() {
1774        // Multi-pattern host blocks ("Host prod staging") are treated as patterns
1775        // and are not included in host_entries(), so set_askpass is a no-op
1776        let config_str = "Host prod staging\n  HostName 10.0.0.1\n";
1777        let mut config = parse_str(config_str);
1778        config.set_host_askpass("prod", "keychain");
1779        // No entries because multi-pattern hosts are pattern hosts
1780        assert!(config.host_entries().is_empty());
1781    }
1782
1783    #[test]
1784    fn askpass_survives_directive_reorder() {
1785        // askpass should survive even when directives are in unusual order
1786        let config_str = "\
1787Host myserver
1788  # purple:askpass op://V/I/p
1789  HostName 10.0.0.1
1790  User root
1791";
1792        let config = parse_str(config_str);
1793        let entry = first_block(&config).to_host_entry();
1794        assert_eq!(entry.askpass, Some("op://V/I/p".to_string()));
1795        assert_eq!(entry.hostname, "10.0.0.1");
1796    }
1797
1798    #[test]
1799    fn askpass_among_many_purple_comments() {
1800        let config_str = "\
1801Host myserver
1802  HostName 10.0.0.1
1803  # purple:tags prod,us-east
1804  # purple:provider do:12345
1805  # purple:askpass pass:ssh/prod
1806";
1807        let config = parse_str(config_str);
1808        let entry = first_block(&config).to_host_entry();
1809        assert_eq!(entry.askpass, Some("pass:ssh/prod".to_string()));
1810        assert!(entry.tags.contains(&"prod".to_string()));
1811    }
1812
1813    #[test]
1814    fn meta_empty_when_no_comment() {
1815        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
1816        let config = parse_str(config_str);
1817        let meta = first_block(&config).meta();
1818        assert!(meta.is_empty());
1819    }
1820
1821    #[test]
1822    fn meta_parses_key_value_pairs() {
1823        let config_str = "\
1824Host myhost
1825  HostName 1.2.3.4
1826  # purple:meta region=nyc3,plan=s-1vcpu-1gb
1827";
1828        let config = parse_str(config_str);
1829        let meta = first_block(&config).meta();
1830        assert_eq!(meta.len(), 2);
1831        assert_eq!(meta[0], ("region".to_string(), "nyc3".to_string()));
1832        assert_eq!(meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
1833    }
1834
1835    #[test]
1836    fn meta_round_trip() {
1837        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
1838        let mut config = parse_str(config_str);
1839        let meta = vec![
1840            ("region".to_string(), "fra1".to_string()),
1841            ("plan".to_string(), "cx11".to_string()),
1842        ];
1843        config.set_host_meta("myhost", &meta);
1844        let output = config.serialize();
1845        assert!(output.contains("# purple:meta region=fra1,plan=cx11"));
1846
1847        let config2 = parse_str(&output);
1848        let parsed = first_block(&config2).meta();
1849        assert_eq!(parsed, meta);
1850    }
1851
1852    #[test]
1853    fn meta_replaces_existing() {
1854        let config_str = "\
1855Host myhost
1856  HostName 1.2.3.4
1857  # purple:meta region=old
1858";
1859        let mut config = parse_str(config_str);
1860        config.set_host_meta(
1861            "myhost",
1862            &[("region".to_string(), "new".to_string())],
1863        );
1864        let output = config.serialize();
1865        assert!(!output.contains("region=old"));
1866        assert!(output.contains("region=new"));
1867    }
1868
1869    #[test]
1870    fn meta_removed_when_empty() {
1871        let config_str = "\
1872Host myhost
1873  HostName 1.2.3.4
1874  # purple:meta region=nyc3
1875";
1876        let mut config = parse_str(config_str);
1877        config.set_host_meta("myhost", &[]);
1878        let output = config.serialize();
1879        assert!(!output.contains("purple:meta"));
1880    }
1881
1882    #[test]
1883    fn meta_sanitizes_commas_in_values() {
1884        let config_str = "Host myhost\n  HostName 1.2.3.4\n";
1885        let mut config = parse_str(config_str);
1886        let meta = vec![("plan".to_string(), "s-1vcpu,1gb".to_string())];
1887        config.set_host_meta("myhost", &meta);
1888        let output = config.serialize();
1889        // Comma stripped to prevent parse corruption
1890        assert!(output.contains("plan=s-1vcpu1gb"));
1891
1892        let config2 = parse_str(&output);
1893        let parsed = first_block(&config2).meta();
1894        assert_eq!(parsed[0].1, "s-1vcpu1gb");
1895    }
1896
1897    #[test]
1898    fn meta_in_host_entry() {
1899        let config_str = "\
1900Host myhost
1901  HostName 1.2.3.4
1902  # purple:meta region=nyc3,plan=s-1vcpu-1gb
1903";
1904        let config = parse_str(config_str);
1905        let entry = first_block(&config).to_host_entry();
1906        assert_eq!(entry.provider_meta.len(), 2);
1907        assert_eq!(entry.provider_meta[0].0, "region");
1908        assert_eq!(entry.provider_meta[1].0, "plan");
1909    }
1910}