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