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