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. Delegates to
16/// the provider registry so the cleanup matcher and the sync writer can never
17/// disagree. A previous hand-maintained copy here drifted: ovh, i3d, leaseweb
18/// and transip were missing, so their headers were deleted on startup despite
19/// active hosts.
20pub(super) fn provider_group_display_name(name: &str) -> &str {
21    crate::providers::provider_display_name(name)
22}
23
24impl SshConfigFile {
25    /// Remove all `# purple:group <DisplayName>` GlobalLines that point at a
26    /// provider with no remaining Host blocks. Returns the count removed.
27    pub fn remove_all_orphaned_group_headers(&mut self) -> usize {
28        let active_providers: std::collections::HashSet<String> = self
29            .elements
30            .iter()
31            .filter_map(|e| {
32                if let ConfigElement::HostBlock(block) = e {
33                    block
34                        .provider()
35                        .map(|(name, _)| provider_group_display_name(&name).to_string())
36                } else {
37                    None
38                }
39            })
40            .collect();
41
42        let mut removed = 0;
43        self.elements.retain(|e| {
44            if let ConfigElement::GlobalLine(line) = e {
45                if let Some(rest) = line.trim().strip_prefix("# purple:group ") {
46                    if !active_providers.contains(rest.trim()) {
47                        removed += 1;
48                        return false;
49                    }
50                }
51            }
52            true
53        });
54        removed
55    }
56
57    /// Repair configs where `# purple:group` comments were absorbed into the
58    /// preceding host block's directives instead of being stored as
59    /// GlobalLines. Returns the number of blocks that were repaired.
60    ///
61    /// Only relocates lines whose suffix matches a known provider's display
62    /// name. A user-authored comment like `# purple:group canary notes` is
63    /// left in place: the suffix `canary notes` is not a provider name, so
64    /// the comment is treated as free-form prose rather than a misparsed
65    /// group header. This protects hand-written comments from being silently
66    /// scrubbed by the next-startup `remove_all_orphaned_group_headers` pass.
67    pub fn repair_absorbed_group_comments(&mut self) -> usize {
68        // Derive known provider display names from the registry so this matcher
69        // can never drift from the sync writer. A hand-maintained copy here
70        // previously omitted ovh, leaseweb, i3d and transip, so their absorbed
71        // group comments were never relocated to top-level GlobalLines.
72        let known_providers: std::collections::HashSet<&str> = crate::providers::PROVIDER_NAMES
73            .iter()
74            .map(|n| crate::providers::provider_display_name(n))
75            .collect();
76
77        let is_known_group = |raw: &str| -> bool {
78            raw.trim()
79                .strip_prefix("# purple:group ")
80                .map(|suffix| known_providers.contains(suffix.trim()))
81                .unwrap_or(false)
82        };
83
84        let mut repaired = 0;
85        let mut idx = 0;
86        while idx < self.elements.len() {
87            let needs_repair = if let ConfigElement::HostBlock(block) = &self.elements[idx] {
88                block
89                    .directives
90                    .iter()
91                    .any(|d| d.is_non_directive && is_known_group(&d.raw_line))
92            } else {
93                false
94            };
95
96            if !needs_repair {
97                idx += 1;
98                continue;
99            }
100
101            let block = if let ConfigElement::HostBlock(block) = &mut self.elements[idx] {
102                block
103            } else {
104                unreachable!()
105            };
106
107            let group_idx = block
108                .directives
109                .iter()
110                .position(|d| d.is_non_directive && is_known_group(&d.raw_line))
111                .unwrap();
112
113            let mut keep_end = group_idx;
114            while keep_end > 0
115                && block.directives[keep_end - 1].is_non_directive
116                && block.directives[keep_end - 1].raw_line.trim().is_empty()
117            {
118                keep_end -= 1;
119            }
120
121            let extracted: Vec<ConfigElement> = block
122                .directives
123                .drain(keep_end..)
124                .map(|d| ConfigElement::GlobalLine(d.raw_line))
125                .collect();
126
127            let insert_at = idx + 1;
128            for (i, elem) in extracted.into_iter().enumerate() {
129                self.elements.insert(insert_at + i, elem);
130            }
131
132            repaired += 1;
133            idx = insert_at;
134            while idx < self.elements.len() {
135                if let ConfigElement::HostBlock(_) = &self.elements[idx] {
136                    break;
137                }
138                idx += 1;
139            }
140        }
141        repaired
142    }
143
144    /// Remove the `# purple:group <DisplayName>` GlobalLine for a single
145    /// provider if no remaining HostBlock has that provider.
146    pub(super) fn remove_orphaned_group_header(&mut self, provider_name: &str) {
147        if self.find_hosts_by_provider(provider_name).is_empty() {
148            let display = provider_group_display_name(provider_name);
149            let header = format!("# purple:group {}", display);
150            self.elements
151                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if *line == header));
152        }
153    }
154}