Skip to main content

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(&section.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    // Only add group header if this PROVIDER (any config) has no existing hosts.
112    // Group headers are shared across labeled configs of the same provider so
113    // both `[do:work]` and `[do:personal]` hosts live under one "DigitalOcean" header.
114    let mut needs_header = !dry_run && config.find_hosts_by_provider(provider.name()).is_empty();
115
116    for remote in remote_hosts {
117        if !remote_ids.insert(remote.server_id.clone()) {
118            continue; // Skip duplicate server_id in same response
119        }
120
121        // Empty IP means the resource exists but has no resolvable address
122        // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
123        // won't delete it, but skip add/update. Still clear stale if the host
124        // reappeared (it exists in the provider, just has no IP).
125        if remote.ip.is_empty() {
126            if let Some(alias) = existing_map.get(&remote.server_id) {
127                if let Some(entry) = entries_map.get(alias.as_str()) {
128                    if entry.stale.is_some() {
129                        if !dry_run {
130                            config.clear_host_stale(alias);
131                        }
132                        result.updated += 1;
133                        continue;
134                    }
135                }
136                result.unchanged += 1;
137            }
138            continue;
139        }
140
141        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
142            // Host exists, check if alias, IP or tags changed
143            if let Some(entry) = entries_map.get(existing_alias) {
144                // Included hosts are read-only; recognize them for dedup but skip mutations
145                if entry.source_file.is_some() {
146                    result.unchanged += 1;
147                    continue;
148                }
149
150                // Host reappeared: clear stale marking
151                let was_stale = entry.stale.is_some();
152                if was_stale && !dry_run {
153                    config.clear_host_stale(existing_alias);
154                }
155
156                // Check if alias prefix changed (e.g. "do" → "ocean")
157                let sanitized = sanitize_name(&remote.name);
158                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
159                let alias_changed = *existing_alias != expected_alias;
160
161                let ip_changed = entry.hostname != remote.ip;
162                let meta_changed = {
163                    let mut local: Vec<(&str, &str)> = entry
164                        .provider_meta
165                        .iter()
166                        .filter(|(k, _)| !is_volatile_meta(k))
167                        .map(|(k, v)| (k.as_str(), v.as_str()))
168                        .collect();
169                    local.sort();
170                    let mut remote_m: Vec<(&str, &str)> = remote
171                        .metadata
172                        .iter()
173                        .filter(|(k, _)| !is_volatile_meta(k))
174                        .map(|(k, v)| (k.as_str(), v.as_str()))
175                        .collect();
176                    remote_m.sort();
177                    local != remote_m
178                };
179                let trimmed_remote: Vec<String> =
180                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
181                let tags_changed = {
182                    // Compare provider_tags with remote (case-insensitive, sorted)
183                    let mut sorted_local: Vec<String> = entry
184                        .provider_tags
185                        .iter()
186                        .map(|t| t.trim().to_lowercase())
187                        .collect();
188                    sorted_local.sort();
189                    let mut sorted_remote: Vec<String> =
190                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
191                    sorted_remote.sort();
192                    sorted_local != sorted_remote
193                };
194                // First migration: host has old-format tags (# purple:tags) but
195                // no # purple:provider_tags comment yet. Tags need splitting.
196                let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
197
198                // After first migration: check if user tags overlap with provider tags
199                let user_tags_overlap = !first_migration
200                    && !trimmed_remote.is_empty()
201                    && entry.tags.iter().any(|t| {
202                        trimmed_remote
203                            .iter()
204                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
205                    });
206
207                if alias_changed
208                    || ip_changed
209                    || tags_changed
210                    || meta_changed
211                    || user_tags_overlap
212                    || first_migration
213                    || was_stale
214                {
215                    if dry_run {
216                        result.updated += 1;
217                    } else {
218                        // Compute the final alias (dedup handles collisions,
219                        // excluding the host being renamed so it doesn't collide with itself)
220                        let new_alias = if alias_changed {
221                            config
222                                .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
223                        } else {
224                            existing_alias.clone()
225                        };
226                        // Re-evaluate: dedup may resolve back to the current alias
227                        let alias_changed = new_alias != *existing_alias;
228
229                        if alias_changed
230                            || ip_changed
231                            || tags_changed
232                            || meta_changed
233                            || user_tags_overlap
234                            || first_migration
235                            || was_stale
236                        {
237                            if alias_changed || ip_changed {
238                                let updated = HostEntry {
239                                    alias: new_alias.clone(),
240                                    hostname: remote.ip.clone(),
241                                    ..entry.clone()
242                                };
243                                config.update_host(existing_alias, &updated);
244                            }
245                            // Tags lookup uses the new alias after rename
246                            let tags_alias = if alias_changed {
247                                &new_alias
248                            } else {
249                                existing_alias
250                            };
251                            if tags_changed || first_migration {
252                                config.set_host_provider_tags(tags_alias, &trimmed_remote);
253                            }
254                            // Migration cleanup
255                            if first_migration {
256                                // First migration: old # purple:tags had both provider
257                                // and user tags mixed. Keep only tags NOT in remote
258                                // (those must be user-added). Provider tags move to
259                                // # purple:provider_tags.
260                                let user_only: Vec<String> = entry
261                                    .tags
262                                    .iter()
263                                    .filter(|t| {
264                                        !trimmed_remote
265                                            .iter()
266                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
267                                    })
268                                    .cloned()
269                                    .collect();
270                                config.set_host_tags(tags_alias, &user_only);
271                            } else if tags_changed || user_tags_overlap {
272                                // Ongoing: remove user tags that overlap with provider tags
273                                let cleaned: Vec<String> = entry
274                                    .tags
275                                    .iter()
276                                    .filter(|t| {
277                                        !trimmed_remote
278                                            .iter()
279                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
280                                    })
281                                    .cloned()
282                                    .collect();
283                                if cleaned.len() != entry.tags.len() {
284                                    config.set_host_tags(tags_alias, &cleaned);
285                                }
286                            }
287                            // Update provider marker with new alias.
288                            // Use the section's full id so labeled configs
289                            // emit 3-segment markers.
290                            if alias_changed {
291                                config.set_host_provider_id(
292                                    &new_alias,
293                                    &section.id,
294                                    &remote.server_id,
295                                );
296                                result
297                                    .renames
298                                    .push((existing_alias.clone(), new_alias.clone()));
299                            }
300                            // Update metadata
301                            if meta_changed {
302                                config.set_host_meta(tags_alias, &remote.metadata);
303                            }
304                            result.updated += 1;
305                        } else {
306                            result.unchanged += 1;
307                        }
308                    }
309                } else {
310                    result.unchanged += 1;
311                }
312            } else {
313                result.unchanged += 1;
314            }
315        } else {
316            // New host
317            let sanitized = sanitize_name(&remote.name);
318            let base_alias = build_alias(&section.alias_prefix, &sanitized);
319            let alias = if dry_run {
320                base_alias
321            } else {
322                config.deduplicate_alias(&base_alias)
323            };
324
325            if !dry_run {
326                // Add group header before the very first host for this provider
327                let wrote_header = needs_header;
328                if needs_header {
329                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
330                        config
331                            .elements
332                            .push(ConfigElement::GlobalLine(String::new()));
333                    }
334                    config.elements.push(ConfigElement::GlobalLine(format!(
335                        "# purple:group {}",
336                        super::provider_display_name(provider.name())
337                    )));
338                    needs_header = false;
339                }
340
341                let entry = HostEntry {
342                    alias: alias.clone(),
343                    hostname: remote.ip.clone(),
344                    user: section.user.clone(),
345                    identity_file: section.identity_file.clone(),
346                    provider: Some(provider.name().to_string()),
347                    ..Default::default()
348                };
349
350                let block = SshConfigFile::entry_to_block(&entry);
351
352                // Insert adjacent to existing provider hosts (keeps groups together).
353                // For the very first host (wrote_header), fall through to push at end.
354                let insert_pos = if !wrote_header {
355                    config.find_provider_insert_position(provider.name())
356                } else {
357                    None
358                };
359
360                if let Some(pos) = insert_pos {
361                    // Insert after last provider host with blank line separation.
362                    config.elements.insert(pos, ConfigElement::HostBlock(block));
363                    // Ensure blank line after the new block if the next element
364                    // is not already a blank (prevents hosts running into group
365                    // headers or other host blocks without visual separation).
366                    let after = pos + 1;
367                    let needs_trailing_blank = config.elements.get(after).is_some_and(
368                        |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
369                    );
370                    if needs_trailing_blank {
371                        config
372                            .elements
373                            .insert(after, ConfigElement::GlobalLine(String::new()));
374                    }
375                } else {
376                    // No existing group or first host: append at end with separator
377                    if !wrote_header
378                        && !config.elements.is_empty()
379                        && !config.last_element_has_trailing_blank()
380                    {
381                        config
382                            .elements
383                            .push(ConfigElement::GlobalLine(String::new()));
384                    }
385                    config.elements.push(ConfigElement::HostBlock(block));
386                }
387
388                config.set_host_provider_id(&alias, &section.id, &remote.server_id);
389                if !remote.tags.is_empty() {
390                    config.set_host_provider_tags(&alias, &remote.tags);
391                }
392                if !remote.metadata.is_empty() {
393                    config.set_host_meta(&alias, &remote.metadata);
394                }
395            }
396
397            result.added += 1;
398        }
399    }
400
401    // Remove deleted hosts (skip included hosts which are read-only)
402    if remove_deleted && !dry_run {
403        let to_remove: Vec<String> = existing_map
404            .iter()
405            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
406            .filter(|(_, alias)| {
407                entries_map
408                    .get(alias.as_str())
409                    .is_none_or(|e| e.source_file.is_none())
410            })
411            .map(|(_, alias)| alias.clone())
412            .collect();
413        for alias in &to_remove {
414            config.delete_host(alias);
415        }
416        result.removed = to_remove.len();
417
418        // Clean up orphan provider header if all hosts for this provider were removed
419        if config.find_hosts_by_provider(provider.name()).is_empty() {
420            let header_text = format!(
421                "# purple:group {}",
422                super::provider_display_name(provider.name())
423            );
424            config
425                .elements
426                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
427        }
428    } else if remove_deleted {
429        result.removed = existing_map
430            .iter()
431            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
432            .filter(|(_, alias)| {
433                entries_map
434                    .get(alias.as_str())
435                    .is_none_or(|e| e.source_file.is_none())
436            })
437            .count();
438    }
439
440    // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
441    if !remove_deleted && !suppress_stale {
442        let to_stale: Vec<String> = existing_map
443            .iter()
444            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
445            .filter(|(_, alias)| {
446                entries_map
447                    .get(alias.as_str())
448                    .is_none_or(|e| e.source_file.is_none())
449            })
450            .map(|(_, alias)| alias.clone())
451            .collect();
452        if !dry_run {
453            let now = std::time::SystemTime::now()
454                .duration_since(std::time::UNIX_EPOCH)
455                .unwrap_or_default()
456                .as_secs();
457            for alias in &to_stale {
458                // Preserve original timestamp if already stale
459                if entries_map
460                    .get(alias.as_str())
461                    .is_none_or(|e| e.stale.is_none())
462                {
463                    config.set_host_stale(alias, now);
464                }
465            }
466        }
467        result.stale = to_stale.len();
468    }
469
470    result
471}
472
473#[cfg(test)]
474#[path = "sync_tests.rs"]
475mod tests;