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 (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(&section.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(&section.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;