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}
16
17/// Sanitize a server name into a valid SSH alias component.
18/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
19/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
20fn sanitize_name(name: &str) -> String {
21    let mut result = String::new();
22    for c in name.chars() {
23        if c.is_ascii_alphanumeric() {
24            result.push(c.to_ascii_lowercase());
25        } else if !result.ends_with('-') {
26            result.push('-');
27        }
28    }
29    let trimmed = result.trim_matches('-').to_string();
30    if trimmed.is_empty() {
31        "server".to_string()
32    } else {
33        trimmed
34    }
35}
36
37/// Display name for a provider (used in group header comments).
38fn provider_header(name: &str) -> &str {
39    match name {
40        "digitalocean" => "DigitalOcean",
41        "vultr" => "Vultr",
42        "linode" => "Linode",
43        "hetzner" => "Hetzner",
44        other => other,
45    }
46}
47
48/// Sync hosts from a cloud provider into the SSH config.
49pub fn sync_provider(
50    config: &mut SshConfigFile,
51    provider: &dyn Provider,
52    remote_hosts: &[ProviderHost],
53    section: &ProviderSection,
54    remove_deleted: bool,
55    dry_run: bool,
56) -> SyncResult {
57    let mut result = SyncResult::default();
58
59    // Build map of server_id -> alias (top-level only, no Include files)
60    let existing = config.find_hosts_by_provider(provider.name());
61    let mut existing_map: HashMap<String, String> = HashMap::new();
62    for (alias, server_id) in &existing {
63        existing_map.insert(server_id.clone(), alias.clone());
64    }
65
66    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
67    let entries_map: HashMap<String, HostEntry> = config
68        .host_entries()
69        .into_iter()
70        .map(|e| (e.alias.clone(), e))
71        .collect();
72
73    // Track which server IDs are still in the remote set (also deduplicates)
74    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
75
76    // Only add group header if this provider has no existing hosts in config
77    let mut needs_header = !dry_run && existing_map.is_empty();
78
79    for remote in remote_hosts {
80        if !remote_ids.insert(remote.server_id.clone()) {
81            continue; // Skip duplicate server_id in same response
82        }
83
84        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
85            // Host exists, check if IP or tags changed
86            if let Some(entry) = entries_map.get(existing_alias) {
87                // Included hosts are read-only; recognize them for dedup but skip mutations
88                if entry.source_file.is_some() {
89                    result.unchanged += 1;
90                    continue;
91                }
92                let ip_changed = entry.hostname != remote.ip;
93                let mut sorted_local = entry.tags.clone();
94                sorted_local.sort();
95                let mut sorted_remote = remote.tags.clone();
96                sorted_remote.sort();
97                let tags_changed = sorted_local != sorted_remote;
98                if ip_changed || tags_changed {
99                    if !dry_run {
100                        if ip_changed {
101                            let updated = HostEntry {
102                                hostname: remote.ip.clone(),
103                                ..entry.clone()
104                            };
105                            config.update_host(existing_alias, &updated);
106                        }
107                        if tags_changed {
108                            config.set_host_tags(existing_alias, &remote.tags);
109                        }
110                    }
111                    result.updated += 1;
112                } else {
113                    result.unchanged += 1;
114                }
115            } else {
116                result.unchanged += 1;
117            }
118        } else {
119            // New host
120            let sanitized = sanitize_name(&remote.name);
121            let base_alias = format!("{}-{}", section.alias_prefix, sanitized);
122            let alias = if dry_run {
123                base_alias
124            } else {
125                config.deduplicate_alias(&base_alias)
126            };
127
128            if !dry_run {
129                // Add group header before the very first host for this provider
130                if needs_header {
131                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
132                        config
133                            .elements
134                            .push(ConfigElement::GlobalLine(String::new()));
135                    }
136                    config
137                        .elements
138                        .push(ConfigElement::GlobalLine(format!(
139                            "# {}",
140                            provider_header(provider.name())
141                        )));
142                    needs_header = false;
143                }
144
145                let entry = HostEntry {
146                    alias: alias.clone(),
147                    hostname: remote.ip.clone(),
148                    user: section.user.clone(),
149                    port: 22,
150                    identity_file: section.identity_file.clone(),
151                    proxy_jump: String::new(),
152                    source_file: None,
153                    tags: remote.tags.clone(),
154                    provider: Some(provider.name().to_string()),
155                };
156
157                let block = SshConfigFile::entry_to_block(&entry);
158                config.elements.push(ConfigElement::HostBlock(block));
159                config.set_host_provider(&alias, provider.name(), &remote.server_id);
160                if !remote.tags.is_empty() {
161                    config.set_host_tags(&alias, &remote.tags);
162                }
163            }
164
165            result.added += 1;
166        }
167    }
168
169    // Remove deleted hosts (skip included hosts which are read-only)
170    if remove_deleted && !dry_run {
171        let to_remove: Vec<String> = existing_map
172            .iter()
173            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
174            .filter(|(_, alias)| {
175                entries_map
176                    .get(alias.as_str())
177                    .is_none_or(|e| e.source_file.is_none())
178            })
179            .map(|(_, alias)| alias.clone())
180            .collect();
181        for alias in &to_remove {
182            config.delete_host(alias);
183        }
184        result.removed = to_remove.len();
185
186        // Clean up orphan provider header if all hosts for this provider were removed
187        if config.find_hosts_by_provider(provider.name()).is_empty() {
188            let header_text = format!("# {}", provider_header(provider.name()));
189            config
190                .elements
191                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
192        }
193    } else if remove_deleted {
194        result.removed = existing_map
195            .iter()
196            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
197            .filter(|(_, alias)| {
198                entries_map
199                    .get(alias.as_str())
200                    .is_none_or(|e| e.source_file.is_none())
201            })
202            .count();
203    }
204
205    result
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::path::PathBuf;
212
213    fn empty_config() -> SshConfigFile {
214        SshConfigFile {
215            elements: Vec::new(),
216            path: PathBuf::from("/tmp/test_config"),
217            crlf: false,
218        }
219    }
220
221    fn make_section() -> ProviderSection {
222        ProviderSection {
223            provider: "digitalocean".to_string(),
224            token: "test".to_string(),
225            alias_prefix: "do".to_string(),
226            user: "root".to_string(),
227            identity_file: String::new(),
228        }
229    }
230
231    struct MockProvider;
232    impl Provider for MockProvider {
233        fn name(&self) -> &str {
234            "digitalocean"
235        }
236        fn short_label(&self) -> &str {
237            "do"
238        }
239        fn fetch_hosts(
240            &self,
241            _token: &str,
242        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
243            Ok(Vec::new())
244        }
245    }
246
247    #[test]
248    fn test_sanitize_name() {
249        assert_eq!(sanitize_name("web-1"), "web-1");
250        assert_eq!(sanitize_name("My Server"), "my-server");
251        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
252        assert_eq!(sanitize_name("--weird--"), "weird");
253        assert_eq!(sanitize_name("UPPER"), "upper");
254        assert_eq!(sanitize_name("a--b"), "a-b");
255        assert_eq!(sanitize_name(""), "server");
256        assert_eq!(sanitize_name("..."), "server");
257    }
258
259    #[test]
260    fn test_sync_adds_new_hosts() {
261        let mut config = empty_config();
262        let section = make_section();
263        let remote = vec![
264            ProviderHost {
265                server_id: "123".to_string(),
266                name: "web-1".to_string(),
267                ip: "1.2.3.4".to_string(),
268                tags: Vec::new(),
269            },
270            ProviderHost {
271                server_id: "456".to_string(),
272                name: "db-1".to_string(),
273                ip: "5.6.7.8".to_string(),
274                tags: Vec::new(),
275            },
276        ];
277
278        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
279        assert_eq!(result.added, 2);
280        assert_eq!(result.updated, 0);
281        assert_eq!(result.unchanged, 0);
282
283        let entries = config.host_entries();
284        assert_eq!(entries.len(), 2);
285        assert_eq!(entries[0].alias, "do-web-1");
286        assert_eq!(entries[0].hostname, "1.2.3.4");
287        assert_eq!(entries[1].alias, "do-db-1");
288    }
289
290    #[test]
291    fn test_sync_updates_changed_ip() {
292        let mut config = empty_config();
293        let section = make_section();
294
295        // First sync: add host
296        let remote = vec![ProviderHost {
297            server_id: "123".to_string(),
298            name: "web-1".to_string(),
299            ip: "1.2.3.4".to_string(),
300            tags: Vec::new(),
301        }];
302        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
303
304        // Second sync: IP changed
305        let remote = vec![ProviderHost {
306            server_id: "123".to_string(),
307            name: "web-1".to_string(),
308            ip: "9.8.7.6".to_string(),
309            tags: Vec::new(),
310        }];
311        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
312        assert_eq!(result.updated, 1);
313        assert_eq!(result.added, 0);
314
315        let entries = config.host_entries();
316        assert_eq!(entries[0].hostname, "9.8.7.6");
317    }
318
319    #[test]
320    fn test_sync_unchanged() {
321        let mut config = empty_config();
322        let section = make_section();
323
324        let remote = vec![ProviderHost {
325            server_id: "123".to_string(),
326            name: "web-1".to_string(),
327            ip: "1.2.3.4".to_string(),
328            tags: Vec::new(),
329        }];
330        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
331
332        // Same data again
333        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
334        assert_eq!(result.unchanged, 1);
335        assert_eq!(result.added, 0);
336        assert_eq!(result.updated, 0);
337    }
338
339    #[test]
340    fn test_sync_removes_deleted() {
341        let mut config = empty_config();
342        let section = make_section();
343
344        let remote = vec![ProviderHost {
345            server_id: "123".to_string(),
346            name: "web-1".to_string(),
347            ip: "1.2.3.4".to_string(),
348            tags: Vec::new(),
349        }];
350        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
351        assert_eq!(config.host_entries().len(), 1);
352
353        // Sync with empty remote list + remove_deleted
354        let result =
355            sync_provider(&mut config, &MockProvider, &[], &section, true, false);
356        assert_eq!(result.removed, 1);
357        assert_eq!(config.host_entries().len(), 0);
358    }
359
360    #[test]
361    fn test_sync_dry_run_no_mutations() {
362        let mut config = empty_config();
363        let section = make_section();
364
365        let remote = vec![ProviderHost {
366            server_id: "123".to_string(),
367            name: "web-1".to_string(),
368            ip: "1.2.3.4".to_string(),
369            tags: Vec::new(),
370        }];
371
372        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
373        assert_eq!(result.added, 1);
374        assert_eq!(config.host_entries().len(), 0); // No actual changes
375    }
376
377    #[test]
378    fn test_sync_dedup_server_id_in_response() {
379        let mut config = empty_config();
380        let section = make_section();
381        let remote = vec![
382            ProviderHost {
383                server_id: "123".to_string(),
384                name: "web-1".to_string(),
385                ip: "1.2.3.4".to_string(),
386                tags: Vec::new(),
387            },
388            ProviderHost {
389                server_id: "123".to_string(),
390                name: "web-1-dup".to_string(),
391                ip: "5.6.7.8".to_string(),
392                tags: Vec::new(),
393            },
394        ];
395
396        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
397        assert_eq!(result.added, 1);
398        assert_eq!(config.host_entries().len(), 1);
399        assert_eq!(config.host_entries()[0].alias, "do-web-1");
400    }
401
402    #[test]
403    fn test_sync_no_duplicate_header_on_repeated_sync() {
404        let mut config = empty_config();
405        let section = make_section();
406
407        // First sync: adds header + host
408        let remote = vec![ProviderHost {
409            server_id: "123".to_string(),
410            name: "web-1".to_string(),
411            ip: "1.2.3.4".to_string(),
412            tags: Vec::new(),
413        }];
414        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
415
416        // Second sync: new host added at provider
417        let remote = vec![
418            ProviderHost {
419                server_id: "123".to_string(),
420                name: "web-1".to_string(),
421                ip: "1.2.3.4".to_string(),
422                tags: Vec::new(),
423            },
424            ProviderHost {
425                server_id: "456".to_string(),
426                name: "db-1".to_string(),
427                ip: "5.6.7.8".to_string(),
428                tags: Vec::new(),
429            },
430        ];
431        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
432
433        // Should have exactly one header
434        let header_count = config
435            .elements
436            .iter()
437            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"))
438            .count();
439        assert_eq!(header_count, 1);
440        assert_eq!(config.host_entries().len(), 2);
441    }
442
443    #[test]
444    fn test_sync_removes_orphan_header() {
445        let mut config = empty_config();
446        let section = make_section();
447
448        // Add a host
449        let remote = vec![ProviderHost {
450            server_id: "123".to_string(),
451            name: "web-1".to_string(),
452            ip: "1.2.3.4".to_string(),
453            tags: Vec::new(),
454        }];
455        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
456
457        // Verify header exists
458        let has_header = config
459            .elements
460            .iter()
461            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
462        assert!(has_header);
463
464        // Remove all hosts (empty remote + remove_deleted)
465        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
466        assert_eq!(result.removed, 1);
467
468        // Header should be cleaned up
469        let has_header = config
470            .elements
471            .iter()
472            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
473        assert!(!has_header);
474    }
475
476    #[test]
477    fn test_sync_writes_provider_tags() {
478        let mut config = empty_config();
479        let section = make_section();
480        let remote = vec![ProviderHost {
481            server_id: "123".to_string(),
482            name: "web-1".to_string(),
483            ip: "1.2.3.4".to_string(),
484            tags: vec!["production".to_string(), "us-east".to_string()],
485        }];
486
487        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
488
489        let entries = config.host_entries();
490        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
491    }
492
493    #[test]
494    fn test_sync_updates_changed_tags() {
495        let mut config = empty_config();
496        let section = make_section();
497
498        // First sync: add with tags
499        let remote = vec![ProviderHost {
500            server_id: "123".to_string(),
501            name: "web-1".to_string(),
502            ip: "1.2.3.4".to_string(),
503            tags: vec!["staging".to_string()],
504        }];
505        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
506        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
507
508        // Second sync: tags changed (IP same)
509        let remote = vec![ProviderHost {
510            server_id: "123".to_string(),
511            name: "web-1".to_string(),
512            ip: "1.2.3.4".to_string(),
513            tags: vec!["production".to_string(), "us-east".to_string()],
514        }];
515        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
516        assert_eq!(result.updated, 1);
517        assert_eq!(
518            config.host_entries()[0].tags,
519            vec!["production", "us-east"]
520        );
521    }
522
523    #[test]
524    fn test_sync_combined_add_update_remove() {
525        let mut config = empty_config();
526        let section = make_section();
527
528        // First sync: add two hosts
529        let remote = vec![
530            ProviderHost {
531                server_id: "1".to_string(),
532                name: "web".to_string(),
533                ip: "1.1.1.1".to_string(),
534                tags: Vec::new(),
535            },
536            ProviderHost {
537                server_id: "2".to_string(),
538                name: "db".to_string(),
539                ip: "2.2.2.2".to_string(),
540                tags: Vec::new(),
541            },
542        ];
543        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
544        assert_eq!(config.host_entries().len(), 2);
545
546        // Second sync: host 1 IP changed, host 2 removed, host 3 added
547        let remote = vec![
548            ProviderHost {
549                server_id: "1".to_string(),
550                name: "web".to_string(),
551                ip: "9.9.9.9".to_string(),
552                tags: Vec::new(),
553            },
554            ProviderHost {
555                server_id: "3".to_string(),
556                name: "cache".to_string(),
557                ip: "3.3.3.3".to_string(),
558                tags: Vec::new(),
559            },
560        ];
561        let result =
562            sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
563        assert_eq!(result.updated, 1);
564        assert_eq!(result.added, 1);
565        assert_eq!(result.removed, 1);
566
567        let entries = config.host_entries();
568        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
569        assert_eq!(entries[0].alias, "do-web");
570        assert_eq!(entries[0].hostname, "9.9.9.9");
571        assert_eq!(entries[1].alias, "do-cache");
572    }
573
574    #[test]
575    fn test_sync_tag_order_insensitive() {
576        let mut config = empty_config();
577        let section = make_section();
578
579        // First sync: tags in one order
580        let remote = vec![ProviderHost {
581            server_id: "123".to_string(),
582            name: "web-1".to_string(),
583            ip: "1.2.3.4".to_string(),
584            tags: vec!["beta".to_string(), "alpha".to_string()],
585        }];
586        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
587
588        // Second sync: same tags, different order
589        let remote = vec![ProviderHost {
590            server_id: "123".to_string(),
591            name: "web-1".to_string(),
592            ip: "1.2.3.4".to_string(),
593            tags: vec!["alpha".to_string(), "beta".to_string()],
594        }];
595        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
596        assert_eq!(result.unchanged, 1);
597        assert_eq!(result.updated, 0);
598    }
599
600    fn config_with_include_provider_host() -> SshConfigFile {
601        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
602
603        // Build an included host block with provider marker
604        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
605        let included_elements = SshConfigFile::parse_content(content);
606
607        SshConfigFile {
608            elements: vec![ConfigElement::Include(IncludeDirective {
609                raw_line: "Include conf.d/*".to_string(),
610                pattern: "conf.d/*".to_string(),
611                resolved_files: vec![IncludedFile {
612                    path: PathBuf::from("/tmp/included.conf"),
613                    elements: included_elements,
614                }],
615            })],
616            path: PathBuf::from("/tmp/test_config"),
617            crlf: false,
618        }
619    }
620
621    #[test]
622    fn test_sync_include_host_skips_update() {
623        let mut config = config_with_include_provider_host();
624        let section = make_section();
625
626        // Remote has same server with different IP — should NOT update included host
627        let remote = vec![ProviderHost {
628            server_id: "inc1".to_string(),
629            name: "included".to_string(),
630            ip: "9.9.9.9".to_string(),
631            tags: Vec::new(),
632        }];
633        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
634        assert_eq!(result.unchanged, 1);
635        assert_eq!(result.updated, 0);
636        assert_eq!(result.added, 0);
637
638        // Verify IP was NOT changed
639        let entries = config.host_entries();
640        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
641        assert_eq!(included.hostname, "1.2.3.4");
642    }
643
644    #[test]
645    fn test_sync_include_host_skips_remove() {
646        let mut config = config_with_include_provider_host();
647        let section = make_section();
648
649        // Empty remote + remove_deleted — should NOT remove included host
650        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
651        assert_eq!(result.removed, 0);
652        assert_eq!(config.host_entries().len(), 1);
653    }
654
655    #[test]
656    fn test_sync_dry_run_remove_count() {
657        let mut config = empty_config();
658        let section = make_section();
659
660        // Add two hosts
661        let remote = vec![
662            ProviderHost {
663                server_id: "1".to_string(),
664                name: "web".to_string(),
665                ip: "1.1.1.1".to_string(),
666                tags: Vec::new(),
667            },
668            ProviderHost {
669                server_id: "2".to_string(),
670                name: "db".to_string(),
671                ip: "2.2.2.2".to_string(),
672                tags: Vec::new(),
673            },
674        ];
675        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
676        assert_eq!(config.host_entries().len(), 2);
677
678        // Dry-run remove with empty remote — should count but not mutate
679        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
680        assert_eq!(result.removed, 2);
681        assert_eq!(config.host_entries().len(), 2); // Still there
682    }
683
684    #[test]
685    fn test_sync_tags_cleared() {
686        let mut config = empty_config();
687        let section = make_section();
688
689        // First sync: host with tags
690        let remote = vec![ProviderHost {
691            server_id: "123".to_string(),
692            name: "web-1".to_string(),
693            ip: "1.2.3.4".to_string(),
694            tags: vec!["production".to_string()],
695        }];
696        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
697        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
698
699        // Second sync: tags removed
700        let remote = vec![ProviderHost {
701            server_id: "123".to_string(),
702            name: "web-1".to_string(),
703            ip: "1.2.3.4".to_string(),
704            tags: Vec::new(),
705        }];
706        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
707        assert_eq!(result.updated, 1);
708        assert!(config.host_entries()[0].tags.is_empty());
709    }
710
711    #[test]
712    fn test_sync_deduplicates_alias() {
713        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
714        let mut config = SshConfigFile {
715            elements: SshConfigFile::parse_content(content),
716            path: PathBuf::from("/tmp/test_config"),
717            crlf: false,
718        };
719        let section = make_section();
720
721        let remote = vec![ProviderHost {
722            server_id: "999".to_string(),
723            name: "web-1".to_string(),
724            ip: "1.2.3.4".to_string(),
725            tags: Vec::new(),
726        }];
727
728        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
729
730        let entries = config.host_entries();
731        // Should have the original + a deduplicated one
732        assert_eq!(entries.len(), 2);
733        assert_eq!(entries[0].alias, "do-web-1");
734        assert_eq!(entries[1].alias, "do-web-1-2");
735    }
736}