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