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/// Build an alias from prefix + sanitized name.
38/// If prefix is empty, uses just the sanitized name (no leading hyphen).
39fn build_alias(prefix: &str, sanitized: &str) -> String {
40    if prefix.is_empty() {
41        sanitized.to_string()
42    } else {
43        format!("{}-{}", prefix, sanitized)
44    }
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    // Keep first occurrence if duplicate provider markers exist (e.g. manual copy).
61    let existing = config.find_hosts_by_provider(provider.name());
62    let mut existing_map: HashMap<String, String> = HashMap::new();
63    for (alias, server_id) in &existing {
64        existing_map
65            .entry(server_id.clone())
66            .or_insert_with(|| alias.clone());
67    }
68
69    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
70    let entries_map: HashMap<String, HostEntry> = config
71        .host_entries()
72        .into_iter()
73        .map(|e| (e.alias.clone(), e))
74        .collect();
75
76    // Track which server IDs are still in the remote set (also deduplicates)
77    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
78
79    // Only add group header if this provider has no existing hosts in config
80    let mut needs_header = !dry_run && existing_map.is_empty();
81
82    for remote in remote_hosts {
83        if !remote_ids.insert(remote.server_id.clone()) {
84            continue; // Skip duplicate server_id in same response
85        }
86
87        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
88            // Host exists, check if alias, IP or tags changed
89            if let Some(entry) = entries_map.get(existing_alias) {
90                // Included hosts are read-only; recognize them for dedup but skip mutations
91                if entry.source_file.is_some() {
92                    result.unchanged += 1;
93                    continue;
94                }
95
96                // Check if alias prefix changed (e.g. "do" → "ocean")
97                let sanitized = sanitize_name(&remote.name);
98                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
99                let alias_changed = *existing_alias != expected_alias;
100
101                let ip_changed = entry.hostname != remote.ip;
102                let mut sorted_local = entry.tags.clone();
103                sorted_local.sort();
104                let mut sorted_remote: Vec<String> =
105                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
106                sorted_remote.sort();
107                let tags_changed = sorted_local != sorted_remote;
108                if alias_changed || ip_changed || tags_changed {
109                    if !dry_run {
110                        // Compute the final alias (dedup handles collisions)
111                        let new_alias = if alias_changed {
112                            config.deduplicate_alias(&expected_alias)
113                        } else {
114                            existing_alias.clone()
115                        };
116
117                        if alias_changed || ip_changed {
118                            let updated = HostEntry {
119                                alias: new_alias.clone(),
120                                hostname: remote.ip.clone(),
121                                ..entry.clone()
122                            };
123                            config.update_host(existing_alias, &updated);
124                        }
125                        // Tags lookup uses the new alias after rename
126                        let tags_alias = if alias_changed { &new_alias } else { existing_alias };
127                        if tags_changed {
128                            config.set_host_tags(tags_alias, &remote.tags);
129                        }
130                        // Update provider marker with new alias
131                        if alias_changed {
132                            config.set_host_provider(&new_alias, provider.name(), &remote.server_id);
133                        }
134                    }
135                    result.updated += 1;
136                } else {
137                    result.unchanged += 1;
138                }
139            } else {
140                result.unchanged += 1;
141            }
142        } else {
143            // New host
144            let sanitized = sanitize_name(&remote.name);
145            let base_alias = build_alias(&section.alias_prefix, &sanitized);
146            let alias = if dry_run {
147                base_alias
148            } else {
149                config.deduplicate_alias(&base_alias)
150            };
151
152            if !dry_run {
153                // Add group header before the very first host for this provider
154                let wrote_header = needs_header;
155                if needs_header {
156                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
157                        config
158                            .elements
159                            .push(ConfigElement::GlobalLine(String::new()));
160                    }
161                    config
162                        .elements
163                        .push(ConfigElement::GlobalLine(format!(
164                            "# purple:group {}",
165                            super::provider_display_name(provider.name())
166                        )));
167                    needs_header = false;
168                }
169
170                let entry = HostEntry {
171                    alias: alias.clone(),
172                    hostname: remote.ip.clone(),
173                    user: section.user.clone(),
174                    port: 22,
175                    identity_file: section.identity_file.clone(),
176                    proxy_jump: String::new(),
177                    source_file: None,
178                    tags: remote.tags.clone(),
179                    provider: Some(provider.name().to_string()),
180                };
181
182                // Add blank line separator before host (skip when preceded by group header
183                // so the header stays adjacent to the first host)
184                if !wrote_header
185                    && !config.elements.is_empty()
186                    && !config.last_element_has_trailing_blank()
187                {
188                    config
189                        .elements
190                        .push(ConfigElement::GlobalLine(String::new()));
191                }
192
193                let block = SshConfigFile::entry_to_block(&entry);
194                config.elements.push(ConfigElement::HostBlock(block));
195                config.set_host_provider(&alias, provider.name(), &remote.server_id);
196                if !remote.tags.is_empty() {
197                    config.set_host_tags(&alias, &remote.tags);
198                }
199            }
200
201            result.added += 1;
202        }
203    }
204
205    // Remove deleted hosts (skip included hosts which are read-only)
206    if remove_deleted && !dry_run {
207        let to_remove: Vec<String> = existing_map
208            .iter()
209            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
210            .filter(|(_, alias)| {
211                entries_map
212                    .get(alias.as_str())
213                    .is_none_or(|e| e.source_file.is_none())
214            })
215            .map(|(_, alias)| alias.clone())
216            .collect();
217        for alias in &to_remove {
218            config.delete_host(alias);
219        }
220        result.removed = to_remove.len();
221
222        // Clean up orphan provider header if all hosts for this provider were removed
223        if config.find_hosts_by_provider(provider.name()).is_empty() {
224            let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
225            config
226                .elements
227                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
228        }
229    } else if remove_deleted {
230        result.removed = existing_map
231            .iter()
232            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
233            .filter(|(_, alias)| {
234                entries_map
235                    .get(alias.as_str())
236                    .is_none_or(|e| e.source_file.is_none())
237            })
238            .count();
239    }
240
241    result
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use std::path::PathBuf;
248
249    fn empty_config() -> SshConfigFile {
250        SshConfigFile {
251            elements: Vec::new(),
252            path: PathBuf::from("/tmp/test_config"),
253            crlf: false,
254        }
255    }
256
257    fn make_section() -> ProviderSection {
258        ProviderSection {
259            provider: "digitalocean".to_string(),
260            token: "test".to_string(),
261            alias_prefix: "do".to_string(),
262            user: "root".to_string(),
263            identity_file: String::new(),
264        }
265    }
266
267    struct MockProvider;
268    impl Provider for MockProvider {
269        fn name(&self) -> &str {
270            "digitalocean"
271        }
272        fn short_label(&self) -> &str {
273            "do"
274        }
275        fn fetch_hosts(
276            &self,
277            _token: &str,
278        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
279            Ok(Vec::new())
280        }
281    }
282
283    #[test]
284    fn test_build_alias() {
285        assert_eq!(build_alias("do", "web-1"), "do-web-1");
286        assert_eq!(build_alias("", "web-1"), "web-1");
287        assert_eq!(build_alias("ocean", "db"), "ocean-db");
288    }
289
290    #[test]
291    fn test_sanitize_name() {
292        assert_eq!(sanitize_name("web-1"), "web-1");
293        assert_eq!(sanitize_name("My Server"), "my-server");
294        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
295        assert_eq!(sanitize_name("--weird--"), "weird");
296        assert_eq!(sanitize_name("UPPER"), "upper");
297        assert_eq!(sanitize_name("a--b"), "a-b");
298        assert_eq!(sanitize_name(""), "server");
299        assert_eq!(sanitize_name("..."), "server");
300    }
301
302    #[test]
303    fn test_sync_adds_new_hosts() {
304        let mut config = empty_config();
305        let section = make_section();
306        let remote = vec![
307            ProviderHost {
308                server_id: "123".to_string(),
309                name: "web-1".to_string(),
310                ip: "1.2.3.4".to_string(),
311                tags: Vec::new(),
312            },
313            ProviderHost {
314                server_id: "456".to_string(),
315                name: "db-1".to_string(),
316                ip: "5.6.7.8".to_string(),
317                tags: Vec::new(),
318            },
319        ];
320
321        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
322        assert_eq!(result.added, 2);
323        assert_eq!(result.updated, 0);
324        assert_eq!(result.unchanged, 0);
325
326        let entries = config.host_entries();
327        assert_eq!(entries.len(), 2);
328        assert_eq!(entries[0].alias, "do-web-1");
329        assert_eq!(entries[0].hostname, "1.2.3.4");
330        assert_eq!(entries[1].alias, "do-db-1");
331    }
332
333    #[test]
334    fn test_sync_updates_changed_ip() {
335        let mut config = empty_config();
336        let section = make_section();
337
338        // First sync: add host
339        let remote = vec![ProviderHost {
340            server_id: "123".to_string(),
341            name: "web-1".to_string(),
342            ip: "1.2.3.4".to_string(),
343            tags: Vec::new(),
344        }];
345        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
346
347        // Second sync: IP changed
348        let remote = vec![ProviderHost {
349            server_id: "123".to_string(),
350            name: "web-1".to_string(),
351            ip: "9.8.7.6".to_string(),
352            tags: Vec::new(),
353        }];
354        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
355        assert_eq!(result.updated, 1);
356        assert_eq!(result.added, 0);
357
358        let entries = config.host_entries();
359        assert_eq!(entries[0].hostname, "9.8.7.6");
360    }
361
362    #[test]
363    fn test_sync_unchanged() {
364        let mut config = empty_config();
365        let section = make_section();
366
367        let remote = vec![ProviderHost {
368            server_id: "123".to_string(),
369            name: "web-1".to_string(),
370            ip: "1.2.3.4".to_string(),
371            tags: Vec::new(),
372        }];
373        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
374
375        // Same data again
376        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
377        assert_eq!(result.unchanged, 1);
378        assert_eq!(result.added, 0);
379        assert_eq!(result.updated, 0);
380    }
381
382    #[test]
383    fn test_sync_removes_deleted() {
384        let mut config = empty_config();
385        let section = make_section();
386
387        let remote = vec![ProviderHost {
388            server_id: "123".to_string(),
389            name: "web-1".to_string(),
390            ip: "1.2.3.4".to_string(),
391            tags: Vec::new(),
392        }];
393        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
394        assert_eq!(config.host_entries().len(), 1);
395
396        // Sync with empty remote list + remove_deleted
397        let result =
398            sync_provider(&mut config, &MockProvider, &[], &section, true, false);
399        assert_eq!(result.removed, 1);
400        assert_eq!(config.host_entries().len(), 0);
401    }
402
403    #[test]
404    fn test_sync_dry_run_no_mutations() {
405        let mut config = empty_config();
406        let section = make_section();
407
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
415        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
416        assert_eq!(result.added, 1);
417        assert_eq!(config.host_entries().len(), 0); // No actual changes
418    }
419
420    #[test]
421    fn test_sync_dedup_server_id_in_response() {
422        let mut config = empty_config();
423        let section = make_section();
424        let remote = vec![
425            ProviderHost {
426                server_id: "123".to_string(),
427                name: "web-1".to_string(),
428                ip: "1.2.3.4".to_string(),
429                tags: Vec::new(),
430            },
431            ProviderHost {
432                server_id: "123".to_string(),
433                name: "web-1-dup".to_string(),
434                ip: "5.6.7.8".to_string(),
435                tags: Vec::new(),
436            },
437        ];
438
439        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
440        assert_eq!(result.added, 1);
441        assert_eq!(config.host_entries().len(), 1);
442        assert_eq!(config.host_entries()[0].alias, "do-web-1");
443    }
444
445    #[test]
446    fn test_sync_duplicate_local_server_id_keeps_first() {
447        // If duplicate provider markers exist locally, sync should use the first alias
448        let content = "\
449Host do-web-1
450  HostName 1.2.3.4
451  # purple:provider digitalocean:123
452
453Host do-web-1-copy
454  HostName 1.2.3.4
455  # purple:provider digitalocean:123
456";
457        let mut config = SshConfigFile {
458            elements: SshConfigFile::parse_content(content),
459            path: PathBuf::from("/tmp/test_config"),
460            crlf: false,
461        };
462        let section = make_section();
463
464        // Remote has same server_id with updated IP
465        let remote = vec![ProviderHost {
466            server_id: "123".to_string(),
467            name: "web-1".to_string(),
468            ip: "5.6.7.8".to_string(),
469            tags: Vec::new(),
470        }];
471
472        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
473        // Should update the first alias (do-web-1), not the copy
474        assert_eq!(result.updated, 1);
475        assert_eq!(result.added, 0);
476        let entries = config.host_entries();
477        let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
478        assert_eq!(first.hostname, "5.6.7.8");
479        // Copy should remain unchanged
480        let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
481        assert_eq!(copy.hostname, "1.2.3.4");
482    }
483
484    #[test]
485    fn test_sync_no_duplicate_header_on_repeated_sync() {
486        let mut config = empty_config();
487        let section = make_section();
488
489        // First sync: adds header + host
490        let remote = vec![ProviderHost {
491            server_id: "123".to_string(),
492            name: "web-1".to_string(),
493            ip: "1.2.3.4".to_string(),
494            tags: Vec::new(),
495        }];
496        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
497
498        // Second sync: new host added at provider
499        let remote = vec![
500            ProviderHost {
501                server_id: "123".to_string(),
502                name: "web-1".to_string(),
503                ip: "1.2.3.4".to_string(),
504                tags: Vec::new(),
505            },
506            ProviderHost {
507                server_id: "456".to_string(),
508                name: "db-1".to_string(),
509                ip: "5.6.7.8".to_string(),
510                tags: Vec::new(),
511            },
512        ];
513        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
514
515        // Should have exactly one header
516        let header_count = config
517            .elements
518            .iter()
519            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
520            .count();
521        assert_eq!(header_count, 1);
522        assert_eq!(config.host_entries().len(), 2);
523    }
524
525    #[test]
526    fn test_sync_removes_orphan_header() {
527        let mut config = empty_config();
528        let section = make_section();
529
530        // Add a host
531        let remote = vec![ProviderHost {
532            server_id: "123".to_string(),
533            name: "web-1".to_string(),
534            ip: "1.2.3.4".to_string(),
535            tags: Vec::new(),
536        }];
537        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
538
539        // Verify header exists
540        let has_header = config
541            .elements
542            .iter()
543            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
544        assert!(has_header);
545
546        // Remove all hosts (empty remote + remove_deleted)
547        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
548        assert_eq!(result.removed, 1);
549
550        // Header should be cleaned up
551        let has_header = config
552            .elements
553            .iter()
554            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
555        assert!(!has_header);
556    }
557
558    #[test]
559    fn test_sync_writes_provider_tags() {
560        let mut config = empty_config();
561        let section = make_section();
562        let remote = vec![ProviderHost {
563            server_id: "123".to_string(),
564            name: "web-1".to_string(),
565            ip: "1.2.3.4".to_string(),
566            tags: vec!["production".to_string(), "us-east".to_string()],
567        }];
568
569        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
570
571        let entries = config.host_entries();
572        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
573    }
574
575    #[test]
576    fn test_sync_updates_changed_tags() {
577        let mut config = empty_config();
578        let section = make_section();
579
580        // First sync: add with tags
581        let remote = vec![ProviderHost {
582            server_id: "123".to_string(),
583            name: "web-1".to_string(),
584            ip: "1.2.3.4".to_string(),
585            tags: vec!["staging".to_string()],
586        }];
587        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
588        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
589
590        // Second sync: tags changed (IP same)
591        let remote = vec![ProviderHost {
592            server_id: "123".to_string(),
593            name: "web-1".to_string(),
594            ip: "1.2.3.4".to_string(),
595            tags: vec!["production".to_string(), "us-east".to_string()],
596        }];
597        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
598        assert_eq!(result.updated, 1);
599        assert_eq!(
600            config.host_entries()[0].tags,
601            vec!["production", "us-east"]
602        );
603    }
604
605    #[test]
606    fn test_sync_combined_add_update_remove() {
607        let mut config = empty_config();
608        let section = make_section();
609
610        // First sync: add two hosts
611        let remote = vec![
612            ProviderHost {
613                server_id: "1".to_string(),
614                name: "web".to_string(),
615                ip: "1.1.1.1".to_string(),
616                tags: Vec::new(),
617            },
618            ProviderHost {
619                server_id: "2".to_string(),
620                name: "db".to_string(),
621                ip: "2.2.2.2".to_string(),
622                tags: Vec::new(),
623            },
624        ];
625        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
626        assert_eq!(config.host_entries().len(), 2);
627
628        // Second sync: host 1 IP changed, host 2 removed, host 3 added
629        let remote = vec![
630            ProviderHost {
631                server_id: "1".to_string(),
632                name: "web".to_string(),
633                ip: "9.9.9.9".to_string(),
634                tags: Vec::new(),
635            },
636            ProviderHost {
637                server_id: "3".to_string(),
638                name: "cache".to_string(),
639                ip: "3.3.3.3".to_string(),
640                tags: Vec::new(),
641            },
642        ];
643        let result =
644            sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
645        assert_eq!(result.updated, 1);
646        assert_eq!(result.added, 1);
647        assert_eq!(result.removed, 1);
648
649        let entries = config.host_entries();
650        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
651        assert_eq!(entries[0].alias, "do-web");
652        assert_eq!(entries[0].hostname, "9.9.9.9");
653        assert_eq!(entries[1].alias, "do-cache");
654    }
655
656    #[test]
657    fn test_sync_tag_order_insensitive() {
658        let mut config = empty_config();
659        let section = make_section();
660
661        // First sync: tags in one order
662        let remote = vec![ProviderHost {
663            server_id: "123".to_string(),
664            name: "web-1".to_string(),
665            ip: "1.2.3.4".to_string(),
666            tags: vec!["beta".to_string(), "alpha".to_string()],
667        }];
668        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
669
670        // Second sync: same tags, different order
671        let remote = vec![ProviderHost {
672            server_id: "123".to_string(),
673            name: "web-1".to_string(),
674            ip: "1.2.3.4".to_string(),
675            tags: vec!["alpha".to_string(), "beta".to_string()],
676        }];
677        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
678        assert_eq!(result.unchanged, 1);
679        assert_eq!(result.updated, 0);
680    }
681
682    fn config_with_include_provider_host() -> SshConfigFile {
683        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
684
685        // Build an included host block with provider marker
686        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
687        let included_elements = SshConfigFile::parse_content(content);
688
689        SshConfigFile {
690            elements: vec![ConfigElement::Include(IncludeDirective {
691                raw_line: "Include conf.d/*".to_string(),
692                pattern: "conf.d/*".to_string(),
693                resolved_files: vec![IncludedFile {
694                    path: PathBuf::from("/tmp/included.conf"),
695                    elements: included_elements,
696                }],
697            })],
698            path: PathBuf::from("/tmp/test_config"),
699            crlf: false,
700        }
701    }
702
703    #[test]
704    fn test_sync_include_host_skips_update() {
705        let mut config = config_with_include_provider_host();
706        let section = make_section();
707
708        // Remote has same server with different IP — should NOT update included host
709        let remote = vec![ProviderHost {
710            server_id: "inc1".to_string(),
711            name: "included".to_string(),
712            ip: "9.9.9.9".to_string(),
713            tags: Vec::new(),
714        }];
715        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
716        assert_eq!(result.unchanged, 1);
717        assert_eq!(result.updated, 0);
718        assert_eq!(result.added, 0);
719
720        // Verify IP was NOT changed
721        let entries = config.host_entries();
722        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
723        assert_eq!(included.hostname, "1.2.3.4");
724    }
725
726    #[test]
727    fn test_sync_include_host_skips_remove() {
728        let mut config = config_with_include_provider_host();
729        let section = make_section();
730
731        // Empty remote + remove_deleted — should NOT remove included host
732        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
733        assert_eq!(result.removed, 0);
734        assert_eq!(config.host_entries().len(), 1);
735    }
736
737    #[test]
738    fn test_sync_dry_run_remove_count() {
739        let mut config = empty_config();
740        let section = make_section();
741
742        // Add two hosts
743        let remote = vec![
744            ProviderHost {
745                server_id: "1".to_string(),
746                name: "web".to_string(),
747                ip: "1.1.1.1".to_string(),
748                tags: Vec::new(),
749            },
750            ProviderHost {
751                server_id: "2".to_string(),
752                name: "db".to_string(),
753                ip: "2.2.2.2".to_string(),
754                tags: Vec::new(),
755            },
756        ];
757        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
758        assert_eq!(config.host_entries().len(), 2);
759
760        // Dry-run remove with empty remote — should count but not mutate
761        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
762        assert_eq!(result.removed, 2);
763        assert_eq!(config.host_entries().len(), 2); // Still there
764    }
765
766    #[test]
767    fn test_sync_tags_cleared() {
768        let mut config = empty_config();
769        let section = make_section();
770
771        // First sync: host with tags
772        let remote = vec![ProviderHost {
773            server_id: "123".to_string(),
774            name: "web-1".to_string(),
775            ip: "1.2.3.4".to_string(),
776            tags: vec!["production".to_string()],
777        }];
778        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
779        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
780
781        // Second sync: tags removed
782        let remote = vec![ProviderHost {
783            server_id: "123".to_string(),
784            name: "web-1".to_string(),
785            ip: "1.2.3.4".to_string(),
786            tags: Vec::new(),
787        }];
788        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
789        assert_eq!(result.updated, 1);
790        assert!(config.host_entries()[0].tags.is_empty());
791    }
792
793    #[test]
794    fn test_sync_deduplicates_alias() {
795        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
796        let mut config = SshConfigFile {
797            elements: SshConfigFile::parse_content(content),
798            path: PathBuf::from("/tmp/test_config"),
799            crlf: false,
800        };
801        let section = make_section();
802
803        let remote = vec![ProviderHost {
804            server_id: "999".to_string(),
805            name: "web-1".to_string(),
806            ip: "1.2.3.4".to_string(),
807            tags: Vec::new(),
808        }];
809
810        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
811
812        let entries = config.host_entries();
813        // Should have the original + a deduplicated one
814        assert_eq!(entries.len(), 2);
815        assert_eq!(entries[0].alias, "do-web-1");
816        assert_eq!(entries[1].alias, "do-web-1-2");
817    }
818
819    #[test]
820    fn test_sync_renames_on_prefix_change() {
821        let mut config = empty_config();
822        let section = make_section(); // prefix = "do"
823
824        // First sync: add host with "do" prefix
825        let remote = vec![ProviderHost {
826            server_id: "123".to_string(),
827            name: "web-1".to_string(),
828            ip: "1.2.3.4".to_string(),
829            tags: Vec::new(),
830        }];
831        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
832        assert_eq!(config.host_entries()[0].alias, "do-web-1");
833
834        // Second sync: prefix changed to "ocean"
835        let new_section = ProviderSection {
836            alias_prefix: "ocean".to_string(),
837            ..section
838        };
839        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
840        assert_eq!(result.updated, 1);
841        assert_eq!(result.unchanged, 0);
842
843        let entries = config.host_entries();
844        assert_eq!(entries.len(), 1);
845        assert_eq!(entries[0].alias, "ocean-web-1");
846        assert_eq!(entries[0].hostname, "1.2.3.4");
847    }
848
849    #[test]
850    fn test_sync_rename_and_ip_change() {
851        let mut config = empty_config();
852        let section = make_section();
853
854        let remote = vec![ProviderHost {
855            server_id: "123".to_string(),
856            name: "web-1".to_string(),
857            ip: "1.2.3.4".to_string(),
858            tags: Vec::new(),
859        }];
860        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
861
862        // Change both prefix and IP
863        let new_section = ProviderSection {
864            alias_prefix: "ocean".to_string(),
865            ..section
866        };
867        let remote = vec![ProviderHost {
868            server_id: "123".to_string(),
869            name: "web-1".to_string(),
870            ip: "9.9.9.9".to_string(),
871            tags: Vec::new(),
872        }];
873        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
874        assert_eq!(result.updated, 1);
875
876        let entries = config.host_entries();
877        assert_eq!(entries[0].alias, "ocean-web-1");
878        assert_eq!(entries[0].hostname, "9.9.9.9");
879    }
880
881    #[test]
882    fn test_sync_rename_dry_run_no_mutation() {
883        let mut config = empty_config();
884        let section = make_section();
885
886        let remote = vec![ProviderHost {
887            server_id: "123".to_string(),
888            name: "web-1".to_string(),
889            ip: "1.2.3.4".to_string(),
890            tags: Vec::new(),
891        }];
892        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
893
894        let new_section = ProviderSection {
895            alias_prefix: "ocean".to_string(),
896            ..section
897        };
898        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
899        assert_eq!(result.updated, 1);
900
901        // Config should be unchanged (dry run)
902        assert_eq!(config.host_entries()[0].alias, "do-web-1");
903    }
904
905    #[test]
906    fn test_sync_no_rename_when_prefix_unchanged() {
907        let mut config = empty_config();
908        let section = make_section();
909
910        let remote = vec![ProviderHost {
911            server_id: "123".to_string(),
912            name: "web-1".to_string(),
913            ip: "1.2.3.4".to_string(),
914            tags: Vec::new(),
915        }];
916        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
917
918        // Same prefix, same everything — should be unchanged
919        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
920        assert_eq!(result.unchanged, 1);
921        assert_eq!(result.updated, 0);
922        assert_eq!(config.host_entries()[0].alias, "do-web-1");
923    }
924
925    #[test]
926    fn test_sync_manual_comment_survives_cleanup() {
927        // A manual "# DigitalOcean" comment (without purple:group prefix)
928        // should NOT be removed when provider hosts are deleted
929        let content = "# DigitalOcean\nHost do-web\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
930        let mut config = SshConfigFile {
931            elements: SshConfigFile::parse_content(content),
932            path: PathBuf::from("/tmp/test_config"),
933            crlf: false,
934        };
935        let section = make_section();
936
937        // Remove all hosts (empty remote + remove_deleted)
938        sync_provider(&mut config, &MockProvider, &[], &section, true, false);
939
940        // The manual "# DigitalOcean" comment should survive (it doesn't have purple:group prefix)
941        let has_manual = config
942            .elements
943            .iter()
944            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
945        assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
946    }
947
948    #[test]
949    fn test_sync_rename_skips_included_host() {
950        let mut config = config_with_include_provider_host();
951
952        let new_section = ProviderSection {
953            provider: "digitalocean".to_string(),
954            token: "test".to_string(),
955            alias_prefix: "ocean".to_string(), // Different prefix
956            user: "root".to_string(),
957            identity_file: String::new(),
958        };
959
960        // Remote has the included host's server_id with a different prefix
961        let remote = vec![ProviderHost {
962            server_id: "inc1".to_string(),
963            name: "included".to_string(),
964            ip: "1.2.3.4".to_string(),
965            tags: Vec::new(),
966        }];
967        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
968        assert_eq!(result.unchanged, 1);
969        assert_eq!(result.updated, 0);
970
971        // Alias should remain unchanged (included hosts are read-only)
972        assert_eq!(config.host_entries()[0].alias, "do-included");
973    }
974}