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