Skip to main content

purple_ssh/ssh_config/
repair.rs

1//! Repair utilities for `# purple:group` comments in an SSH config file.
2//!
3//! Two problems this module fixes:
4//!
5//! 1. Provider group headers (`# purple:group <DisplayName>`) that end up
6//!    absorbed as non-directive lines inside a preceding Host block.
7//!    `repair_absorbed_group_comments` promotes them back to top-level
8//!    `GlobalLine` elements so they render correctly.
9//! 2. Orphaned group headers for providers that no longer have any hosts
10//!    configured. `remove_all_orphaned_group_headers` and
11//!    `remove_orphaned_group_header` drop those stale lines.
12
13use super::model::{ConfigElement, SshConfigFile};
14
15/// Display name for a provider used in `# purple:group` headers.
16/// Mirrors `providers::provider_display_name()` without a cross-module
17/// dependency.
18pub(super) fn provider_group_display_name(name: &str) -> &str {
19    match name {
20        "digitalocean" => "DigitalOcean",
21        "vultr" => "Vultr",
22        "linode" => "Linode",
23        "hetzner" => "Hetzner",
24        "upcloud" => "UpCloud",
25        "proxmox" => "Proxmox VE",
26        "aws" => "AWS EC2",
27        "scaleway" => "Scaleway",
28        "gcp" => "GCP",
29        "azure" => "Azure",
30        "tailscale" => "Tailscale",
31        "oracle" => "Oracle Cloud",
32        other => other,
33    }
34}
35
36impl SshConfigFile {
37    /// Remove all `# purple:group <DisplayName>` GlobalLines that point at a
38    /// provider with no remaining Host blocks. Returns the count removed.
39    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
40        let active_providers: std::collections::HashSet<String> = self
41            .elements
42            .iter()
43            .filter_map(|e| {
44                if let ConfigElement::HostBlock(block) = e {
45                    block
46                        .provider()
47                        .map(|(name, _)| provider_group_display_name(&name).to_string())
48                } else {
49                    None
50                }
51            })
52            .collect();
53
54        let mut removed = 0;
55        self.elements.retain(|e| {
56            if let ConfigElement::GlobalLine(line) = e {
57                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
58                    if !active_providers.contains(rest.trim()) {
59                        removed += 1;
60                        return false;
61                    }
62                }
63            }
64            true
65        });
66        removed
67    }
68
69    /// Repair configs where `# purple:group` comments were absorbed into the
70    /// preceding host block's directives instead of being stored as
71    /// GlobalLines. Returns the number of blocks that were repaired.
72    ///
73    /// Only relocates lines whose suffix matches a known provider's display
74    /// name. A user-authored comment like `# purple:group canary notes` is
75    /// left in place: the suffix `canary notes` is not a provider name, so
76    /// the comment is treated as free-form prose rather than a misparsed
77    /// group header. This protects hand-written comments from being silently
78    /// scrubbed by the next-startup `remove_all_orphaned_group_headers` pass.
79    pub fn repair_absorbed_group_comments(&mut self) -> usize {
80        // Build the set of known provider display names once. The list is
81        // closed under purple's supported providers and tiny, so allocation
82        // cost is negligible.
83        let known_providers: std::collections::HashSet<&str> = [
84            "DigitalOcean",
85            "Vultr",
86            "Linode",
87            "Hetzner",
88            "UpCloud",
89            "Proxmox VE",
90            "AWS EC2",
91            "Scaleway",
92            "GCP",
93            "Azure",
94            "Tailscale",
95            "Oracle Cloud",
96        ]
97        .iter()
98        .copied()
99        .collect();
100
101        let is_known_group = |raw: &str| -> bool {
102            raw.trim()
103                .strip_prefix("# purple:group ")
104                .map(|suffix| known_providers.contains(suffix.trim()))
105                .unwrap_or(false)
106        };
107
108        let mut repaired = 0;
109        let mut idx = 0;
110        while idx < self.elements.len() {
111            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
112                block
113                    .directives
114                    .iter()
115                    .any(|d| d.is_non_directive && is_known_group(&d.raw_line))
116            } else {
117                false
118            };
119
120            if !needs_repair {
121                idx += 1;
122                continue;
123            }
124
125            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
126                block
127            } else {
128                unreachable!()
129            };
130
131            let group_idx = block
132                .directives
133                .iter()
134                .position(|d| d.is_non_directive && is_known_group(&d.raw_line))
135                .unwrap();
136
137            let mut keep_end = group_idx;
138            while keep_end > 0
139                && block.directives[keep_end - 1].is_non_directive
140                && block.directives[keep_end - 1].raw_line.trim().is_empty()
141            {
142                keep_end -= 1;
143            }
144
145            let extracted: Vec<ConfigElement> = block
146                .directives
147                .drain(keep_end..)
148                .map(|d| ConfigElement::GlobalLine(d.raw_line))
149                .collect();
150
151            let insert_at = idx + 1;
152            for (i, elem) in extracted.into_iter().enumerate() {
153                self.elements.insert(insert_at + i, elem);
154            }
155
156            repaired += 1;
157            idx = insert_at;
158            while idx < self.elements.len() {
159                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
160                    break;
161                }
162                idx += 1;
163            }
164        }
165        repaired
166    }
167
168    /// Remove the `# purple:group <DisplayName>` GlobalLine for a single
169    /// provider if no remaining HostBlock has that provider.
170    pub(super) fn remove_orphaned_group_header(&mut self, provider_name: &str) {
171        if self.find_hosts_by_provider(provider_name).is_empty() {
172            let display = provider_group_display_name(provider_name);
173            let header = format!("# purple:group {}", display);
174            self.elements
175                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
176        }
177    }
178}