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