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.
74 //
75 // Bare config: claim EVERY marker for this provider regardless of how
76 // many `:`-segments it has. Some providers' server_ids contain colons
77 // (Proxmox uses `qemu:300`, OCI compartment IDs are path-like) and the
78 // marker `# purple:provider proxmox:qemu:300` is ambiguous in isolation
79 // - it could be a labeled marker (`proxmox:qemu`, server_id=`300`) or a
80 // legacy 2-segment marker with a colon-bearing server_id. Since a bare
81 // section is by definition the only config for its provider (we reject
82 // bare+labeled mix), it must own all of those hosts to avoid duplication.
83 //
84 // Labeled config: scope to its exact ProviderConfigId so two labeled
85 // configs of the same provider don't clobber each other's diff.
86 let existing = if section.id.label.is_none() {
87 // Bare config: use raw 2-segment interpretation so server_ids with
88 // colons (Proxmox `qemu:300`, OCI compartment paths) match correctly
89 // against the API response and don't get duplicated as "missing".
90 config.find_hosts_by_provider_raw(provider.name())
91 } else {
92 config.find_hosts_by_id(§ion.id)
93 };
94 let mut existing_map: HashMap<String, String> = HashMap::new();
95 for (alias, server_id) in &existing {
96 existing_map
97 .entry(server_id.clone())
98 .or_insert_with(|| alias.clone());
99 }
100
101 // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
102 let entries_map: HashMap<String, HostEntry> = config
103 .host_entries()
104 .into_iter()
105 .map(|e| (e.alias.clone(), e))
106 .collect();
107
108 // Track which server IDs are still in the remote set (also deduplicates)
109 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
110
111 // Add a group header when this provider has no TOP-LEVEL host yet. The
112 // header and every synced host live at top level (Include files are
113 // read-only), so this must agree with find_provider_insert_position, which
114 // is also top-level only. Using the include-aware find_hosts_by_provider
115 // here left a new host header-less when the provider's existing hosts lived
116 // solely in an Include. Group headers are still shared across labeled
117 // configs of one provider: the second labeled config finds the first
118 // config's top-level host and skips a duplicate header.
119 let mut needs_header = !dry_run
120 && config
121 .find_provider_insert_position(provider.name())
122 .is_none();
123
124 for remote in remote_hosts {
125 if !remote_ids.insert(remote.server_id.clone()) {
126 continue; // Skip duplicate server_id in same response
127 }
128
129 // Empty IP means the resource exists but has no resolvable address
130 // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
131 // won't delete it, but skip add/update. Still clear stale if the host
132 // reappeared (it exists in the provider, just has no IP).
133 if remote.ip.is_empty() {
134 if let Some(alias) = existing_map.get(&remote.server_id) {
135 if let Some(entry) = entries_map.get(alias.as_str()) {
136 if entry.stale.is_some() {
137 if !dry_run {
138 let _ = config.clear_host_stale(alias);
139 }
140 result.updated += 1;
141 continue;
142 }
143 }
144 result.unchanged += 1;
145 }
146 continue;
147 }
148
149 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
150 // Host exists, check if alias, IP or tags changed
151 if let Some(entry) = entries_map.get(existing_alias) {
152 // Included hosts are read-only; recognize them for dedup but skip mutations
153 if entry.source_file.is_some() {
154 result.unchanged += 1;
155 continue;
156 }
157
158 // Host reappeared: clear stale marking
159 let was_stale = entry.stale.is_some();
160 if was_stale && !dry_run {
161 let _ = config.clear_host_stale(existing_alias);
162 }
163
164 // Check if alias prefix changed (e.g. "do" → "ocean")
165 let sanitized = sanitize_name(&remote.name);
166 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
167 let alias_changed = *existing_alias != expected_alias;
168
169 let ip_changed = entry.hostname != remote.ip;
170 let meta_changed = {
171 let mut local: Vec<(&str, &str)> = entry
172 .provider_meta
173 .iter()
174 .filter(|(k, _)| !is_volatile_meta(k))
175 .map(|(k, v)| (k.as_str(), v.as_str()))
176 .collect();
177 local.sort();
178 let mut remote_m: Vec<(&str, &str)> = remote
179 .metadata
180 .iter()
181 .filter(|(k, _)| !is_volatile_meta(k))
182 .map(|(k, v)| (k.as_str(), v.as_str()))
183 .collect();
184 remote_m.sort();
185 local != remote_m
186 };
187 let trimmed_remote: Vec<String> =
188 remote.tags.iter().map(|t| t.trim().to_string()).collect();
189 let tags_changed = {
190 // Compare provider_tags with remote (case-insensitive, sorted)
191 let mut sorted_local: Vec<String> = entry
192 .provider_tags
193 .iter()
194 .map(|t| t.trim().to_lowercase())
195 .collect();
196 sorted_local.sort();
197 let mut sorted_remote: Vec<String> =
198 trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
199 sorted_remote.sort();
200 sorted_local != sorted_remote
201 };
202 // First migration: host has old-format tags (# purple:tags) but
203 // no # purple:provider_tags comment yet. Tags need splitting.
204 let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
205
206 // After first migration: check if user tags overlap with provider tags
207 let user_tags_overlap = !first_migration
208 && !trimmed_remote.is_empty()
209 && entry.tags.iter().any(|t| {
210 trimmed_remote
211 .iter()
212 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
213 });
214
215 if alias_changed
216 || ip_changed
217 || tags_changed
218 || meta_changed
219 || user_tags_overlap
220 || first_migration
221 || was_stale
222 {
223 if dry_run {
224 result.updated += 1;
225 } else {
226 // Compute the final alias (dedup handles collisions,
227 // excluding the host being renamed so it doesn't collide with itself)
228 let new_alias = if alias_changed {
229 config
230 .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
231 } else {
232 existing_alias.clone()
233 };
234 // Re-evaluate: dedup may resolve back to the current alias
235 let alias_changed = new_alias != *existing_alias;
236
237 if alias_changed
238 || ip_changed
239 || tags_changed
240 || meta_changed
241 || user_tags_overlap
242 || first_migration
243 || was_stale
244 {
245 if alias_changed || ip_changed {
246 let updated = HostEntry {
247 alias: new_alias.clone(),
248 hostname: remote.ip.clone(),
249 ..entry.clone()
250 };
251 config.update_host(existing_alias, &updated);
252 }
253 // Tags lookup uses the new alias after rename
254 let tags_alias = if alias_changed {
255 &new_alias
256 } else {
257 existing_alias
258 };
259 if tags_changed || first_migration {
260 let _ = config.set_host_provider_tags(tags_alias, &trimmed_remote);
261 }
262 // Migration cleanup
263 if first_migration {
264 // First migration: old # purple:tags had both provider
265 // and user tags mixed. Keep only tags NOT in remote
266 // (those must be user-added). Provider tags move to
267 // # purple:provider_tags.
268 let user_only: Vec<String> = entry
269 .tags
270 .iter()
271 .filter(|t| {
272 !trimmed_remote
273 .iter()
274 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
275 })
276 .cloned()
277 .collect();
278 let _ = config.set_host_tags(tags_alias, &user_only);
279 } else if tags_changed || user_tags_overlap {
280 // Ongoing: remove user tags that overlap with provider tags
281 let cleaned: Vec<String> = entry
282 .tags
283 .iter()
284 .filter(|t| {
285 !trimmed_remote
286 .iter()
287 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
288 })
289 .cloned()
290 .collect();
291 if cleaned.len() != entry.tags.len() {
292 let _ = config.set_host_tags(tags_alias, &cleaned);
293 }
294 }
295 // Update provider marker with new alias.
296 // Use the section's full id so labeled configs
297 // emit 3-segment markers.
298 if alias_changed {
299 let _ = config.set_host_provider_id(
300 &new_alias,
301 §ion.id,
302 &remote.server_id,
303 );
304 result
305 .renames
306 .push((existing_alias.clone(), new_alias.clone()));
307 }
308 // Update metadata
309 if meta_changed {
310 let _ = config.set_host_meta(tags_alias, &remote.metadata);
311 }
312 result.updated += 1;
313 } else {
314 result.unchanged += 1;
315 }
316 }
317 } else {
318 result.unchanged += 1;
319 }
320 } else {
321 result.unchanged += 1;
322 }
323 } else {
324 // New host
325 let sanitized = sanitize_name(&remote.name);
326 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
327 let alias = if dry_run {
328 base_alias
329 } else {
330 config.deduplicate_alias(&base_alias)
331 };
332
333 if !dry_run {
334 // Add group header before the very first host for this provider
335 let wrote_header = needs_header;
336 if needs_header {
337 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
338 config
339 .elements
340 .push(ConfigElement::GlobalLine(String::new()));
341 }
342 config.elements.push(ConfigElement::GlobalLine(format!(
343 "# purple:group {}",
344 super::provider_display_name(provider.name())
345 )));
346 needs_header = false;
347 }
348
349 let entry = HostEntry {
350 alias: alias.clone(),
351 hostname: remote.ip.clone(),
352 user: section.user.clone(),
353 identity_file: section.identity_file.clone(),
354 provider: Some(provider.name().to_string()),
355 ..Default::default()
356 };
357
358 let block = SshConfigFile::entry_to_block(&entry);
359
360 // Insert adjacent to existing provider hosts (keeps groups together).
361 // For the very first host (wrote_header), fall through to push at end.
362 let insert_pos = if !wrote_header {
363 config.find_provider_insert_position(provider.name())
364 } else {
365 None
366 };
367
368 if let Some(pos) = insert_pos {
369 // Mirror add_host: guarantee a blank line BEFORE the new
370 // block (so consecutive synced hosts never glue together)
371 // and AFTER it (so it never runs into the next group header
372 // or host block).
373 let mut idx = pos;
374 let needs_blank_before = idx > 0
375 && !matches!(
376 config.elements.get(idx - 1),
377 Some(ConfigElement::GlobalLine(line)) if line.trim().is_empty()
378 );
379 if needs_blank_before {
380 config
381 .elements
382 .insert(idx, ConfigElement::GlobalLine(String::new()));
383 idx += 1;
384 }
385 config.elements.insert(idx, ConfigElement::HostBlock(block));
386 let after = idx + 1;
387 let needs_trailing_blank = config.elements.get(after).is_some_and(
388 |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
389 );
390 if needs_trailing_blank {
391 config
392 .elements
393 .insert(after, ConfigElement::GlobalLine(String::new()));
394 }
395 } else {
396 // No existing group or first host: append at end with separator
397 if !wrote_header
398 && !config.elements.is_empty()
399 && !config.last_element_has_trailing_blank()
400 {
401 config
402 .elements
403 .push(ConfigElement::GlobalLine(String::new()));
404 }
405 config.elements.push(ConfigElement::HostBlock(block));
406 }
407
408 let _ = config.set_host_provider_id(&alias, §ion.id, &remote.server_id);
409 if !remote.tags.is_empty() {
410 let _ = config.set_host_provider_tags(&alias, &remote.tags);
411 }
412 if !remote.metadata.is_empty() {
413 let _ = config.set_host_meta(&alias, &remote.metadata);
414 }
415 }
416
417 result.added += 1;
418 }
419 }
420
421 // Remove deleted hosts (skip included hosts which are read-only)
422 if remove_deleted && !dry_run {
423 let to_remove: Vec<String> = existing_map
424 .iter()
425 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
426 .filter(|(_, alias)| {
427 entries_map
428 .get(alias.as_str())
429 .is_none_or(|e| e.source_file.is_none())
430 })
431 .map(|(_, alias)| alias.clone())
432 .collect();
433 for alias in &to_remove {
434 config.delete_host(alias);
435 }
436 result.removed = to_remove.len();
437
438 // Clean up orphan provider header if all hosts for this provider were removed
439 if config.find_hosts_by_provider(provider.name()).is_empty() {
440 let header_text = format!(
441 "# purple:group {}",
442 super::provider_display_name(provider.name())
443 );
444 config
445 .elements
446 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
447 }
448 } else if remove_deleted {
449 result.removed = existing_map
450 .iter()
451 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
452 .filter(|(_, alias)| {
453 entries_map
454 .get(alias.as_str())
455 .is_none_or(|e| e.source_file.is_none())
456 })
457 .count();
458 }
459
460 // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
461 if !remove_deleted && !suppress_stale {
462 let to_stale: Vec<String> = existing_map
463 .iter()
464 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
465 .filter(|(_, alias)| {
466 entries_map
467 .get(alias.as_str())
468 .is_none_or(|e| e.source_file.is_none())
469 })
470 .map(|(_, alias)| alias.clone())
471 .collect();
472 if !dry_run {
473 let now = std::time::SystemTime::now()
474 .duration_since(std::time::UNIX_EPOCH)
475 .unwrap_or_default()
476 .as_secs();
477 for alias in &to_stale {
478 // Preserve original timestamp if already stale
479 if entries_map
480 .get(alias.as_str())
481 .is_none_or(|e| e.stale.is_none())
482 {
483 let _ = config.set_host_stale(alias, now);
484 }
485 }
486 }
487 result.stale = to_stale.len();
488 }
489
490 result
491}
492
493#[cfg(test)]
494#[path = "sync_tests.rs"]
495mod tests;