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