purple_ssh/providers/sync.rs
1use std::collections::HashMap;
2
3use crate::ssh_config::model::{ConfigElement, HostEntry, SshConfigFile};
4
5use super::config::ProviderSection;
6use super::{Provider, ProviderHost};
7
8/// Result of a sync operation.
9#[derive(Debug, Default)]
10pub struct SyncResult {
11 pub added: usize,
12 pub updated: usize,
13 pub removed: usize,
14 pub unchanged: usize,
15 /// Hosts marked stale (disappeared from provider but not hard-deleted).
16 pub stale: usize,
17 /// Alias renames: (old_alias, new_alias) pairs.
18 pub renames: Vec<(String, String)>,
19}
20
21/// Sanitize a server name into a valid SSH alias component.
22/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
23/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
24fn sanitize_name(name: &str) -> String {
25 let mut result = String::new();
26 for c in name.chars() {
27 if c.is_ascii_alphanumeric() {
28 result.push(c.to_ascii_lowercase());
29 } else if !result.ends_with('-') {
30 result.push('-');
31 }
32 }
33 let trimmed = result.trim_matches('-').to_string();
34 if trimmed.is_empty() {
35 "server".to_string()
36 } else {
37 trimmed
38 }
39}
40
41/// Build an alias from prefix + sanitized name.
42/// If prefix is empty, uses just the sanitized name (no leading hyphen).
43fn build_alias(prefix: &str, sanitized: &str) -> String {
44 if prefix.is_empty() {
45 sanitized.to_string()
46 } else {
47 format!("{}-{}", prefix, sanitized)
48 }
49}
50
51/// Whether a metadata key is volatile (changes frequently without user action).
52/// Volatile keys are excluded from the sync diff comparison so that a status
53/// change alone does not trigger an SSH config rewrite. The value is still
54/// stored and displayed when the host is updated for other reasons.
55fn is_volatile_meta(key: &str) -> bool {
56 key == "status"
57}
58
59/// Sync hosts from a cloud provider into the SSH config.
60/// Provider tags are always stored in `# purple:provider_tags` and exactly
61/// mirror the remote state. User tags in `# purple:tags` are preserved.
62pub fn sync_provider(
63 config: &mut SshConfigFile,
64 provider: &dyn Provider,
65 remote_hosts: &[ProviderHost],
66 section: &ProviderSection,
67 remove_deleted: bool,
68 suppress_stale: bool,
69 dry_run: bool,
70) -> SyncResult {
71 let mut result = SyncResult::default();
72
73 // Build map of server_id -> alias (top-level only, no Include files).
74 // Keep first occurrence if duplicate provider markers exist (e.g. manual copy).
75 let existing = config.find_hosts_by_provider(provider.name());
76 let mut existing_map: HashMap<String, String> = HashMap::new();
77 for (alias, server_id) in &existing {
78 existing_map
79 .entry(server_id.clone())
80 .or_insert_with(|| alias.clone());
81 }
82
83 // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
84 let entries_map: HashMap<String, HostEntry> = config
85 .host_entries()
86 .into_iter()
87 .map(|e| (e.alias.clone(), e))
88 .collect();
89
90 // Track which server IDs are still in the remote set (also deduplicates)
91 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
92
93 // Only add group header if this provider has no existing hosts in config
94 let mut needs_header = !dry_run && existing_map.is_empty();
95
96 for remote in remote_hosts {
97 if !remote_ids.insert(remote.server_id.clone()) {
98 continue; // Skip duplicate server_id in same response
99 }
100
101 // Empty IP means the resource exists but has no resolvable address
102 // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
103 // won't delete it, but skip add/update. Still clear stale if the host
104 // reappeared (it exists in the provider, just has no IP).
105 if remote.ip.is_empty() {
106 if let Some(alias) = existing_map.get(&remote.server_id) {
107 if let Some(entry) = entries_map.get(alias.as_str()) {
108 if entry.stale.is_some() {
109 if !dry_run {
110 config.clear_host_stale(alias);
111 }
112 result.updated += 1;
113 continue;
114 }
115 }
116 result.unchanged += 1;
117 }
118 continue;
119 }
120
121 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
122 // Host exists, check if alias, IP or tags changed
123 if let Some(entry) = entries_map.get(existing_alias) {
124 // Included hosts are read-only; recognize them for dedup but skip mutations
125 if entry.source_file.is_some() {
126 result.unchanged += 1;
127 continue;
128 }
129
130 // Host reappeared: clear stale marking
131 let was_stale = entry.stale.is_some();
132 if was_stale && !dry_run {
133 config.clear_host_stale(existing_alias);
134 }
135
136 // Check if alias prefix changed (e.g. "do" → "ocean")
137 let sanitized = sanitize_name(&remote.name);
138 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
139 let alias_changed = *existing_alias != expected_alias;
140
141 let ip_changed = entry.hostname != remote.ip;
142 let meta_changed = {
143 let mut local: Vec<(&str, &str)> = entry
144 .provider_meta
145 .iter()
146 .filter(|(k, _)| !is_volatile_meta(k))
147 .map(|(k, v)| (k.as_str(), v.as_str()))
148 .collect();
149 local.sort();
150 let mut remote_m: Vec<(&str, &str)> = remote
151 .metadata
152 .iter()
153 .filter(|(k, _)| !is_volatile_meta(k))
154 .map(|(k, v)| (k.as_str(), v.as_str()))
155 .collect();
156 remote_m.sort();
157 local != remote_m
158 };
159 let trimmed_remote: Vec<String> =
160 remote.tags.iter().map(|t| t.trim().to_string()).collect();
161 let tags_changed = {
162 // Compare provider_tags with remote (case-insensitive, sorted)
163 let mut sorted_local: Vec<String> = entry
164 .provider_tags
165 .iter()
166 .map(|t| t.trim().to_lowercase())
167 .collect();
168 sorted_local.sort();
169 let mut sorted_remote: Vec<String> =
170 trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
171 sorted_remote.sort();
172 sorted_local != sorted_remote
173 };
174 // First migration: host has old-format tags (# purple:tags) but
175 // no # purple:provider_tags comment yet. Tags need splitting.
176 let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
177
178 // After first migration: check if user tags overlap with provider tags
179 let user_tags_overlap = !first_migration
180 && !trimmed_remote.is_empty()
181 && entry.tags.iter().any(|t| {
182 trimmed_remote
183 .iter()
184 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
185 });
186
187 if alias_changed
188 || ip_changed
189 || tags_changed
190 || meta_changed
191 || user_tags_overlap
192 || first_migration
193 || was_stale
194 {
195 if dry_run {
196 result.updated += 1;
197 } else {
198 // Compute the final alias (dedup handles collisions,
199 // excluding the host being renamed so it doesn't collide with itself)
200 let new_alias = if alias_changed {
201 config
202 .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
203 } else {
204 existing_alias.clone()
205 };
206 // Re-evaluate: dedup may resolve back to the current alias
207 let alias_changed = new_alias != *existing_alias;
208
209 if alias_changed
210 || ip_changed
211 || tags_changed
212 || meta_changed
213 || user_tags_overlap
214 || first_migration
215 || was_stale
216 {
217 if alias_changed || ip_changed {
218 let updated = HostEntry {
219 alias: new_alias.clone(),
220 hostname: remote.ip.clone(),
221 ..entry.clone()
222 };
223 config.update_host(existing_alias, &updated);
224 }
225 // Tags lookup uses the new alias after rename
226 let tags_alias = if alias_changed {
227 &new_alias
228 } else {
229 existing_alias
230 };
231 if tags_changed || first_migration {
232 config.set_host_provider_tags(tags_alias, &trimmed_remote);
233 }
234 // Migration cleanup
235 if first_migration {
236 // First migration: old # purple:tags had both provider
237 // and user tags mixed. Keep only tags NOT in remote
238 // (those must be user-added). Provider tags move to
239 // # purple:provider_tags.
240 let user_only: Vec<String> = entry
241 .tags
242 .iter()
243 .filter(|t| {
244 !trimmed_remote
245 .iter()
246 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
247 })
248 .cloned()
249 .collect();
250 config.set_host_tags(tags_alias, &user_only);
251 } else if tags_changed || user_tags_overlap {
252 // Ongoing: remove user tags that overlap with provider tags
253 let cleaned: Vec<String> = entry
254 .tags
255 .iter()
256 .filter(|t| {
257 !trimmed_remote
258 .iter()
259 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
260 })
261 .cloned()
262 .collect();
263 if cleaned.len() != entry.tags.len() {
264 config.set_host_tags(tags_alias, &cleaned);
265 }
266 }
267 // Update provider marker with new alias
268 if alias_changed {
269 config.set_host_provider(
270 &new_alias,
271 provider.name(),
272 &remote.server_id,
273 );
274 result
275 .renames
276 .push((existing_alias.clone(), new_alias.clone()));
277 }
278 // Update metadata
279 if meta_changed {
280 config.set_host_meta(tags_alias, &remote.metadata);
281 }
282 result.updated += 1;
283 } else {
284 result.unchanged += 1;
285 }
286 }
287 } else {
288 result.unchanged += 1;
289 }
290 } else {
291 result.unchanged += 1;
292 }
293 } else {
294 // New host
295 let sanitized = sanitize_name(&remote.name);
296 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
297 let alias = if dry_run {
298 base_alias
299 } else {
300 config.deduplicate_alias(&base_alias)
301 };
302
303 if !dry_run {
304 // Add group header before the very first host for this provider
305 let wrote_header = needs_header;
306 if needs_header {
307 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
308 config
309 .elements
310 .push(ConfigElement::GlobalLine(String::new()));
311 }
312 config.elements.push(ConfigElement::GlobalLine(format!(
313 "# purple:group {}",
314 super::provider_display_name(provider.name())
315 )));
316 needs_header = false;
317 }
318
319 let entry = HostEntry {
320 alias: alias.clone(),
321 hostname: remote.ip.clone(),
322 user: section.user.clone(),
323 identity_file: section.identity_file.clone(),
324 provider: Some(provider.name().to_string()),
325 ..Default::default()
326 };
327
328 let block = SshConfigFile::entry_to_block(&entry);
329
330 // Insert adjacent to existing provider hosts (keeps groups together).
331 // For the very first host (wrote_header), fall through to push at end.
332 let insert_pos = if !wrote_header {
333 config.find_provider_insert_position(provider.name())
334 } else {
335 None
336 };
337
338 if let Some(pos) = insert_pos {
339 // Insert after last provider host with blank line separation.
340 config.elements.insert(pos, ConfigElement::HostBlock(block));
341 // Ensure blank line after the new block if the next element
342 // is not already a blank (prevents hosts running into group
343 // headers or other host blocks without visual separation).
344 let after = pos + 1;
345 let needs_trailing_blank = config.elements.get(after).is_some_and(
346 |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
347 );
348 if needs_trailing_blank {
349 config
350 .elements
351 .insert(after, ConfigElement::GlobalLine(String::new()));
352 }
353 } else {
354 // No existing group or first host: append at end with separator
355 if !wrote_header
356 && !config.elements.is_empty()
357 && !config.last_element_has_trailing_blank()
358 {
359 config
360 .elements
361 .push(ConfigElement::GlobalLine(String::new()));
362 }
363 config.elements.push(ConfigElement::HostBlock(block));
364 }
365
366 config.set_host_provider(&alias, provider.name(), &remote.server_id);
367 if !remote.tags.is_empty() {
368 config.set_host_provider_tags(&alias, &remote.tags);
369 }
370 if !remote.metadata.is_empty() {
371 config.set_host_meta(&alias, &remote.metadata);
372 }
373 }
374
375 result.added += 1;
376 }
377 }
378
379 // Remove deleted hosts (skip included hosts which are read-only)
380 if remove_deleted && !dry_run {
381 let to_remove: Vec<String> = existing_map
382 .iter()
383 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
384 .filter(|(_, alias)| {
385 entries_map
386 .get(alias.as_str())
387 .is_none_or(|e| e.source_file.is_none())
388 })
389 .map(|(_, alias)| alias.clone())
390 .collect();
391 for alias in &to_remove {
392 config.delete_host(alias);
393 }
394 result.removed = to_remove.len();
395
396 // Clean up orphan provider header if all hosts for this provider were removed
397 if config.find_hosts_by_provider(provider.name()).is_empty() {
398 let header_text = format!(
399 "# purple:group {}",
400 super::provider_display_name(provider.name())
401 );
402 config
403 .elements
404 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
405 }
406 } else if remove_deleted {
407 result.removed = existing_map
408 .iter()
409 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
410 .filter(|(_, alias)| {
411 entries_map
412 .get(alias.as_str())
413 .is_none_or(|e| e.source_file.is_none())
414 })
415 .count();
416 }
417
418 // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
419 if !remove_deleted && !suppress_stale {
420 let to_stale: Vec<String> = existing_map
421 .iter()
422 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
423 .filter(|(_, alias)| {
424 entries_map
425 .get(alias.as_str())
426 .is_none_or(|e| e.source_file.is_none())
427 })
428 .map(|(_, alias)| alias.clone())
429 .collect();
430 if !dry_run {
431 let now = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .unwrap_or_default()
434 .as_secs();
435 for alias in &to_stale {
436 // Preserve original timestamp if already stale
437 if entries_map
438 .get(alias.as_str())
439 .is_none_or(|e| e.stale.is_none())
440 {
441 config.set_host_stale(alias, now);
442 }
443 }
444 }
445 result.stale = to_stale.len();
446 }
447
448 result
449}
450
451#[cfg(test)]
452#[path = "sync_tests.rs"]
453mod tests;