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}
82
83impl Default for HostEntry {
84    fn default() -> Self {
85        Self {
86            alias: String::new(),
87            hostname: String::new(),
88            user: String::new(),
89            port: 22,
90            identity_file: String::new(),
91            proxy_jump: String::new(),
92            source_file: None,
93            tags: Vec::new(),
94            provider: None,
95            tunnel_count: 0,
96        }
97    }
98}
99
100impl HostEntry {
101    /// Build the SSH command string for this host (e.g. "ssh -- 'myserver'").
102    /// Shell-quotes the alias to prevent injection when pasted into a terminal.
103    pub fn ssh_command(&self) -> String {
104        let escaped = self.alias.replace('\'', "'\\''");
105        format!("ssh -- '{}'", escaped)
106    }
107}
108
109/// Returns true if the host pattern contains wildcards, character classes,
110/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
111/// These are SSH match patterns, not concrete hosts.
112pub fn is_host_pattern(pattern: &str) -> bool {
113    pattern.contains('*')
114        || pattern.contains('?')
115        || pattern.contains('[')
116        || pattern.starts_with('!')
117        || pattern.contains(' ')
118        || pattern.contains('\t')
119}
120
121impl HostBlock {
122    /// Index of the first trailing blank line (for inserting content before separators).
123    fn content_end(&self) -> usize {
124        let mut pos = self.directives.len();
125        while pos > 0 {
126            if self.directives[pos - 1].is_non_directive
127                && self.directives[pos - 1].raw_line.trim().is_empty()
128            {
129                pos -= 1;
130            } else {
131                break;
132            }
133        }
134        pos
135    }
136
137    /// Remove and return trailing blank lines.
138    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
139        let end = self.content_end();
140        self.directives.drain(end..).collect()
141    }
142
143    /// Ensure exactly one trailing blank line.
144    fn ensure_trailing_blank(&mut self) {
145        self.pop_trailing_blanks();
146        self.directives.push(Directive {
147            key: String::new(),
148            value: String::new(),
149            raw_line: String::new(),
150            is_non_directive: true,
151        });
152    }
153
154    /// Detect indentation used by existing directives (falls back to "  ").
155    fn detect_indent(&self) -> String {
156        for d in &self.directives {
157            if !d.is_non_directive && !d.raw_line.is_empty() {
158                let trimmed = d.raw_line.trim_start();
159                let indent_len = d.raw_line.len() - trimmed.len();
160                if indent_len > 0 {
161                    return d.raw_line[..indent_len].to_string();
162                }
163            }
164        }
165        "  ".to_string()
166    }
167
168    /// Extract tags from purple:tags comment in directives.
169    pub fn tags(&self) -> Vec<String> {
170        for d in &self.directives {
171            if d.is_non_directive {
172                let trimmed = d.raw_line.trim();
173                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
174                    return rest
175                        .split(',')
176                        .map(|t| t.trim().to_string())
177                        .filter(|t| !t.is_empty())
178                        .collect();
179                }
180            }
181        }
182        Vec::new()
183    }
184
185    /// Extract provider info from purple:provider comment in directives.
186    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
187    pub fn provider(&self) -> Option<(String, String)> {
188        for d in &self.directives {
189            if d.is_non_directive {
190                let trimmed = d.raw_line.trim();
191                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
192                    if let Some((name, id)) = rest.split_once(':') {
193                        return Some((name.trim().to_string(), id.trim().to_string()));
194                    }
195                }
196            }
197        }
198        None
199    }
200
201    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
202    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
203        let indent = self.detect_indent();
204        self.directives.retain(|d| {
205            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
206        });
207        let pos = self.content_end();
208        self.directives.insert(
209            pos,
210            Directive {
211                key: String::new(),
212                value: String::new(),
213                raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
214                is_non_directive: true,
215            },
216        );
217    }
218
219    /// Set tags on a host block. Replaces existing purple:tags comment or adds one.
220    pub fn set_tags(&mut self, tags: &[String]) {
221        let indent = self.detect_indent();
222        self.directives.retain(|d| {
223            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
224        });
225        if !tags.is_empty() {
226            let pos = self.content_end();
227            self.directives.insert(
228                pos,
229                Directive {
230                    key: String::new(),
231                    value: String::new(),
232                    raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
233                    is_non_directive: true,
234                },
235            );
236        }
237    }
238
239    /// Extract a convenience HostEntry view from this block.
240    pub fn to_host_entry(&self) -> HostEntry {
241        let mut entry = HostEntry {
242            alias: self.host_pattern.clone(),
243            port: 22,
244            ..Default::default()
245        };
246        for d in &self.directives {
247            if d.is_non_directive {
248                continue;
249            }
250            if d.key.eq_ignore_ascii_case("hostname") {
251                entry.hostname = d.value.clone();
252            } else if d.key.eq_ignore_ascii_case("user") {
253                entry.user = d.value.clone();
254            } else if d.key.eq_ignore_ascii_case("port") {
255                entry.port = d.value.parse().unwrap_or(22);
256            } else if d.key.eq_ignore_ascii_case("identityfile") {
257                if entry.identity_file.is_empty() {
258                    entry.identity_file = d.value.clone();
259                }
260            } else if d.key.eq_ignore_ascii_case("proxyjump") {
261                entry.proxy_jump = d.value.clone();
262            }
263        }
264        entry.tags = self.tags();
265        entry.provider = self.provider().map(|(name, _)| name);
266        entry.tunnel_count = self.tunnel_count();
267        entry
268    }
269
270    /// Count forwarding directives (LocalForward, RemoteForward, DynamicForward).
271    pub fn tunnel_count(&self) -> u16 {
272        let count = self
273            .directives
274            .iter()
275            .filter(|d| {
276                !d.is_non_directive
277                    && (d.key.eq_ignore_ascii_case("localforward")
278                        || d.key.eq_ignore_ascii_case("remoteforward")
279                        || d.key.eq_ignore_ascii_case("dynamicforward"))
280            })
281            .count();
282        count.min(u16::MAX as usize) as u16
283    }
284
285    /// Check if this block has any tunnel forwarding directives.
286    #[allow(dead_code)]
287    pub fn has_tunnels(&self) -> bool {
288        self.directives.iter().any(|d| {
289            !d.is_non_directive
290                && (d.key.eq_ignore_ascii_case("localforward")
291                    || d.key.eq_ignore_ascii_case("remoteforward")
292                    || d.key.eq_ignore_ascii_case("dynamicforward"))
293        })
294    }
295
296    /// Extract tunnel rules from forwarding directives.
297    pub fn tunnel_directives(&self) -> Vec<crate::tunnel::TunnelRule> {
298        self.directives
299            .iter()
300            .filter(|d| !d.is_non_directive)
301            .filter_map(|d| crate::tunnel::TunnelRule::parse_value(&d.key, &d.value))
302            .collect()
303    }
304}
305
306impl SshConfigFile {
307    /// Get all host entries as convenience views (including from Include files).
308    pub fn host_entries(&self) -> Vec<HostEntry> {
309        let mut entries = Vec::new();
310        Self::collect_host_entries(&self.elements, &mut entries);
311        entries
312    }
313
314    /// Collect all resolved Include file paths (recursively).
315    pub fn include_paths(&self) -> Vec<PathBuf> {
316        let mut paths = Vec::new();
317        Self::collect_include_paths(&self.elements, &mut paths);
318        paths
319    }
320
321    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
322        for e in elements {
323            if let ConfigElement::Include(include) = e {
324                for file in &include.resolved_files {
325                    paths.push(file.path.clone());
326                    Self::collect_include_paths(&file.elements, paths);
327                }
328            }
329        }
330    }
331
332    /// Collect parent directories of Include glob patterns.
333    /// When a file is added/removed under a glob dir, the directory's mtime changes.
334    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
335        let config_dir = self.path.parent();
336        let mut seen = std::collections::HashSet::new();
337        let mut dirs = Vec::new();
338        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
339        dirs
340    }
341
342    fn collect_include_glob_dirs(
343        elements: &[ConfigElement],
344        config_dir: Option<&std::path::Path>,
345        seen: &mut std::collections::HashSet<PathBuf>,
346        dirs: &mut Vec<PathBuf>,
347    ) {
348        for e in elements {
349            if let ConfigElement::Include(include) = e {
350                // Split on whitespace to handle multi-pattern Includes
351                // (same as resolve_include does)
352                for single in include.pattern.split_whitespace() {
353                    let expanded = Self::expand_tilde(single);
354                    let resolved = if expanded.starts_with('/') {
355                        PathBuf::from(&expanded)
356                    } else if let Some(dir) = config_dir {
357                        dir.join(&expanded)
358                    } else {
359                        continue;
360                    };
361                    if let Some(parent) = resolved.parent() {
362                        let parent = parent.to_path_buf();
363                        if seen.insert(parent.clone()) {
364                            dirs.push(parent);
365                        }
366                    }
367                }
368                // Recurse into resolved files
369                for file in &include.resolved_files {
370                    Self::collect_include_glob_dirs(
371                        &file.elements,
372                        file.path.parent(),
373                        seen,
374                        dirs,
375                    );
376                }
377            }
378        }
379    }
380
381
382    /// Recursively collect host entries from a list of elements.
383    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
384        for e in elements {
385            match e {
386                ConfigElement::HostBlock(block) => {
387                    if is_host_pattern(&block.host_pattern) {
388                        continue;
389                    }
390                    entries.push(block.to_host_entry());
391                }
392                ConfigElement::Include(include) => {
393                    for file in &include.resolved_files {
394                        let start = entries.len();
395                        Self::collect_host_entries(&file.elements, entries);
396                        for entry in &mut entries[start..] {
397                            if entry.source_file.is_none() {
398                                entry.source_file = Some(file.path.clone());
399                            }
400                        }
401                    }
402                }
403                ConfigElement::GlobalLine(_) => {}
404            }
405        }
406    }
407
408    /// Check if a host alias already exists (including in Include files).
409    /// Walks the element tree directly without building HostEntry structs.
410    pub fn has_host(&self, alias: &str) -> bool {
411        Self::has_host_in_elements(&self.elements, alias)
412    }
413
414    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
415        for e in elements {
416            match e {
417                ConfigElement::HostBlock(block) => {
418                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
419                        return true;
420                    }
421                }
422                ConfigElement::Include(include) => {
423                    for file in &include.resolved_files {
424                        if Self::has_host_in_elements(&file.elements, alias) {
425                            return true;
426                        }
427                    }
428                }
429                ConfigElement::GlobalLine(_) => {}
430            }
431        }
432        false
433    }
434
435    /// Check if a host alias is from an included file (read-only).
436    /// Handles multi-pattern Host lines by splitting on whitespace.
437    pub fn is_included_host(&self, alias: &str) -> bool {
438        // Not in top-level elements → must be in an Include
439        for e in &self.elements {
440            match e {
441                ConfigElement::HostBlock(block) => {
442                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
443                        return false;
444                    }
445                }
446                ConfigElement::Include(include) => {
447                    for file in &include.resolved_files {
448                        if Self::has_host_in_elements(&file.elements, alias) {
449                            return true;
450                        }
451                    }
452                }
453                ConfigElement::GlobalLine(_) => {}
454            }
455        }
456        false
457    }
458
459    /// Add a new host entry to the config.
460    pub fn add_host(&mut self, entry: &HostEntry) {
461        let block = Self::entry_to_block(entry);
462        // Add a blank line separator if the file isn't empty and doesn't already end with one
463        if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
464            self.elements
465                .push(ConfigElement::GlobalLine(String::new()));
466        }
467        self.elements.push(ConfigElement::HostBlock(block));
468    }
469
470    /// Check if the last element already ends with a blank line.
471    pub fn last_element_has_trailing_blank(&self) -> bool {
472        match self.elements.last() {
473            Some(ConfigElement::HostBlock(block)) => block
474                .directives
475                .last()
476                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
477            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
478            _ => false,
479        }
480    }
481
482    /// Update an existing host entry by alias.
483    /// Merges changes into the existing block, preserving unknown directives.
484    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
485        for element in &mut self.elements {
486            if let ConfigElement::HostBlock(block) = element {
487                if block.host_pattern == old_alias {
488                    // Update host pattern (preserve raw_host_line when alias unchanged)
489                    if entry.alias != block.host_pattern {
490                        block.host_pattern = entry.alias.clone();
491                        block.raw_host_line = format!("Host {}", entry.alias);
492                    }
493
494                    // Merge known directives (update existing, add missing, remove empty)
495                    Self::upsert_directive(block, "HostName", &entry.hostname);
496                    Self::upsert_directive(block, "User", &entry.user);
497                    if entry.port != 22 {
498                        Self::upsert_directive(block, "Port", &entry.port.to_string());
499                    } else {
500                        // Remove explicit Port 22 (it's the default)
501                        block
502                            .directives
503                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
504                    }
505                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
506                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
507                    return;
508                }
509            }
510        }
511    }
512
513    /// Update a directive in-place, add it if missing, or remove it if value is empty.
514    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
515        if value.is_empty() {
516            block
517                .directives
518                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
519            return;
520        }
521        let indent = block.detect_indent();
522        for d in &mut block.directives {
523            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
524                // Only rebuild raw_line when value actually changed (preserves inline comments)
525                if d.value != value {
526                    d.value = value.to_string();
527                    // Detect separator style from original raw_line and preserve it.
528                    // Handles: "Key value", "Key=value", "Key = value", "Key =value"
529                    // Only considers '=' as separator if it appears before any
530                    // non-whitespace content (avoids matching '=' inside values
531                    // like "IdentityFile ~/.ssh/id=prod").
532                    let trimmed = d.raw_line.trim_start();
533                    let after_key = &trimmed[d.key.len()..];
534                    let sep = if after_key.trim_start().starts_with('=') {
535                        let eq_pos = after_key.find('=').unwrap();
536                        let after_eq = &after_key[eq_pos + 1..];
537                        let trailing_ws = after_eq.len() - after_eq.trim_start().len();
538                        after_key[..eq_pos + 1 + trailing_ws].to_string()
539                    } else {
540                        " ".to_string()
541                    };
542                    d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
543                }
544                return;
545            }
546        }
547        // Not found — insert before trailing blanks
548        let pos = block.content_end();
549        block.directives.insert(
550            pos,
551            Directive {
552                key: key.to_string(),
553                value: value.to_string(),
554                raw_line: format!("{}{} {}", indent, key, value),
555                is_non_directive: false,
556            },
557        );
558    }
559
560    /// Set provider on a host block by alias.
561    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
562        for element in &mut self.elements {
563            if let ConfigElement::HostBlock(block) = element {
564                if block.host_pattern == alias {
565                    block.set_provider(provider_name, server_id);
566                    return;
567                }
568            }
569        }
570    }
571
572    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
573    /// Searches both top-level elements and Include files so that provider hosts
574    /// in included configs are recognized during sync (prevents duplicate additions).
575    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
576        let mut results = Vec::new();
577        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
578        results
579    }
580
581    fn collect_provider_hosts(
582        elements: &[ConfigElement],
583        provider_name: &str,
584        results: &mut Vec<(String, String)>,
585    ) {
586        for element in elements {
587            match element {
588                ConfigElement::HostBlock(block) => {
589                    if let Some((name, id)) = block.provider() {
590                        if name == provider_name {
591                            results.push((block.host_pattern.clone(), id));
592                        }
593                    }
594                }
595                ConfigElement::Include(include) => {
596                    for file in &include.resolved_files {
597                        Self::collect_provider_hosts(&file.elements, provider_name, results);
598                    }
599                }
600                ConfigElement::GlobalLine(_) => {}
601            }
602        }
603    }
604
605    /// Compare two directive values with whitespace normalization.
606    /// Handles hand-edited configs with tabs or multiple spaces.
607    fn values_match(a: &str, b: &str) -> bool {
608        a.split_whitespace().eq(b.split_whitespace())
609    }
610
611    /// Add a forwarding directive to a host block.
612    /// Inserts at `content_end()` (before trailing blanks), using detected indentation.
613    /// Uses split_whitespace matching for multi-pattern Host lines.
614    pub fn add_forward(&mut self, alias: &str, directive_key: &str, value: &str) {
615        for element in &mut self.elements {
616            if let ConfigElement::HostBlock(block) = element {
617                if block.host_pattern.split_whitespace().any(|p| p == alias) {
618                    let indent = block.detect_indent();
619                    let pos = block.content_end();
620                    block.directives.insert(
621                        pos,
622                        Directive {
623                            key: directive_key.to_string(),
624                            value: value.to_string(),
625                            raw_line: format!("{}{} {}", indent, directive_key, value),
626                            is_non_directive: false,
627                        },
628                    );
629                    return;
630                }
631            }
632        }
633    }
634
635    /// Remove a specific forwarding directive from a host block.
636    /// Matches key (case-insensitive) and value (whitespace-normalized).
637    /// Uses split_whitespace matching for multi-pattern Host lines.
638    /// Returns true if a directive was actually removed.
639    pub fn remove_forward(&mut self, alias: &str, directive_key: &str, value: &str) -> bool {
640        for element in &mut self.elements {
641            if let ConfigElement::HostBlock(block) = element {
642                if block.host_pattern.split_whitespace().any(|p| p == alias) {
643                    if let Some(pos) = block.directives.iter().position(|d| {
644                        !d.is_non_directive
645                            && d.key.eq_ignore_ascii_case(directive_key)
646                            && Self::values_match(&d.value, value)
647                    }) {
648                        block.directives.remove(pos);
649                        return true;
650                    }
651                    return false;
652                }
653            }
654        }
655        false
656    }
657
658    /// Check if a host block has a specific forwarding directive.
659    /// Uses whitespace-normalized value comparison and split_whitespace host matching.
660    pub fn has_forward(&self, alias: &str, directive_key: &str, value: &str) -> bool {
661        for element in &self.elements {
662            if let ConfigElement::HostBlock(block) = element {
663                if block.host_pattern.split_whitespace().any(|p| p == alias) {
664                    return block.directives.iter().any(|d| {
665                        !d.is_non_directive
666                            && d.key.eq_ignore_ascii_case(directive_key)
667                            && Self::values_match(&d.value, value)
668                    });
669                }
670            }
671        }
672        false
673    }
674
675    /// Find tunnel directives for a host alias, searching all elements including
676    /// Include files. Uses split_whitespace matching like has_host() for multi-pattern
677    /// Host lines.
678    pub fn find_tunnel_directives(&self, alias: &str) -> Vec<crate::tunnel::TunnelRule> {
679        Self::find_tunnel_directives_in(&self.elements, alias)
680    }
681
682    fn find_tunnel_directives_in(
683        elements: &[ConfigElement],
684        alias: &str,
685    ) -> Vec<crate::tunnel::TunnelRule> {
686        for element in elements {
687            match element {
688                ConfigElement::HostBlock(block) => {
689                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
690                        return block.tunnel_directives();
691                    }
692                }
693                ConfigElement::Include(include) => {
694                    for file in &include.resolved_files {
695                        let rules = Self::find_tunnel_directives_in(&file.elements, alias);
696                        if !rules.is_empty() {
697                            return rules;
698                        }
699                    }
700                }
701                ConfigElement::GlobalLine(_) => {}
702            }
703        }
704        Vec::new()
705    }
706
707    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
708    pub fn deduplicate_alias(&self, base: &str) -> String {
709        self.deduplicate_alias_excluding(base, None)
710    }
711
712    /// Generate a unique alias, optionally excluding one alias from collision detection.
713    /// Used during rename so the host being renamed doesn't collide with itself.
714    pub fn deduplicate_alias_excluding(&self, base: &str, exclude: Option<&str>) -> String {
715        let is_taken = |alias: &str| {
716            if exclude == Some(alias) {
717                return false;
718            }
719            self.has_host(alias)
720        };
721        if !is_taken(base) {
722            return base.to_string();
723        }
724        for n in 2..=9999 {
725            let candidate = format!("{}-{}", base, n);
726            if !is_taken(&candidate) {
727                return candidate;
728            }
729        }
730        // Practically unreachable: fall back to PID-based suffix
731        format!("{}-{}", base, std::process::id())
732    }
733
734    /// Set tags on a host block by alias.
735    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
736        for element in &mut self.elements {
737            if let ConfigElement::HostBlock(block) = element {
738                if block.host_pattern == alias {
739                    block.set_tags(tags);
740                    return;
741                }
742            }
743        }
744    }
745
746    /// Delete a host entry by alias.
747    #[allow(dead_code)]
748    pub fn delete_host(&mut self, alias: &str) {
749        self.elements.retain(|e| match e {
750            ConfigElement::HostBlock(block) => block.host_pattern != alias,
751            _ => true,
752        });
753        // Collapse consecutive blank lines left by deletion
754        self.elements.dedup_by(|a, b| {
755            matches!(
756                (&*a, &*b),
757                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
758                if x.trim().is_empty() && y.trim().is_empty()
759            )
760        });
761    }
762
763    /// Delete a host and return the removed element and its position for undo.
764    /// Does NOT collapse blank lines so the position stays valid for re-insertion.
765    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
766        let pos = self.elements.iter().position(|e| {
767            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
768        })?;
769        let element = self.elements.remove(pos);
770        Some((element, pos))
771    }
772
773    /// Insert a host block at a specific position (for undo).
774    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
775        let pos = position.min(self.elements.len());
776        self.elements.insert(pos, element);
777    }
778
779    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
780    #[allow(dead_code)]
781    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
782        let pos_a = self.elements.iter().position(|e| {
783            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
784        });
785        let pos_b = self.elements.iter().position(|e| {
786            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
787        });
788        if let (Some(a), Some(b)) = (pos_a, pos_b) {
789            if a == b {
790                return false;
791            }
792            let (first, second) = (a.min(b), a.max(b));
793
794            // Strip trailing blanks from both blocks before swap
795            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
796                block.pop_trailing_blanks();
797            }
798            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
799                block.pop_trailing_blanks();
800            }
801
802            // Swap
803            self.elements.swap(first, second);
804
805            // Add trailing blank to first block (separator between the two)
806            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
807                block.ensure_trailing_blank();
808            }
809
810            // Add trailing blank to second only if not the last element
811            if second < self.elements.len() - 1 {
812                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
813                    block.ensure_trailing_blank();
814                }
815            }
816
817            return true;
818        }
819        false
820    }
821
822    /// Convert a HostEntry into a new HostBlock with clean formatting.
823    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
824        let mut directives = Vec::new();
825
826        if !entry.hostname.is_empty() {
827            directives.push(Directive {
828                key: "HostName".to_string(),
829                value: entry.hostname.clone(),
830                raw_line: format!("  HostName {}", entry.hostname),
831                is_non_directive: false,
832            });
833        }
834        if !entry.user.is_empty() {
835            directives.push(Directive {
836                key: "User".to_string(),
837                value: entry.user.clone(),
838                raw_line: format!("  User {}", entry.user),
839                is_non_directive: false,
840            });
841        }
842        if entry.port != 22 {
843            directives.push(Directive {
844                key: "Port".to_string(),
845                value: entry.port.to_string(),
846                raw_line: format!("  Port {}", entry.port),
847                is_non_directive: false,
848            });
849        }
850        if !entry.identity_file.is_empty() {
851            directives.push(Directive {
852                key: "IdentityFile".to_string(),
853                value: entry.identity_file.clone(),
854                raw_line: format!("  IdentityFile {}", entry.identity_file),
855                is_non_directive: false,
856            });
857        }
858        if !entry.proxy_jump.is_empty() {
859            directives.push(Directive {
860                key: "ProxyJump".to_string(),
861                value: entry.proxy_jump.clone(),
862                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
863                is_non_directive: false,
864            });
865        }
866
867        HostBlock {
868            host_pattern: entry.alias.clone(),
869            raw_host_line: format!("Host {}", entry.alias),
870            directives,
871        }
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    fn parse_str(content: &str) -> SshConfigFile {
880        SshConfigFile {
881            elements: SshConfigFile::parse_content(content),
882            path: PathBuf::from("/tmp/test_config"),
883            crlf: false,
884        }
885    }
886
887    #[test]
888    fn tunnel_directives_extracts_forwards() {
889        let config = parse_str(
890            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
891        );
892        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
893            let rules = block.tunnel_directives();
894            assert_eq!(rules.len(), 3);
895            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
896            assert_eq!(rules[0].bind_port, 8080);
897            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
898            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
899        } else {
900            panic!("Expected HostBlock");
901        }
902    }
903
904    #[test]
905    fn tunnel_count_counts_forwards() {
906        let config = parse_str(
907            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n",
908        );
909        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
910            assert_eq!(block.tunnel_count(), 2);
911        } else {
912            panic!("Expected HostBlock");
913        }
914    }
915
916    #[test]
917    fn tunnel_count_zero_for_no_forwards() {
918        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
919        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
920            assert_eq!(block.tunnel_count(), 0);
921            assert!(!block.has_tunnels());
922        } else {
923            panic!("Expected HostBlock");
924        }
925    }
926
927    #[test]
928    fn has_tunnels_true_with_forward() {
929        let config = parse_str("Host myserver\n  HostName 10.0.0.1\n  DynamicForward 1080\n");
930        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
931            assert!(block.has_tunnels());
932        } else {
933            panic!("Expected HostBlock");
934        }
935    }
936
937    #[test]
938    fn add_forward_inserts_directive() {
939        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n  User admin\n");
940        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
941        let output = config.serialize();
942        assert!(output.contains("LocalForward 8080 localhost:80"));
943        // Existing directives preserved
944        assert!(output.contains("HostName 10.0.0.1"));
945        assert!(output.contains("User admin"));
946    }
947
948    #[test]
949    fn add_forward_preserves_indentation() {
950        let mut config = parse_str("Host myserver\n\tHostName 10.0.0.1\n");
951        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
952        let output = config.serialize();
953        assert!(output.contains("\tLocalForward 8080 localhost:80"));
954    }
955
956    #[test]
957    fn add_multiple_forwards_same_type() {
958        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
959        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
960        config.add_forward("myserver", "LocalForward", "9090 localhost:90");
961        let output = config.serialize();
962        assert!(output.contains("LocalForward 8080 localhost:80"));
963        assert!(output.contains("LocalForward 9090 localhost:90"));
964    }
965
966    #[test]
967    fn remove_forward_removes_exact_match() {
968        let mut config = parse_str(
969            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
970        );
971        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
972        let output = config.serialize();
973        assert!(!output.contains("8080 localhost:80"));
974        assert!(output.contains("9090 localhost:90"));
975    }
976
977    #[test]
978    fn remove_forward_leaves_other_directives() {
979        let mut config = parse_str(
980            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  User admin\n",
981        );
982        config.remove_forward("myserver", "LocalForward", "8080 localhost:80");
983        let output = config.serialize();
984        assert!(!output.contains("LocalForward"));
985        assert!(output.contains("HostName 10.0.0.1"));
986        assert!(output.contains("User admin"));
987    }
988
989    #[test]
990    fn remove_forward_no_match_is_noop() {
991        let original = "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n";
992        let mut config = parse_str(original);
993        config.remove_forward("myserver", "LocalForward", "9999 localhost:99");
994        assert_eq!(config.serialize(), original);
995    }
996
997    #[test]
998    fn host_entry_tunnel_count_populated() {
999        let config = parse_str(
1000            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n  DynamicForward 1080\n",
1001        );
1002        let entries = config.host_entries();
1003        assert_eq!(entries.len(), 1);
1004        assert_eq!(entries[0].tunnel_count, 2);
1005    }
1006
1007    #[test]
1008    fn remove_forward_returns_true_on_match() {
1009        let mut config = parse_str(
1010            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1011        );
1012        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1013    }
1014
1015    #[test]
1016    fn remove_forward_returns_false_on_no_match() {
1017        let mut config = parse_str(
1018            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1019        );
1020        assert!(!config.remove_forward("myserver", "LocalForward", "9999 localhost:99"));
1021    }
1022
1023    #[test]
1024    fn remove_forward_returns_false_for_unknown_host() {
1025        let mut config = parse_str("Host myserver\n  HostName 10.0.0.1\n");
1026        assert!(!config.remove_forward("nohost", "LocalForward", "8080 localhost:80"));
1027    }
1028
1029    #[test]
1030    fn has_forward_finds_match() {
1031        let config = parse_str(
1032            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1033        );
1034        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1035    }
1036
1037    #[test]
1038    fn has_forward_no_match() {
1039        let config = parse_str(
1040            "Host myserver\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1041        );
1042        assert!(!config.has_forward("myserver", "LocalForward", "9999 localhost:99"));
1043        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1044    }
1045
1046    #[test]
1047    fn has_forward_case_insensitive_key() {
1048        let config = parse_str(
1049            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1050        );
1051        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1052    }
1053
1054    #[test]
1055    fn add_forward_to_empty_block() {
1056        let mut config = parse_str("Host myserver\n");
1057        config.add_forward("myserver", "LocalForward", "8080 localhost:80");
1058        let output = config.serialize();
1059        assert!(output.contains("LocalForward 8080 localhost:80"));
1060    }
1061
1062    #[test]
1063    fn remove_forward_case_insensitive_key_match() {
1064        let mut config = parse_str(
1065            "Host myserver\n  HostName 10.0.0.1\n  localforward 8080 localhost:80\n",
1066        );
1067        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1068        assert!(!config.serialize().contains("localforward"));
1069    }
1070
1071    #[test]
1072    fn tunnel_count_case_insensitive() {
1073        let config = parse_str(
1074            "Host myserver\n  localforward 8080 localhost:80\n  REMOTEFORWARD 9090 localhost:90\n  dynamicforward 1080\n",
1075        );
1076        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1077            assert_eq!(block.tunnel_count(), 3);
1078        } else {
1079            panic!("Expected HostBlock");
1080        }
1081    }
1082
1083    #[test]
1084    fn tunnel_directives_extracts_all_types() {
1085        let config = parse_str(
1086            "Host myserver\n  LocalForward 8080 localhost:80\n  RemoteForward 9090 localhost:3000\n  DynamicForward 1080\n",
1087        );
1088        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1089            let rules = block.tunnel_directives();
1090            assert_eq!(rules.len(), 3);
1091            assert_eq!(rules[0].tunnel_type, crate::tunnel::TunnelType::Local);
1092            assert_eq!(rules[1].tunnel_type, crate::tunnel::TunnelType::Remote);
1093            assert_eq!(rules[2].tunnel_type, crate::tunnel::TunnelType::Dynamic);
1094        } else {
1095            panic!("Expected HostBlock");
1096        }
1097    }
1098
1099    #[test]
1100    fn tunnel_directives_skips_malformed() {
1101        let config = parse_str(
1102            "Host myserver\n  LocalForward not_valid\n  DynamicForward 1080\n",
1103        );
1104        if let Some(ConfigElement::HostBlock(block)) = config.elements.first() {
1105            let rules = block.tunnel_directives();
1106            assert_eq!(rules.len(), 1);
1107            assert_eq!(rules[0].bind_port, 1080);
1108        } else {
1109            panic!("Expected HostBlock");
1110        }
1111    }
1112
1113    #[test]
1114    fn find_tunnel_directives_multi_pattern_host() {
1115        let config = parse_str(
1116            "Host prod staging\n  HostName 10.0.0.1\n  LocalForward 8080 localhost:80\n",
1117        );
1118        let rules = config.find_tunnel_directives("prod");
1119        assert_eq!(rules.len(), 1);
1120        assert_eq!(rules[0].bind_port, 8080);
1121        let rules2 = config.find_tunnel_directives("staging");
1122        assert_eq!(rules2.len(), 1);
1123    }
1124
1125    #[test]
1126    fn find_tunnel_directives_no_match() {
1127        let config = parse_str(
1128            "Host myserver\n  LocalForward 8080 localhost:80\n",
1129        );
1130        let rules = config.find_tunnel_directives("nohost");
1131        assert!(rules.is_empty());
1132    }
1133
1134    #[test]
1135    fn has_forward_exact_match() {
1136        let config = parse_str(
1137            "Host myserver\n  LocalForward 8080 localhost:80\n",
1138        );
1139        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1140        assert!(!config.has_forward("myserver", "LocalForward", "9090 localhost:80"));
1141        assert!(!config.has_forward("myserver", "RemoteForward", "8080 localhost:80"));
1142        assert!(!config.has_forward("nohost", "LocalForward", "8080 localhost:80"));
1143    }
1144
1145    #[test]
1146    fn has_forward_whitespace_normalized() {
1147        let config = parse_str(
1148            "Host myserver\n  LocalForward 8080  localhost:80\n",
1149        );
1150        // Extra space in config value vs single space in query — should still match
1151        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1152    }
1153
1154    #[test]
1155    fn has_forward_multi_pattern_host() {
1156        let config = parse_str(
1157            "Host prod staging\n  LocalForward 8080 localhost:80\n",
1158        );
1159        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1160        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1161    }
1162
1163    #[test]
1164    fn add_forward_multi_pattern_host() {
1165        let mut config = parse_str(
1166            "Host prod staging\n  HostName 10.0.0.1\n",
1167        );
1168        config.add_forward("prod", "LocalForward", "8080 localhost:80");
1169        assert!(config.has_forward("prod", "LocalForward", "8080 localhost:80"));
1170        assert!(config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1171    }
1172
1173    #[test]
1174    fn remove_forward_multi_pattern_host() {
1175        let mut config = parse_str(
1176            "Host prod staging\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1177        );
1178        assert!(config.remove_forward("staging", "LocalForward", "8080 localhost:80"));
1179        assert!(!config.has_forward("staging", "LocalForward", "8080 localhost:80"));
1180        // Other forward should remain
1181        assert!(config.has_forward("staging", "LocalForward", "9090 localhost:90"));
1182    }
1183
1184    #[test]
1185    fn edit_tunnel_detects_duplicate_after_remove() {
1186        // Simulates edit flow: remove old, then check if new value already exists
1187        let mut config = parse_str(
1188            "Host myserver\n  LocalForward 8080 localhost:80\n  LocalForward 9090 localhost:90\n",
1189        );
1190        // Edit rule A (8080) toward rule B (9090): remove A first
1191        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1192        // Now check if the target value already exists — should detect duplicate
1193        assert!(config.has_forward("myserver", "LocalForward", "9090 localhost:90"));
1194    }
1195
1196    #[test]
1197    fn has_forward_tab_whitespace_normalized() {
1198        let config = parse_str(
1199            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1200        );
1201        // Tab in config value vs space in query — should match via values_match
1202        assert!(config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1203    }
1204
1205    #[test]
1206    fn remove_forward_tab_whitespace_normalized() {
1207        let mut config = parse_str(
1208            "Host myserver\n  LocalForward 8080\tlocalhost:80\n",
1209        );
1210        // Remove with single space should match tab-separated value
1211        assert!(config.remove_forward("myserver", "LocalForward", "8080 localhost:80"));
1212        assert!(!config.has_forward("myserver", "LocalForward", "8080 localhost:80"));
1213    }
1214
1215    #[test]
1216    fn upsert_preserves_space_separator_when_value_contains_equals() {
1217        let mut config = parse_str(
1218            "Host myserver\n  IdentityFile ~/.ssh/id=prod\n",
1219        );
1220        let entry = HostEntry {
1221            alias: "myserver".to_string(),
1222            hostname: "10.0.0.1".to_string(),
1223            identity_file: "~/.ssh/id=staging".to_string(),
1224            port: 22,
1225            ..Default::default()
1226        };
1227        config.update_host("myserver", &entry);
1228        let output = config.serialize();
1229        // Separator should remain a space, not pick up the = from the value
1230        assert!(output.contains("  IdentityFile ~/.ssh/id=staging"), "got: {}", output);
1231        assert!(!output.contains("IdentityFile="), "got: {}", output);
1232    }
1233
1234    #[test]
1235    fn upsert_preserves_equals_separator() {
1236        let mut config = parse_str(
1237            "Host myserver\n  IdentityFile=~/.ssh/id_rsa\n",
1238        );
1239        let entry = HostEntry {
1240            alias: "myserver".to_string(),
1241            hostname: "10.0.0.1".to_string(),
1242            identity_file: "~/.ssh/id_ed25519".to_string(),
1243            port: 22,
1244            ..Default::default()
1245        };
1246        config.update_host("myserver", &entry);
1247        let output = config.serialize();
1248        assert!(output.contains("IdentityFile=~/.ssh/id_ed25519"), "got: {}", output);
1249    }
1250
1251    #[test]
1252    fn upsert_preserves_spaced_equals_separator() {
1253        let mut config = parse_str(
1254            "Host myserver\n  IdentityFile = ~/.ssh/id_rsa\n",
1255        );
1256        let entry = HostEntry {
1257            alias: "myserver".to_string(),
1258            hostname: "10.0.0.1".to_string(),
1259            identity_file: "~/.ssh/id_ed25519".to_string(),
1260            port: 22,
1261            ..Default::default()
1262        };
1263        config.update_host("myserver", &entry);
1264        let output = config.serialize();
1265        assert!(output.contains("IdentityFile = ~/.ssh/id_ed25519"), "got: {}", output);
1266    }
1267
1268    #[test]
1269    fn is_included_host_false_for_main_config() {
1270        let config = parse_str(
1271            "Host myserver\n  HostName 10.0.0.1\n",
1272        );
1273        assert!(!config.is_included_host("myserver"));
1274    }
1275
1276    #[test]
1277    fn is_included_host_false_for_nonexistent() {
1278        let config = parse_str(
1279            "Host myserver\n  HostName 10.0.0.1\n",
1280        );
1281        assert!(!config.is_included_host("nohost"));
1282    }
1283
1284    #[test]
1285    fn is_included_host_multi_pattern_main_config() {
1286        let config = parse_str(
1287            "Host prod staging\n  HostName 10.0.0.1\n",
1288        );
1289        assert!(!config.is_included_host("prod"));
1290        assert!(!config.is_included_host("staging"));
1291    }
1292}