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}
80
81impl Default for HostEntry {
82    fn default() -> Self {
83        Self {
84            alias: String::new(),
85            hostname: String::new(),
86            user: String::new(),
87            port: 22,
88            identity_file: String::new(),
89            proxy_jump: String::new(),
90            source_file: None,
91            tags: Vec::new(),
92            provider: None,
93        }
94    }
95}
96
97impl HostEntry {
98    /// Build the SSH command string for this host (e.g. "ssh -- 'myserver'").
99    /// Shell-quotes the alias to prevent injection when pasted into a terminal.
100    pub fn ssh_command(&self) -> String {
101        let escaped = self.alias.replace('\'', "'\\''");
102        format!("ssh -- '{}'", escaped)
103    }
104}
105
106/// Returns true if the host pattern contains wildcards, character classes,
107/// negation or whitespace-separated multi-patterns (*, ?, [], !, space/tab).
108/// These are SSH match patterns, not concrete hosts.
109pub fn is_host_pattern(pattern: &str) -> bool {
110    pattern.contains('*')
111        || pattern.contains('?')
112        || pattern.contains('[')
113        || pattern.starts_with('!')
114        || pattern.contains(' ')
115        || pattern.contains('\t')
116}
117
118impl HostBlock {
119    /// Index of the first trailing blank line (for inserting content before separators).
120    fn content_end(&self) -> usize {
121        let mut pos = self.directives.len();
122        while pos > 0 {
123            if self.directives[pos - 1].is_non_directive
124                && self.directives[pos - 1].raw_line.trim().is_empty()
125            {
126                pos -= 1;
127            } else {
128                break;
129            }
130        }
131        pos
132    }
133
134    /// Remove and return trailing blank lines.
135    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
136        let end = self.content_end();
137        self.directives.drain(end..).collect()
138    }
139
140    /// Ensure exactly one trailing blank line.
141    fn ensure_trailing_blank(&mut self) {
142        self.pop_trailing_blanks();
143        self.directives.push(Directive {
144            key: String::new(),
145            value: String::new(),
146            raw_line: String::new(),
147            is_non_directive: true,
148        });
149    }
150
151    /// Detect indentation used by existing directives (falls back to "  ").
152    fn detect_indent(&self) -> String {
153        for d in &self.directives {
154            if !d.is_non_directive && !d.raw_line.is_empty() {
155                let trimmed = d.raw_line.trim_start();
156                let indent_len = d.raw_line.len() - trimmed.len();
157                if indent_len > 0 {
158                    return d.raw_line[..indent_len].to_string();
159                }
160            }
161        }
162        "  ".to_string()
163    }
164
165    /// Extract tags from purple:tags comment in directives.
166    pub fn tags(&self) -> Vec<String> {
167        for d in &self.directives {
168            if d.is_non_directive {
169                let trimmed = d.raw_line.trim();
170                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
171                    return rest
172                        .split(',')
173                        .map(|t| t.trim().to_string())
174                        .filter(|t| !t.is_empty())
175                        .collect();
176                }
177            }
178        }
179        Vec::new()
180    }
181
182    /// Extract provider info from purple:provider comment in directives.
183    /// Returns (provider_name, server_id), e.g. ("digitalocean", "412345678").
184    pub fn provider(&self) -> Option<(String, String)> {
185        for d in &self.directives {
186            if d.is_non_directive {
187                let trimmed = d.raw_line.trim();
188                if let Some(rest) = trimmed.strip_prefix("# purple:provider ") {
189                    if let Some((name, id)) = rest.split_once(':') {
190                        return Some((name.trim().to_string(), id.trim().to_string()));
191                    }
192                }
193            }
194        }
195        None
196    }
197
198    /// Set provider on a host block. Replaces existing purple:provider comment or adds one.
199    pub fn set_provider(&mut self, provider_name: &str, server_id: &str) {
200        let indent = self.detect_indent();
201        self.directives.retain(|d| {
202            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:provider"))
203        });
204        let pos = self.content_end();
205        self.directives.insert(
206            pos,
207            Directive {
208                key: String::new(),
209                value: String::new(),
210                raw_line: format!("{}# purple:provider {}:{}", indent, provider_name, server_id),
211                is_non_directive: true,
212            },
213        );
214    }
215
216    /// Set tags on a host block. Replaces existing purple:tags comment or adds one.
217    pub fn set_tags(&mut self, tags: &[String]) {
218        let indent = self.detect_indent();
219        self.directives.retain(|d| {
220            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
221        });
222        if !tags.is_empty() {
223            let pos = self.content_end();
224            self.directives.insert(
225                pos,
226                Directive {
227                    key: String::new(),
228                    value: String::new(),
229                    raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
230                    is_non_directive: true,
231                },
232            );
233        }
234    }
235
236    /// Extract a convenience HostEntry view from this block.
237    pub fn to_host_entry(&self) -> HostEntry {
238        let mut entry = HostEntry {
239            alias: self.host_pattern.clone(),
240            port: 22,
241            ..Default::default()
242        };
243        for d in &self.directives {
244            if d.is_non_directive {
245                continue;
246            }
247            if d.key.eq_ignore_ascii_case("hostname") {
248                entry.hostname = d.value.clone();
249            } else if d.key.eq_ignore_ascii_case("user") {
250                entry.user = d.value.clone();
251            } else if d.key.eq_ignore_ascii_case("port") {
252                entry.port = d.value.parse().unwrap_or(22);
253            } else if d.key.eq_ignore_ascii_case("identityfile") {
254                if entry.identity_file.is_empty() {
255                    entry.identity_file = d.value.clone();
256                }
257            } else if d.key.eq_ignore_ascii_case("proxyjump") {
258                entry.proxy_jump = d.value.clone();
259            }
260        }
261        entry.tags = self.tags();
262        entry.provider = self.provider().map(|(name, _)| name);
263        entry
264    }
265}
266
267impl SshConfigFile {
268    /// Get all host entries as convenience views (including from Include files).
269    pub fn host_entries(&self) -> Vec<HostEntry> {
270        let mut entries = Vec::new();
271        Self::collect_host_entries(&self.elements, &mut entries);
272        entries
273    }
274
275    /// Collect all resolved Include file paths (recursively).
276    pub fn include_paths(&self) -> Vec<PathBuf> {
277        let mut paths = Vec::new();
278        Self::collect_include_paths(&self.elements, &mut paths);
279        paths
280    }
281
282    fn collect_include_paths(elements: &[ConfigElement], paths: &mut Vec<PathBuf>) {
283        for e in elements {
284            if let ConfigElement::Include(include) = e {
285                for file in &include.resolved_files {
286                    paths.push(file.path.clone());
287                    Self::collect_include_paths(&file.elements, paths);
288                }
289            }
290        }
291    }
292
293    /// Collect parent directories of Include glob patterns.
294    /// When a file is added/removed under a glob dir, the directory's mtime changes.
295    pub fn include_glob_dirs(&self) -> Vec<PathBuf> {
296        let config_dir = self.path.parent();
297        let mut seen = std::collections::HashSet::new();
298        let mut dirs = Vec::new();
299        Self::collect_include_glob_dirs(&self.elements, config_dir, &mut seen, &mut dirs);
300        dirs
301    }
302
303    fn collect_include_glob_dirs(
304        elements: &[ConfigElement],
305        config_dir: Option<&std::path::Path>,
306        seen: &mut std::collections::HashSet<PathBuf>,
307        dirs: &mut Vec<PathBuf>,
308    ) {
309        for e in elements {
310            if let ConfigElement::Include(include) = e {
311                // Split on whitespace to handle multi-pattern Includes
312                // (same as resolve_include does)
313                for single in include.pattern.split_whitespace() {
314                    let expanded = Self::expand_tilde(single);
315                    let resolved = if expanded.starts_with('/') {
316                        PathBuf::from(&expanded)
317                    } else if let Some(dir) = config_dir {
318                        dir.join(&expanded)
319                    } else {
320                        continue;
321                    };
322                    if let Some(parent) = resolved.parent() {
323                        let parent = parent.to_path_buf();
324                        if seen.insert(parent.clone()) {
325                            dirs.push(parent);
326                        }
327                    }
328                }
329                // Recurse into resolved files
330                for file in &include.resolved_files {
331                    Self::collect_include_glob_dirs(
332                        &file.elements,
333                        file.path.parent(),
334                        seen,
335                        dirs,
336                    );
337                }
338            }
339        }
340    }
341
342
343    /// Recursively collect host entries from a list of elements.
344    fn collect_host_entries(elements: &[ConfigElement], entries: &mut Vec<HostEntry>) {
345        for e in elements {
346            match e {
347                ConfigElement::HostBlock(block) => {
348                    if is_host_pattern(&block.host_pattern) {
349                        continue;
350                    }
351                    entries.push(block.to_host_entry());
352                }
353                ConfigElement::Include(include) => {
354                    for file in &include.resolved_files {
355                        let start = entries.len();
356                        Self::collect_host_entries(&file.elements, entries);
357                        for entry in &mut entries[start..] {
358                            if entry.source_file.is_none() {
359                                entry.source_file = Some(file.path.clone());
360                            }
361                        }
362                    }
363                }
364                ConfigElement::GlobalLine(_) => {}
365            }
366        }
367    }
368
369    /// Check if a host alias already exists (including in Include files).
370    /// Walks the element tree directly without building HostEntry structs.
371    pub fn has_host(&self, alias: &str) -> bool {
372        Self::has_host_in_elements(&self.elements, alias)
373    }
374
375    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
376        for e in elements {
377            match e {
378                ConfigElement::HostBlock(block) => {
379                    if block.host_pattern.split_whitespace().any(|p| p == alias) {
380                        return true;
381                    }
382                }
383                ConfigElement::Include(include) => {
384                    for file in &include.resolved_files {
385                        if Self::has_host_in_elements(&file.elements, alias) {
386                            return true;
387                        }
388                    }
389                }
390                ConfigElement::GlobalLine(_) => {}
391            }
392        }
393        false
394    }
395
396    /// Add a new host entry to the config.
397    pub fn add_host(&mut self, entry: &HostEntry) {
398        let block = Self::entry_to_block(entry);
399        // Add a blank line separator if the file isn't empty and doesn't already end with one
400        if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
401            self.elements
402                .push(ConfigElement::GlobalLine(String::new()));
403        }
404        self.elements.push(ConfigElement::HostBlock(block));
405    }
406
407    /// Check if the last element already ends with a blank line.
408    pub fn last_element_has_trailing_blank(&self) -> bool {
409        match self.elements.last() {
410            Some(ConfigElement::HostBlock(block)) => block
411                .directives
412                .last()
413                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
414            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
415            _ => false,
416        }
417    }
418
419    /// Update an existing host entry by alias.
420    /// Merges changes into the existing block, preserving unknown directives.
421    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
422        for element in &mut self.elements {
423            if let ConfigElement::HostBlock(block) = element {
424                if block.host_pattern == old_alias {
425                    // Update host pattern (preserve raw_host_line when alias unchanged)
426                    if entry.alias != block.host_pattern {
427                        block.host_pattern = entry.alias.clone();
428                        block.raw_host_line = format!("Host {}", entry.alias);
429                    }
430
431                    // Merge known directives (update existing, add missing, remove empty)
432                    Self::upsert_directive(block, "HostName", &entry.hostname);
433                    Self::upsert_directive(block, "User", &entry.user);
434                    if entry.port != 22 {
435                        Self::upsert_directive(block, "Port", &entry.port.to_string());
436                    } else {
437                        // Remove explicit Port 22 (it's the default)
438                        block
439                            .directives
440                            .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case("port"));
441                    }
442                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
443                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
444                    return;
445                }
446            }
447        }
448    }
449
450    /// Update a directive in-place, add it if missing, or remove it if value is empty.
451    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
452        if value.is_empty() {
453            block
454                .directives
455                .retain(|d| d.is_non_directive || !d.key.eq_ignore_ascii_case(key));
456            return;
457        }
458        let indent = block.detect_indent();
459        for d in &mut block.directives {
460            if !d.is_non_directive && d.key.eq_ignore_ascii_case(key) {
461                // Only rebuild raw_line when value actually changed (preserves inline comments)
462                if d.value != value {
463                    d.value = value.to_string();
464                    // Detect equals-syntax from original raw_line and preserve it
465                    let trimmed = d.raw_line.trim_start();
466                    let after_key = &trimmed[d.key.len()..];
467                    let sep = if after_key.starts_with('=') { "=" } else { " " };
468                    d.raw_line = format!("{}{}{}{}", indent, d.key, sep, value);
469                }
470                return;
471            }
472        }
473        // Not found — insert before trailing blanks
474        let pos = block.content_end();
475        block.directives.insert(
476            pos,
477            Directive {
478                key: key.to_string(),
479                value: value.to_string(),
480                raw_line: format!("{}{} {}", indent, key, value),
481                is_non_directive: false,
482            },
483        );
484    }
485
486    /// Set provider on a host block by alias.
487    pub fn set_host_provider(&mut self, alias: &str, provider_name: &str, server_id: &str) {
488        for element in &mut self.elements {
489            if let ConfigElement::HostBlock(block) = element {
490                if block.host_pattern == alias {
491                    block.set_provider(provider_name, server_id);
492                    return;
493                }
494            }
495        }
496    }
497
498    /// Find all hosts with a specific provider, returning (alias, server_id) pairs.
499    /// Searches both top-level elements and Include files so that provider hosts
500    /// in included configs are recognized during sync (prevents duplicate additions).
501    pub fn find_hosts_by_provider(&self, provider_name: &str) -> Vec<(String, String)> {
502        let mut results = Vec::new();
503        Self::collect_provider_hosts(&self.elements, provider_name, &mut results);
504        results
505    }
506
507    fn collect_provider_hosts(
508        elements: &[ConfigElement],
509        provider_name: &str,
510        results: &mut Vec<(String, String)>,
511    ) {
512        for element in elements {
513            match element {
514                ConfigElement::HostBlock(block) => {
515                    if let Some((name, id)) = block.provider() {
516                        if name == provider_name {
517                            results.push((block.host_pattern.clone(), id));
518                        }
519                    }
520                }
521                ConfigElement::Include(include) => {
522                    for file in &include.resolved_files {
523                        Self::collect_provider_hosts(&file.elements, provider_name, results);
524                    }
525                }
526                ConfigElement::GlobalLine(_) => {}
527            }
528        }
529    }
530
531    /// Generate a unique alias by appending -2, -3, etc. if the base alias is taken.
532    pub fn deduplicate_alias(&self, base: &str) -> String {
533        if !self.has_host(base) {
534            return base.to_string();
535        }
536        for n in 2..=9999 {
537            let candidate = format!("{}-{}", base, n);
538            if !self.has_host(&candidate) {
539                return candidate;
540            }
541        }
542        // Practically unreachable: fall back to PID-based suffix
543        format!("{}-{}", base, std::process::id())
544    }
545
546    /// Set tags on a host block by alias.
547    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
548        for element in &mut self.elements {
549            if let ConfigElement::HostBlock(block) = element {
550                if block.host_pattern == alias {
551                    block.set_tags(tags);
552                    return;
553                }
554            }
555        }
556    }
557
558    /// Delete a host entry by alias.
559    #[allow(dead_code)]
560    pub fn delete_host(&mut self, alias: &str) {
561        self.elements.retain(|e| match e {
562            ConfigElement::HostBlock(block) => block.host_pattern != alias,
563            _ => true,
564        });
565        // Collapse consecutive blank lines left by deletion
566        self.elements.dedup_by(|a, b| {
567            matches!(
568                (&*a, &*b),
569                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
570                if x.trim().is_empty() && y.trim().is_empty()
571            )
572        });
573    }
574
575    /// Delete a host and return the removed element and its position for undo.
576    /// Does NOT collapse blank lines so the position stays valid for re-insertion.
577    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
578        let pos = self.elements.iter().position(|e| {
579            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
580        })?;
581        let element = self.elements.remove(pos);
582        Some((element, pos))
583    }
584
585    /// Insert a host block at a specific position (for undo).
586    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
587        let pos = position.min(self.elements.len());
588        self.elements.insert(pos, element);
589    }
590
591    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
592    #[allow(dead_code)]
593    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
594        let pos_a = self.elements.iter().position(|e| {
595            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
596        });
597        let pos_b = self.elements.iter().position(|e| {
598            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
599        });
600        if let (Some(a), Some(b)) = (pos_a, pos_b) {
601            if a == b {
602                return false;
603            }
604            let (first, second) = (a.min(b), a.max(b));
605
606            // Strip trailing blanks from both blocks before swap
607            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
608                block.pop_trailing_blanks();
609            }
610            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
611                block.pop_trailing_blanks();
612            }
613
614            // Swap
615            self.elements.swap(first, second);
616
617            // Add trailing blank to first block (separator between the two)
618            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
619                block.ensure_trailing_blank();
620            }
621
622            // Add trailing blank to second only if not the last element
623            if second < self.elements.len() - 1 {
624                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
625                    block.ensure_trailing_blank();
626                }
627            }
628
629            return true;
630        }
631        false
632    }
633
634    /// Convert a HostEntry into a new HostBlock with clean formatting.
635    pub(crate) fn entry_to_block(entry: &HostEntry) -> HostBlock {
636        let mut directives = Vec::new();
637
638        if !entry.hostname.is_empty() {
639            directives.push(Directive {
640                key: "HostName".to_string(),
641                value: entry.hostname.clone(),
642                raw_line: format!("  HostName {}", entry.hostname),
643                is_non_directive: false,
644            });
645        }
646        if !entry.user.is_empty() {
647            directives.push(Directive {
648                key: "User".to_string(),
649                value: entry.user.clone(),
650                raw_line: format!("  User {}", entry.user),
651                is_non_directive: false,
652            });
653        }
654        if entry.port != 22 {
655            directives.push(Directive {
656                key: "Port".to_string(),
657                value: entry.port.to_string(),
658                raw_line: format!("  Port {}", entry.port),
659                is_non_directive: false,
660            });
661        }
662        if !entry.identity_file.is_empty() {
663            directives.push(Directive {
664                key: "IdentityFile".to_string(),
665                value: entry.identity_file.clone(),
666                raw_line: format!("  IdentityFile {}", entry.identity_file),
667                is_non_directive: false,
668            });
669        }
670        if !entry.proxy_jump.is_empty() {
671            directives.push(Directive {
672                key: "ProxyJump".to_string(),
673                value: entry.proxy_jump.clone(),
674                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
675                is_non_directive: false,
676            });
677        }
678
679        HostBlock {
680            host_pattern: entry.alias.clone(),
681            raw_host_line: format!("Host {}", entry.alias),
682            directives,
683        }
684    }
685}