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}
10
11/// An Include directive that references other config files.
12#[derive(Debug, Clone)]
13#[allow(dead_code)]
14pub struct IncludeDirective {
15    pub raw_line: String,
16    pub pattern: String,
17    pub resolved_files: Vec<IncludedFile>,
18}
19
20/// A file resolved from an Include directive.
21#[derive(Debug, Clone)]
22pub struct IncludedFile {
23    pub path: PathBuf,
24    pub elements: Vec<ConfigElement>,
25}
26
27/// A single element in the config file.
28#[derive(Debug, Clone)]
29pub enum ConfigElement {
30    /// A Host block: the "Host <pattern>" line plus all indented directives.
31    HostBlock(HostBlock),
32    /// A comment, blank line, or global directive not inside a Host block.
33    GlobalLine(String),
34    /// An Include directive referencing other config files (read-only).
35    Include(IncludeDirective),
36}
37
38/// A parsed Host block with its directives.
39#[derive(Debug, Clone)]
40pub struct HostBlock {
41    /// The host alias/pattern (the value after "Host").
42    pub host_pattern: String,
43    /// The original raw "Host ..." line for faithful reproduction.
44    pub raw_host_line: String,
45    /// Parsed directives inside this block.
46    pub directives: Vec<Directive>,
47}
48
49/// A directive line inside a Host block.
50#[derive(Debug, Clone)]
51pub struct Directive {
52    /// The directive key (e.g., "HostName", "User", "Port").
53    pub key: String,
54    /// The directive value.
55    pub value: String,
56    /// The original raw line (preserves indentation, inline comments).
57    pub raw_line: String,
58    /// Whether this is a comment-only or blank line inside a host block.
59    pub is_non_directive: bool,
60}
61
62/// Convenience view for the TUI — extracted from a HostBlock.
63#[derive(Debug, Clone, Default)]
64pub struct HostEntry {
65    pub alias: String,
66    pub hostname: String,
67    pub user: String,
68    pub port: u16,
69    pub identity_file: String,
70    pub proxy_jump: String,
71    /// If this host comes from an included file, the file path.
72    pub source_file: Option<PathBuf>,
73    /// Tags from purple:tags comment.
74    pub tags: Vec<String>,
75}
76
77impl HostEntry {
78    /// Build the SSH command string for this host (e.g. "ssh myserver").
79    pub fn ssh_command(&self) -> String {
80        format!("ssh {}", self.alias)
81    }
82}
83
84impl HostBlock {
85    /// Index of the first trailing blank line (for inserting content before separators).
86    fn content_end(&self) -> usize {
87        let mut pos = self.directives.len();
88        while pos > 0 {
89            if self.directives[pos - 1].is_non_directive
90                && self.directives[pos - 1].raw_line.trim().is_empty()
91            {
92                pos -= 1;
93            } else {
94                break;
95            }
96        }
97        pos
98    }
99
100    /// Remove and return trailing blank lines.
101    fn pop_trailing_blanks(&mut self) -> Vec<Directive> {
102        let end = self.content_end();
103        self.directives.drain(end..).collect()
104    }
105
106    /// Ensure exactly one trailing blank line.
107    fn ensure_trailing_blank(&mut self) {
108        self.pop_trailing_blanks();
109        self.directives.push(Directive {
110            key: String::new(),
111            value: String::new(),
112            raw_line: String::new(),
113            is_non_directive: true,
114        });
115    }
116
117    /// Detect indentation used by existing directives (falls back to "  ").
118    fn detect_indent(&self) -> String {
119        for d in &self.directives {
120            if !d.is_non_directive && !d.raw_line.is_empty() {
121                let trimmed = d.raw_line.trim_start();
122                let indent_len = d.raw_line.len() - trimmed.len();
123                if indent_len > 0 {
124                    return d.raw_line[..indent_len].to_string();
125                }
126            }
127        }
128        "  ".to_string()
129    }
130
131    /// Extract tags from purple:tags comment in directives.
132    pub fn tags(&self) -> Vec<String> {
133        for d in &self.directives {
134            if d.is_non_directive {
135                let trimmed = d.raw_line.trim();
136                if let Some(rest) = trimmed.strip_prefix("# purple:tags ") {
137                    return rest
138                        .split(',')
139                        .map(|t| t.trim().to_string())
140                        .filter(|t| !t.is_empty())
141                        .collect();
142                }
143            }
144        }
145        Vec::new()
146    }
147
148    /// Set tags on a host block. Replaces existing purple:tags comment or adds one.
149    pub fn set_tags(&mut self, tags: &[String]) {
150        let indent = self.detect_indent();
151        self.directives.retain(|d| {
152            !(d.is_non_directive && d.raw_line.trim().starts_with("# purple:tags"))
153        });
154        if !tags.is_empty() {
155            let pos = self.content_end();
156            self.directives.insert(
157                pos,
158                Directive {
159                    key: String::new(),
160                    value: String::new(),
161                    raw_line: format!("{}# purple:tags {}", indent, tags.join(",")),
162                    is_non_directive: true,
163                },
164            );
165        }
166    }
167
168    /// Extract a convenience HostEntry view from this block.
169    pub fn to_host_entry(&self) -> HostEntry {
170        let mut entry = HostEntry {
171            alias: self.host_pattern.clone(),
172            port: 22,
173            ..Default::default()
174        };
175        for d in &self.directives {
176            if d.is_non_directive {
177                continue;
178            }
179            match d.key.to_lowercase().as_str() {
180                "hostname" => entry.hostname = d.value.clone(),
181                "user" => entry.user = d.value.clone(),
182                "port" => entry.port = d.value.parse().unwrap_or(22),
183                "identityfile" => entry.identity_file = d.value.clone(),
184                "proxyjump" => entry.proxy_jump = d.value.clone(),
185                _ => {}
186            }
187        }
188        entry.tags = self.tags();
189        entry
190    }
191}
192
193impl SshConfigFile {
194    /// Get all host entries as convenience views (including from Include files).
195    pub fn host_entries(&self) -> Vec<HostEntry> {
196        Self::collect_host_entries(&self.elements)
197    }
198
199    /// Recursively collect host entries from a list of elements.
200    fn collect_host_entries(elements: &[ConfigElement]) -> Vec<HostEntry> {
201        let mut entries = Vec::new();
202        for e in elements {
203            match e {
204                ConfigElement::HostBlock(block) => {
205                    // Skip wildcard/multi patterns (*, ?, space-separated)
206                    if block.host_pattern.contains('*')
207                        || block.host_pattern.contains('?')
208                        || block.host_pattern.contains(' ')
209                    {
210                        continue;
211                    }
212                    entries.push(block.to_host_entry());
213                }
214                ConfigElement::Include(include) => {
215                    for file in &include.resolved_files {
216                        let mut file_entries = Self::collect_host_entries(&file.elements);
217                        for entry in &mut file_entries {
218                            if entry.source_file.is_none() {
219                                entry.source_file = Some(file.path.clone());
220                            }
221                        }
222                        entries.extend(file_entries);
223                    }
224                }
225                ConfigElement::GlobalLine(_) => {}
226            }
227        }
228        entries
229    }
230
231    /// Check if a host alias already exists (including in Include files).
232    /// Walks the element tree directly without building HostEntry structs.
233    pub fn has_host(&self, alias: &str) -> bool {
234        Self::has_host_in_elements(&self.elements, alias)
235    }
236
237    fn has_host_in_elements(elements: &[ConfigElement], alias: &str) -> bool {
238        for e in elements {
239            match e {
240                ConfigElement::HostBlock(block) => {
241                    if block.host_pattern == alias {
242                        return true;
243                    }
244                }
245                ConfigElement::Include(include) => {
246                    for file in &include.resolved_files {
247                        if Self::has_host_in_elements(&file.elements, alias) {
248                            return true;
249                        }
250                    }
251                }
252                ConfigElement::GlobalLine(_) => {}
253            }
254        }
255        false
256    }
257
258    /// Add a new host entry to the config.
259    pub fn add_host(&mut self, entry: &HostEntry) {
260        let block = Self::entry_to_block(entry);
261        // Add a blank line separator if the file isn't empty and doesn't already end with one
262        if !self.elements.is_empty() && !self.last_element_has_trailing_blank() {
263            self.elements
264                .push(ConfigElement::GlobalLine(String::new()));
265        }
266        self.elements.push(ConfigElement::HostBlock(block));
267    }
268
269    /// Check if the last element already ends with a blank line.
270    fn last_element_has_trailing_blank(&self) -> bool {
271        match self.elements.last() {
272            Some(ConfigElement::HostBlock(block)) => block
273                .directives
274                .last()
275                .is_some_and(|d| d.is_non_directive && d.raw_line.trim().is_empty()),
276            Some(ConfigElement::GlobalLine(line)) => line.trim().is_empty(),
277            _ => false,
278        }
279    }
280
281    /// Update an existing host entry by alias.
282    /// Merges changes into the existing block, preserving unknown directives.
283    pub fn update_host(&mut self, old_alias: &str, entry: &HostEntry) {
284        for element in &mut self.elements {
285            if let ConfigElement::HostBlock(block) = element {
286                if block.host_pattern == old_alias {
287                    // Update host pattern
288                    block.host_pattern = entry.alias.clone();
289                    block.raw_host_line = format!("Host {}", entry.alias);
290
291                    // Merge known directives (update existing, add missing, remove empty)
292                    Self::upsert_directive(block, "HostName", &entry.hostname);
293                    Self::upsert_directive(block, "User", &entry.user);
294                    if entry.port != 22 {
295                        Self::upsert_directive(block, "Port", &entry.port.to_string());
296                    } else {
297                        // Remove explicit Port 22 (it's the default)
298                        block
299                            .directives
300                            .retain(|d| d.is_non_directive || d.key.to_lowercase() != "port");
301                    }
302                    Self::upsert_directive(block, "IdentityFile", &entry.identity_file);
303                    Self::upsert_directive(block, "ProxyJump", &entry.proxy_jump);
304                    return;
305                }
306            }
307        }
308    }
309
310    /// Update a directive in-place, add it if missing, or remove it if value is empty.
311    fn upsert_directive(block: &mut HostBlock, key: &str, value: &str) {
312        if value.is_empty() {
313            block
314                .directives
315                .retain(|d| d.is_non_directive || d.key.to_lowercase() != key.to_lowercase());
316            return;
317        }
318        let indent = block.detect_indent();
319        for d in &mut block.directives {
320            if !d.is_non_directive && d.key.to_lowercase() == key.to_lowercase() {
321                d.value = value.to_string();
322                // Preserve original key casing for round-trip fidelity
323                d.raw_line = format!("{}{} {}", indent, d.key, value);
324                return;
325            }
326        }
327        // Not found — insert before trailing blanks
328        let pos = block.content_end();
329        block.directives.insert(
330            pos,
331            Directive {
332                key: key.to_string(),
333                value: value.to_string(),
334                raw_line: format!("{}{} {}", indent, key, value),
335                is_non_directive: false,
336            },
337        );
338    }
339
340    /// Set tags on a host block by alias.
341    pub fn set_host_tags(&mut self, alias: &str, tags: &[String]) {
342        for element in &mut self.elements {
343            if let ConfigElement::HostBlock(block) = element {
344                if block.host_pattern == alias {
345                    block.set_tags(tags);
346                    return;
347                }
348            }
349        }
350    }
351
352    /// Delete a host entry by alias.
353    #[allow(dead_code)]
354    pub fn delete_host(&mut self, alias: &str) {
355        self.elements.retain(|e| match e {
356            ConfigElement::HostBlock(block) => block.host_pattern != alias,
357            _ => true,
358        });
359        // Collapse consecutive blank lines left by deletion
360        self.elements.dedup_by(|a, b| {
361            matches!(
362                (&*a, &*b),
363                (ConfigElement::GlobalLine(x), ConfigElement::GlobalLine(y))
364                if x.trim().is_empty() && y.trim().is_empty()
365            )
366        });
367    }
368
369    /// Delete a host and return the removed element and its position for undo.
370    /// Does NOT collapse blank lines so the position stays valid for re-insertion.
371    pub fn delete_host_undoable(&mut self, alias: &str) -> Option<(ConfigElement, usize)> {
372        let pos = self.elements.iter().position(|e| {
373            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias)
374        })?;
375        let element = self.elements.remove(pos);
376        Some((element, pos))
377    }
378
379    /// Insert a host block at a specific position (for undo).
380    pub fn insert_host_at(&mut self, element: ConfigElement, position: usize) {
381        let pos = position.min(self.elements.len());
382        self.elements.insert(pos, element);
383    }
384
385    /// Swap two host blocks in the config by alias. Returns true if swap was performed.
386    #[allow(dead_code)]
387    pub fn swap_hosts(&mut self, alias_a: &str, alias_b: &str) -> bool {
388        let pos_a = self.elements.iter().position(|e| {
389            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_a)
390        });
391        let pos_b = self.elements.iter().position(|e| {
392            matches!(e, ConfigElement::HostBlock(b) if b.host_pattern == alias_b)
393        });
394        if let (Some(a), Some(b)) = (pos_a, pos_b) {
395            let (first, second) = (a.min(b), a.max(b));
396
397            // Strip trailing blanks from both blocks before swap
398            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
399                block.pop_trailing_blanks();
400            }
401            if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
402                block.pop_trailing_blanks();
403            }
404
405            // Swap
406            self.elements.swap(first, second);
407
408            // Add trailing blank to first block (separator between the two)
409            if let ConfigElement::HostBlock(block) = &mut self.elements[first] {
410                block.ensure_trailing_blank();
411            }
412
413            // Add trailing blank to second only if not the last element
414            if second < self.elements.len() - 1 {
415                if let ConfigElement::HostBlock(block) = &mut self.elements[second] {
416                    block.ensure_trailing_blank();
417                }
418            }
419
420            return true;
421        }
422        false
423    }
424
425    /// Convert a HostEntry into a new HostBlock with clean formatting.
426    fn entry_to_block(entry: &HostEntry) -> HostBlock {
427        let mut directives = Vec::new();
428
429        if !entry.hostname.is_empty() {
430            directives.push(Directive {
431                key: "HostName".to_string(),
432                value: entry.hostname.clone(),
433                raw_line: format!("  HostName {}", entry.hostname),
434                is_non_directive: false,
435            });
436        }
437        if !entry.user.is_empty() {
438            directives.push(Directive {
439                key: "User".to_string(),
440                value: entry.user.clone(),
441                raw_line: format!("  User {}", entry.user),
442                is_non_directive: false,
443            });
444        }
445        if entry.port != 22 {
446            directives.push(Directive {
447                key: "Port".to_string(),
448                value: entry.port.to_string(),
449                raw_line: format!("  Port {}", entry.port),
450                is_non_directive: false,
451            });
452        }
453        if !entry.identity_file.is_empty() {
454            directives.push(Directive {
455                key: "IdentityFile".to_string(),
456                value: entry.identity_file.clone(),
457                raw_line: format!("  IdentityFile {}", entry.identity_file),
458                is_non_directive: false,
459            });
460        }
461        if !entry.proxy_jump.is_empty() {
462            directives.push(Directive {
463                key: "ProxyJump".to_string(),
464                value: entry.proxy_jump.clone(),
465                raw_line: format!("  ProxyJump {}", entry.proxy_jump),
466                is_non_directive: false,
467            });
468        }
469
470        HostBlock {
471            host_pattern: entry.alias.clone(),
472            raw_host_line: format!("Host {}", entry.alias),
473            directives,
474        }
475    }
476}