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