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    /// Alias renames: (old_alias, new_alias) pairs.
16    pub renames: Vec<(String, String)>,
17}
18
19/// Sanitize a server name into a valid SSH alias component.
20/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
21/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
22fn sanitize_name(name: &str) -> String {
23    let mut result = String::new();
24    for c in name.chars() {
25        if c.is_ascii_alphanumeric() {
26            result.push(c.to_ascii_lowercase());
27        } else if !result.ends_with('-') {
28            result.push('-');
29        }
30    }
31    let trimmed = result.trim_matches('-').to_string();
32    if trimmed.is_empty() {
33        "server".to_string()
34    } else {
35        trimmed
36    }
37}
38
39/// Build an alias from prefix + sanitized name.
40/// If prefix is empty, uses just the sanitized name (no leading hyphen).
41fn build_alias(prefix: &str, sanitized: &str) -> String {
42    if prefix.is_empty() {
43        sanitized.to_string()
44    } else {
45        format!("{}-{}", prefix, sanitized)
46    }
47}
48
49
50/// Sync hosts from a cloud provider into the SSH config.
51pub fn sync_provider(
52    config: &mut SshConfigFile,
53    provider: &dyn Provider,
54    remote_hosts: &[ProviderHost],
55    section: &ProviderSection,
56    remove_deleted: bool,
57    dry_run: bool,
58) -> SyncResult {
59    sync_provider_with_options(
60        config,
61        provider,
62        remote_hosts,
63        section,
64        remove_deleted,
65        dry_run,
66        false,
67    )
68}
69
70/// Sync hosts from a cloud provider into the SSH config.
71/// When `reset_tags` is true, local tags are replaced with provider tags
72/// instead of being merged (cleans up stale tags).
73pub fn sync_provider_with_options(
74    config: &mut SshConfigFile,
75    provider: &dyn Provider,
76    remote_hosts: &[ProviderHost],
77    section: &ProviderSection,
78    remove_deleted: bool,
79    dry_run: bool,
80    reset_tags: bool,
81) -> SyncResult {
82    let mut result = SyncResult::default();
83
84    // Build map of server_id -> alias (top-level only, no Include files).
85    // Keep first occurrence if duplicate provider markers exist (e.g. manual copy).
86    let existing = config.find_hosts_by_provider(provider.name());
87    let mut existing_map: HashMap<String, String> = HashMap::new();
88    for (alias, server_id) in &existing {
89        existing_map
90            .entry(server_id.clone())
91            .or_insert_with(|| alias.clone());
92    }
93
94    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
95    let entries_map: HashMap<String, HostEntry> = config
96        .host_entries()
97        .into_iter()
98        .map(|e| (e.alias.clone(), e))
99        .collect();
100
101    // Track which server IDs are still in the remote set (also deduplicates)
102    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
103
104    // Only add group header if this provider has no existing hosts in config
105    let mut needs_header = !dry_run && existing_map.is_empty();
106
107    for remote in remote_hosts {
108        if !remote_ids.insert(remote.server_id.clone()) {
109            continue; // Skip duplicate server_id in same response
110        }
111
112        // Empty IP means the resource exists but has no resolvable address
113        // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
114        // won't delete it, but skip add/update.
115        if remote.ip.is_empty() {
116            if existing_map.contains_key(&remote.server_id) {
117                result.unchanged += 1;
118            }
119            continue;
120        }
121
122        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
123            // Host exists, check if alias, IP or tags changed
124            if let Some(entry) = entries_map.get(existing_alias) {
125                // Included hosts are read-only; recognize them for dedup but skip mutations
126                if entry.source_file.is_some() {
127                    result.unchanged += 1;
128                    continue;
129                }
130
131                // Check if alias prefix changed (e.g. "do" → "ocean")
132                let sanitized = sanitize_name(&remote.name);
133                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
134                let alias_changed = *existing_alias != expected_alias;
135
136                let ip_changed = entry.hostname != remote.ip;
137                let trimmed_remote: Vec<String> =
138                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
139                let tags_changed = if reset_tags {
140                    // Exact comparison (case-insensitive): replace local tags with provider tags
141                    let mut sorted_local: Vec<String> =
142                        entry.tags.iter().map(|t| t.to_lowercase()).collect();
143                    sorted_local.sort();
144                    let mut sorted_remote: Vec<String> =
145                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
146                    sorted_remote.sort();
147                    sorted_local != sorted_remote
148                } else {
149                    // Subset check (case-insensitive): only trigger when provider tags are missing locally
150                    trimmed_remote.iter().any(|rt| {
151                        !entry
152                            .tags
153                            .iter()
154                            .any(|lt| lt.eq_ignore_ascii_case(rt))
155                    })
156                };
157                if alias_changed || ip_changed || tags_changed {
158                    if dry_run {
159                        result.updated += 1;
160                    } else {
161                        // Compute the final alias (dedup handles collisions,
162                        // excluding the host being renamed so it doesn't collide with itself)
163                        let new_alias = if alias_changed {
164                            config.deduplicate_alias_excluding(
165                                &expected_alias,
166                                Some(existing_alias),
167                            )
168                        } else {
169                            existing_alias.clone()
170                        };
171                        // Re-evaluate: dedup may resolve back to the current alias
172                        let alias_changed = new_alias != *existing_alias;
173
174                        if alias_changed || ip_changed || tags_changed {
175                            if alias_changed || ip_changed {
176                                let updated = HostEntry {
177                                    alias: new_alias.clone(),
178                                    hostname: remote.ip.clone(),
179                                    ..entry.clone()
180                                };
181                                config.update_host(existing_alias, &updated);
182                            }
183                            // Tags lookup uses the new alias after rename
184                            let tags_alias =
185                                if alias_changed { &new_alias } else { existing_alias };
186                            if tags_changed {
187                                if reset_tags {
188                                    config.set_host_tags(tags_alias, &trimmed_remote);
189                                } else {
190                                    // Merge (case-insensitive): keep existing local tags, add missing remote tags
191                                    let mut merged = entry.tags.clone();
192                                    for rt in &trimmed_remote {
193                                        if !merged.iter().any(|t| t.eq_ignore_ascii_case(rt)) {
194                                            merged.push(rt.clone());
195                                        }
196                                    }
197                                    config.set_host_tags(tags_alias, &merged);
198                                }
199                            }
200                            // Update provider marker with new alias
201                            if alias_changed {
202                                config.set_host_provider(
203                                    &new_alias,
204                                    provider.name(),
205                                    &remote.server_id,
206                                );
207                                result.renames.push((existing_alias.clone(), new_alias.clone()));
208                            }
209                            result.updated += 1;
210                        } else {
211                            result.unchanged += 1;
212                        }
213                    }
214                } else {
215                    result.unchanged += 1;
216                }
217            } else {
218                result.unchanged += 1;
219            }
220        } else {
221            // New host
222            let sanitized = sanitize_name(&remote.name);
223            let base_alias = build_alias(&section.alias_prefix, &sanitized);
224            let alias = if dry_run {
225                base_alias
226            } else {
227                config.deduplicate_alias(&base_alias)
228            };
229
230            if !dry_run {
231                // Add group header before the very first host for this provider
232                let wrote_header = needs_header;
233                if needs_header {
234                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
235                        config
236                            .elements
237                            .push(ConfigElement::GlobalLine(String::new()));
238                    }
239                    config
240                        .elements
241                        .push(ConfigElement::GlobalLine(format!(
242                            "# purple:group {}",
243                            super::provider_display_name(provider.name())
244                        )));
245                    needs_header = false;
246                }
247
248                let entry = HostEntry {
249                    alias: alias.clone(),
250                    hostname: remote.ip.clone(),
251                    user: section.user.clone(),
252                    identity_file: section.identity_file.clone(),
253                    tags: remote.tags.clone(),
254                    provider: Some(provider.name().to_string()),
255                    ..Default::default()
256                };
257
258                // Add blank line separator before host (skip when preceded by group header
259                // so the header stays adjacent to the first host)
260                if !wrote_header
261                    && !config.elements.is_empty()
262                    && !config.last_element_has_trailing_blank()
263                {
264                    config
265                        .elements
266                        .push(ConfigElement::GlobalLine(String::new()));
267                }
268
269                let block = SshConfigFile::entry_to_block(&entry);
270                config.elements.push(ConfigElement::HostBlock(block));
271                config.set_host_provider(&alias, provider.name(), &remote.server_id);
272                if !remote.tags.is_empty() {
273                    config.set_host_tags(&alias, &remote.tags);
274                }
275            }
276
277            result.added += 1;
278        }
279    }
280
281    // Remove deleted hosts (skip included hosts which are read-only)
282    if remove_deleted && !dry_run {
283        let to_remove: Vec<String> = existing_map
284            .iter()
285            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
286            .filter(|(_, alias)| {
287                entries_map
288                    .get(alias.as_str())
289                    .is_none_or(|e| e.source_file.is_none())
290            })
291            .map(|(_, alias)| alias.clone())
292            .collect();
293        for alias in &to_remove {
294            config.delete_host(alias);
295        }
296        result.removed = to_remove.len();
297
298        // Clean up orphan provider header if all hosts for this provider were removed
299        if config.find_hosts_by_provider(provider.name()).is_empty() {
300            let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
301            config
302                .elements
303                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
304        }
305    } else if remove_deleted {
306        result.removed = existing_map
307            .iter()
308            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
309            .filter(|(_, alias)| {
310                entries_map
311                    .get(alias.as_str())
312                    .is_none_or(|e| e.source_file.is_none())
313            })
314            .count();
315    }
316
317    result
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::path::PathBuf;
324
325    fn empty_config() -> SshConfigFile {
326        SshConfigFile {
327            elements: Vec::new(),
328            path: PathBuf::from("/tmp/test_config"),
329            crlf: false,
330        }
331    }
332
333    fn make_section() -> ProviderSection {
334        ProviderSection {
335            provider: "digitalocean".to_string(),
336            token: "test".to_string(),
337            alias_prefix: "do".to_string(),
338            user: "root".to_string(),
339            identity_file: String::new(),
340            url: String::new(),
341            verify_tls: true,
342            auto_sync: true,
343        }
344    }
345
346    struct MockProvider;
347    impl Provider for MockProvider {
348        fn name(&self) -> &str {
349            "digitalocean"
350        }
351        fn short_label(&self) -> &str {
352            "do"
353        }
354        fn fetch_hosts_cancellable(
355            &self,
356            _token: &str,
357            _cancel: &std::sync::atomic::AtomicBool,
358        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
359            Ok(Vec::new())
360        }
361    }
362
363    #[test]
364    fn test_build_alias() {
365        assert_eq!(build_alias("do", "web-1"), "do-web-1");
366        assert_eq!(build_alias("", "web-1"), "web-1");
367        assert_eq!(build_alias("ocean", "db"), "ocean-db");
368    }
369
370    #[test]
371    fn test_sanitize_name() {
372        assert_eq!(sanitize_name("web-1"), "web-1");
373        assert_eq!(sanitize_name("My Server"), "my-server");
374        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
375        assert_eq!(sanitize_name("--weird--"), "weird");
376        assert_eq!(sanitize_name("UPPER"), "upper");
377        assert_eq!(sanitize_name("a--b"), "a-b");
378        assert_eq!(sanitize_name(""), "server");
379        assert_eq!(sanitize_name("..."), "server");
380    }
381
382    #[test]
383    fn test_sync_adds_new_hosts() {
384        let mut config = empty_config();
385        let section = make_section();
386        let remote = vec![
387            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            ProviderHost {
394                server_id: "456".to_string(),
395                name: "db-1".to_string(),
396                ip: "5.6.7.8".to_string(),
397                tags: Vec::new(),
398            },
399        ];
400
401        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
402        assert_eq!(result.added, 2);
403        assert_eq!(result.updated, 0);
404        assert_eq!(result.unchanged, 0);
405
406        let entries = config.host_entries();
407        assert_eq!(entries.len(), 2);
408        assert_eq!(entries[0].alias, "do-web-1");
409        assert_eq!(entries[0].hostname, "1.2.3.4");
410        assert_eq!(entries[1].alias, "do-db-1");
411    }
412
413    #[test]
414    fn test_sync_updates_changed_ip() {
415        let mut config = empty_config();
416        let section = make_section();
417
418        // First sync: add host
419        let remote = vec![ProviderHost {
420            server_id: "123".to_string(),
421            name: "web-1".to_string(),
422            ip: "1.2.3.4".to_string(),
423            tags: Vec::new(),
424        }];
425        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
426
427        // Second sync: IP changed
428        let remote = vec![ProviderHost {
429            server_id: "123".to_string(),
430            name: "web-1".to_string(),
431            ip: "9.8.7.6".to_string(),
432            tags: Vec::new(),
433        }];
434        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
435        assert_eq!(result.updated, 1);
436        assert_eq!(result.added, 0);
437
438        let entries = config.host_entries();
439        assert_eq!(entries[0].hostname, "9.8.7.6");
440    }
441
442    #[test]
443    fn test_sync_unchanged() {
444        let mut config = empty_config();
445        let section = make_section();
446
447        let remote = vec![ProviderHost {
448            server_id: "123".to_string(),
449            name: "web-1".to_string(),
450            ip: "1.2.3.4".to_string(),
451            tags: Vec::new(),
452        }];
453        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
454
455        // Same data again
456        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
457        assert_eq!(result.unchanged, 1);
458        assert_eq!(result.added, 0);
459        assert_eq!(result.updated, 0);
460    }
461
462    #[test]
463    fn test_sync_removes_deleted() {
464        let mut config = empty_config();
465        let section = make_section();
466
467        let remote = vec![ProviderHost {
468            server_id: "123".to_string(),
469            name: "web-1".to_string(),
470            ip: "1.2.3.4".to_string(),
471            tags: Vec::new(),
472        }];
473        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
474        assert_eq!(config.host_entries().len(), 1);
475
476        // Sync with empty remote list + remove_deleted
477        let result =
478            sync_provider(&mut config, &MockProvider, &[], &section, true, false);
479        assert_eq!(result.removed, 1);
480        assert_eq!(config.host_entries().len(), 0);
481    }
482
483    #[test]
484    fn test_sync_dry_run_no_mutations() {
485        let mut config = empty_config();
486        let section = make_section();
487
488        let remote = vec![ProviderHost {
489            server_id: "123".to_string(),
490            name: "web-1".to_string(),
491            ip: "1.2.3.4".to_string(),
492            tags: Vec::new(),
493        }];
494
495        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
496        assert_eq!(result.added, 1);
497        assert_eq!(config.host_entries().len(), 0); // No actual changes
498    }
499
500    #[test]
501    fn test_sync_dedup_server_id_in_response() {
502        let mut config = empty_config();
503        let section = make_section();
504        let remote = vec![
505            ProviderHost {
506                server_id: "123".to_string(),
507                name: "web-1".to_string(),
508                ip: "1.2.3.4".to_string(),
509                tags: Vec::new(),
510            },
511            ProviderHost {
512                server_id: "123".to_string(),
513                name: "web-1-dup".to_string(),
514                ip: "5.6.7.8".to_string(),
515                tags: Vec::new(),
516            },
517        ];
518
519        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
520        assert_eq!(result.added, 1);
521        assert_eq!(config.host_entries().len(), 1);
522        assert_eq!(config.host_entries()[0].alias, "do-web-1");
523    }
524
525    #[test]
526    fn test_sync_duplicate_local_server_id_keeps_first() {
527        // If duplicate provider markers exist locally, sync should use the first alias
528        let content = "\
529Host do-web-1
530  HostName 1.2.3.4
531  # purple:provider digitalocean:123
532
533Host do-web-1-copy
534  HostName 1.2.3.4
535  # purple:provider digitalocean:123
536";
537        let mut config = SshConfigFile {
538            elements: SshConfigFile::parse_content(content),
539            path: PathBuf::from("/tmp/test_config"),
540            crlf: false,
541        };
542        let section = make_section();
543
544        // Remote has same server_id with updated IP
545        let remote = vec![ProviderHost {
546            server_id: "123".to_string(),
547            name: "web-1".to_string(),
548            ip: "5.6.7.8".to_string(),
549            tags: Vec::new(),
550        }];
551
552        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
553        // Should update the first alias (do-web-1), not the copy
554        assert_eq!(result.updated, 1);
555        assert_eq!(result.added, 0);
556        let entries = config.host_entries();
557        let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
558        assert_eq!(first.hostname, "5.6.7.8");
559        // Copy should remain unchanged
560        let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
561        assert_eq!(copy.hostname, "1.2.3.4");
562    }
563
564    #[test]
565    fn test_sync_no_duplicate_header_on_repeated_sync() {
566        let mut config = empty_config();
567        let section = make_section();
568
569        // First sync: adds header + host
570        let remote = vec![ProviderHost {
571            server_id: "123".to_string(),
572            name: "web-1".to_string(),
573            ip: "1.2.3.4".to_string(),
574            tags: Vec::new(),
575        }];
576        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
577
578        // Second sync: new host added at provider
579        let remote = vec![
580            ProviderHost {
581                server_id: "123".to_string(),
582                name: "web-1".to_string(),
583                ip: "1.2.3.4".to_string(),
584                tags: Vec::new(),
585            },
586            ProviderHost {
587                server_id: "456".to_string(),
588                name: "db-1".to_string(),
589                ip: "5.6.7.8".to_string(),
590                tags: Vec::new(),
591            },
592        ];
593        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
594
595        // Should have exactly one header
596        let header_count = config
597            .elements
598            .iter()
599            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
600            .count();
601        assert_eq!(header_count, 1);
602        assert_eq!(config.host_entries().len(), 2);
603    }
604
605    #[test]
606    fn test_sync_removes_orphan_header() {
607        let mut config = empty_config();
608        let section = make_section();
609
610        // Add a host
611        let remote = vec![ProviderHost {
612            server_id: "123".to_string(),
613            name: "web-1".to_string(),
614            ip: "1.2.3.4".to_string(),
615            tags: Vec::new(),
616        }];
617        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
618
619        // Verify header exists
620        let has_header = config
621            .elements
622            .iter()
623            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
624        assert!(has_header);
625
626        // Remove all hosts (empty remote + remove_deleted)
627        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
628        assert_eq!(result.removed, 1);
629
630        // Header should be cleaned up
631        let has_header = config
632            .elements
633            .iter()
634            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
635        assert!(!has_header);
636    }
637
638    #[test]
639    fn test_sync_writes_provider_tags() {
640        let mut config = empty_config();
641        let section = make_section();
642        let remote = vec![ProviderHost {
643            server_id: "123".to_string(),
644            name: "web-1".to_string(),
645            ip: "1.2.3.4".to_string(),
646            tags: vec!["production".to_string(), "us-east".to_string()],
647        }];
648
649        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
650
651        let entries = config.host_entries();
652        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
653    }
654
655    #[test]
656    fn test_sync_updates_changed_tags() {
657        let mut config = empty_config();
658        let section = make_section();
659
660        // First sync: add with tags
661        let remote = vec![ProviderHost {
662            server_id: "123".to_string(),
663            name: "web-1".to_string(),
664            ip: "1.2.3.4".to_string(),
665            tags: vec!["staging".to_string()],
666        }];
667        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
668        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
669
670        // Second sync: new provider tags added — existing tags are preserved (merge)
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!["production".to_string(), "us-east".to_string()],
676        }];
677        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
678        assert_eq!(result.updated, 1);
679        assert_eq!(
680            config.host_entries()[0].tags,
681            vec!["staging", "production", "us-east"]
682        );
683    }
684
685    #[test]
686    fn test_sync_combined_add_update_remove() {
687        let mut config = empty_config();
688        let section = make_section();
689
690        // First sync: add two hosts
691        let remote = vec![
692            ProviderHost {
693                server_id: "1".to_string(),
694                name: "web".to_string(),
695                ip: "1.1.1.1".to_string(),
696                tags: Vec::new(),
697            },
698            ProviderHost {
699                server_id: "2".to_string(),
700                name: "db".to_string(),
701                ip: "2.2.2.2".to_string(),
702                tags: Vec::new(),
703            },
704        ];
705        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
706        assert_eq!(config.host_entries().len(), 2);
707
708        // Second sync: host 1 IP changed, host 2 removed, host 3 added
709        let remote = vec![
710            ProviderHost {
711                server_id: "1".to_string(),
712                name: "web".to_string(),
713                ip: "9.9.9.9".to_string(),
714                tags: Vec::new(),
715            },
716            ProviderHost {
717                server_id: "3".to_string(),
718                name: "cache".to_string(),
719                ip: "3.3.3.3".to_string(),
720                tags: Vec::new(),
721            },
722        ];
723        let result =
724            sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
725        assert_eq!(result.updated, 1);
726        assert_eq!(result.added, 1);
727        assert_eq!(result.removed, 1);
728
729        let entries = config.host_entries();
730        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
731        assert_eq!(entries[0].alias, "do-web");
732        assert_eq!(entries[0].hostname, "9.9.9.9");
733        assert_eq!(entries[1].alias, "do-cache");
734    }
735
736    #[test]
737    fn test_sync_tag_order_insensitive() {
738        let mut config = empty_config();
739        let section = make_section();
740
741        // First sync: tags in one order
742        let remote = vec![ProviderHost {
743            server_id: "123".to_string(),
744            name: "web-1".to_string(),
745            ip: "1.2.3.4".to_string(),
746            tags: vec!["beta".to_string(), "alpha".to_string()],
747        }];
748        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
749
750        // Second sync: same tags, different order
751        let remote = vec![ProviderHost {
752            server_id: "123".to_string(),
753            name: "web-1".to_string(),
754            ip: "1.2.3.4".to_string(),
755            tags: vec!["alpha".to_string(), "beta".to_string()],
756        }];
757        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
758        assert_eq!(result.unchanged, 1);
759        assert_eq!(result.updated, 0);
760    }
761
762    fn config_with_include_provider_host() -> SshConfigFile {
763        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
764
765        // Build an included host block with provider marker
766        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
767        let included_elements = SshConfigFile::parse_content(content);
768
769        SshConfigFile {
770            elements: vec![ConfigElement::Include(IncludeDirective {
771                raw_line: "Include conf.d/*".to_string(),
772                pattern: "conf.d/*".to_string(),
773                resolved_files: vec![IncludedFile {
774                    path: PathBuf::from("/tmp/included.conf"),
775                    elements: included_elements,
776                }],
777            })],
778            path: PathBuf::from("/tmp/test_config"),
779            crlf: false,
780        }
781    }
782
783    #[test]
784    fn test_sync_include_host_skips_update() {
785        let mut config = config_with_include_provider_host();
786        let section = make_section();
787
788        // Remote has same server with different IP — should NOT update included host
789        let remote = vec![ProviderHost {
790            server_id: "inc1".to_string(),
791            name: "included".to_string(),
792            ip: "9.9.9.9".to_string(),
793            tags: Vec::new(),
794        }];
795        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
796        assert_eq!(result.unchanged, 1);
797        assert_eq!(result.updated, 0);
798        assert_eq!(result.added, 0);
799
800        // Verify IP was NOT changed
801        let entries = config.host_entries();
802        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
803        assert_eq!(included.hostname, "1.2.3.4");
804    }
805
806    #[test]
807    fn test_sync_include_host_skips_remove() {
808        let mut config = config_with_include_provider_host();
809        let section = make_section();
810
811        // Empty remote + remove_deleted — should NOT remove included host
812        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
813        assert_eq!(result.removed, 0);
814        assert_eq!(config.host_entries().len(), 1);
815    }
816
817    #[test]
818    fn test_sync_dry_run_remove_count() {
819        let mut config = empty_config();
820        let section = make_section();
821
822        // Add two hosts
823        let remote = vec![
824            ProviderHost {
825                server_id: "1".to_string(),
826                name: "web".to_string(),
827                ip: "1.1.1.1".to_string(),
828                tags: Vec::new(),
829            },
830            ProviderHost {
831                server_id: "2".to_string(),
832                name: "db".to_string(),
833                ip: "2.2.2.2".to_string(),
834                tags: Vec::new(),
835            },
836        ];
837        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
838        assert_eq!(config.host_entries().len(), 2);
839
840        // Dry-run remove with empty remote — should count but not mutate
841        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
842        assert_eq!(result.removed, 2);
843        assert_eq!(config.host_entries().len(), 2); // Still there
844    }
845
846    #[test]
847    fn test_sync_tags_cleared_remotely_preserved_locally() {
848        let mut config = empty_config();
849        let section = make_section();
850
851        // First sync: host with tags
852        let remote = vec![ProviderHost {
853            server_id: "123".to_string(),
854            name: "web-1".to_string(),
855            ip: "1.2.3.4".to_string(),
856            tags: vec!["production".to_string()],
857        }];
858        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
859        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
860
861        // Second sync: remote tags empty — local tags preserved (may be user-added)
862        let remote = vec![ProviderHost {
863            server_id: "123".to_string(),
864            name: "web-1".to_string(),
865            ip: "1.2.3.4".to_string(),
866            tags: Vec::new(),
867        }];
868        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
869        assert_eq!(result.unchanged, 1);
870        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
871    }
872
873    #[test]
874    fn test_sync_deduplicates_alias() {
875        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
876        let mut config = SshConfigFile {
877            elements: SshConfigFile::parse_content(content),
878            path: PathBuf::from("/tmp/test_config"),
879            crlf: false,
880        };
881        let section = make_section();
882
883        let remote = vec![ProviderHost {
884            server_id: "999".to_string(),
885            name: "web-1".to_string(),
886            ip: "1.2.3.4".to_string(),
887            tags: Vec::new(),
888        }];
889
890        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
891
892        let entries = config.host_entries();
893        // Should have the original + a deduplicated one
894        assert_eq!(entries.len(), 2);
895        assert_eq!(entries[0].alias, "do-web-1");
896        assert_eq!(entries[1].alias, "do-web-1-2");
897    }
898
899    #[test]
900    fn test_sync_renames_on_prefix_change() {
901        let mut config = empty_config();
902        let section = make_section(); // prefix = "do"
903
904        // First sync: add host with "do" prefix
905        let remote = vec![ProviderHost {
906            server_id: "123".to_string(),
907            name: "web-1".to_string(),
908            ip: "1.2.3.4".to_string(),
909            tags: Vec::new(),
910        }];
911        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
912        assert_eq!(config.host_entries()[0].alias, "do-web-1");
913
914        // Second sync: prefix changed to "ocean"
915        let new_section = ProviderSection {
916            alias_prefix: "ocean".to_string(),
917            ..section
918        };
919        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
920        assert_eq!(result.updated, 1);
921        assert_eq!(result.unchanged, 0);
922
923        let entries = config.host_entries();
924        assert_eq!(entries.len(), 1);
925        assert_eq!(entries[0].alias, "ocean-web-1");
926        assert_eq!(entries[0].hostname, "1.2.3.4");
927    }
928
929    #[test]
930    fn test_sync_rename_and_ip_change() {
931        let mut config = empty_config();
932        let section = make_section();
933
934        let remote = vec![ProviderHost {
935            server_id: "123".to_string(),
936            name: "web-1".to_string(),
937            ip: "1.2.3.4".to_string(),
938            tags: Vec::new(),
939        }];
940        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
941
942        // Change both prefix and IP
943        let new_section = ProviderSection {
944            alias_prefix: "ocean".to_string(),
945            ..section
946        };
947        let remote = vec![ProviderHost {
948            server_id: "123".to_string(),
949            name: "web-1".to_string(),
950            ip: "9.9.9.9".to_string(),
951            tags: Vec::new(),
952        }];
953        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
954        assert_eq!(result.updated, 1);
955
956        let entries = config.host_entries();
957        assert_eq!(entries[0].alias, "ocean-web-1");
958        assert_eq!(entries[0].hostname, "9.9.9.9");
959    }
960
961    #[test]
962    fn test_sync_rename_dry_run_no_mutation() {
963        let mut config = empty_config();
964        let section = make_section();
965
966        let remote = vec![ProviderHost {
967            server_id: "123".to_string(),
968            name: "web-1".to_string(),
969            ip: "1.2.3.4".to_string(),
970            tags: Vec::new(),
971        }];
972        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
973
974        let new_section = ProviderSection {
975            alias_prefix: "ocean".to_string(),
976            ..section
977        };
978        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
979        assert_eq!(result.updated, 1);
980
981        // Config should be unchanged (dry run)
982        assert_eq!(config.host_entries()[0].alias, "do-web-1");
983    }
984
985    #[test]
986    fn test_sync_no_rename_when_prefix_unchanged() {
987        let mut config = empty_config();
988        let section = make_section();
989
990        let remote = vec![ProviderHost {
991            server_id: "123".to_string(),
992            name: "web-1".to_string(),
993            ip: "1.2.3.4".to_string(),
994            tags: Vec::new(),
995        }];
996        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
997
998        // Same prefix, same everything — should be unchanged
999        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1000        assert_eq!(result.unchanged, 1);
1001        assert_eq!(result.updated, 0);
1002        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1003    }
1004
1005    #[test]
1006    fn test_sync_manual_comment_survives_cleanup() {
1007        // A manual "# DigitalOcean" comment (without purple:group prefix)
1008        // should NOT be removed when provider hosts are deleted
1009        let content = "# DigitalOcean\nHost do-web\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
1010        let mut config = SshConfigFile {
1011            elements: SshConfigFile::parse_content(content),
1012            path: PathBuf::from("/tmp/test_config"),
1013            crlf: false,
1014        };
1015        let section = make_section();
1016
1017        // Remove all hosts (empty remote + remove_deleted)
1018        sync_provider(&mut config, &MockProvider, &[], &section, true, false);
1019
1020        // The manual "# DigitalOcean" comment should survive (it doesn't have purple:group prefix)
1021        let has_manual = config
1022            .elements
1023            .iter()
1024            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
1025        assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
1026    }
1027
1028    #[test]
1029    fn test_sync_rename_skips_included_host() {
1030        let mut config = config_with_include_provider_host();
1031
1032        let new_section = ProviderSection {
1033            provider: "digitalocean".to_string(),
1034            token: "test".to_string(),
1035            alias_prefix: "ocean".to_string(), // Different prefix
1036            user: "root".to_string(),
1037            identity_file: String::new(),
1038            url: String::new(),
1039            verify_tls: true,
1040            auto_sync: true,
1041        };
1042
1043        // Remote has the included host's server_id with a different prefix
1044        let remote = vec![ProviderHost {
1045            server_id: "inc1".to_string(),
1046            name: "included".to_string(),
1047            ip: "1.2.3.4".to_string(),
1048            tags: Vec::new(),
1049        }];
1050        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1051        assert_eq!(result.unchanged, 1);
1052        assert_eq!(result.updated, 0);
1053
1054        // Alias should remain unchanged (included hosts are read-only)
1055        assert_eq!(config.host_entries()[0].alias, "do-included");
1056    }
1057
1058    #[test]
1059    fn test_sync_rename_stable_with_manual_collision() {
1060        let mut config = empty_config();
1061        let section = make_section(); // prefix = "do"
1062
1063        // First sync: add provider host
1064        let remote = vec![ProviderHost {
1065            server_id: "123".to_string(),
1066            name: "web-1".to_string(),
1067            ip: "1.2.3.4".to_string(),
1068            tags: Vec::new(),
1069        }];
1070        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1071        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1072
1073        // Manually add a host that will collide with the renamed alias
1074        let manual = HostEntry {
1075            alias: "ocean-web-1".to_string(),
1076            hostname: "5.5.5.5".to_string(),
1077            ..Default::default()
1078        };
1079        config.add_host(&manual);
1080
1081        // Second sync: prefix changes to "ocean", collides with manual host
1082        let new_section = ProviderSection {
1083            alias_prefix: "ocean".to_string(),
1084            ..section.clone()
1085        };
1086        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1087        assert_eq!(result.updated, 1);
1088
1089        let entries = config.host_entries();
1090        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1091        assert_eq!(provider_host.alias, "ocean-web-1-2");
1092
1093        // Third sync: same state. Should be stable (not flip to -3)
1094        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1095        assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1096
1097        let entries = config.host_entries();
1098        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1099        assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
1100    }
1101
1102    #[test]
1103    fn test_sync_preserves_user_tags() {
1104        let mut config = empty_config();
1105        let section = make_section();
1106
1107        // First sync: add host with provider tag
1108        let remote = vec![ProviderHost {
1109            server_id: "123".to_string(),
1110            name: "web-1".to_string(),
1111            ip: "1.2.3.4".to_string(),
1112            tags: vec!["nyc1".to_string()],
1113        }];
1114        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1115        assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1116
1117        // User manually adds a tag via the TUI
1118        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1119        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1120
1121        // Second sync: same provider tags — user tag "prod" must survive
1122        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1123        assert_eq!(result.unchanged, 1);
1124        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1125    }
1126
1127    #[test]
1128    fn test_sync_merges_new_provider_tag_with_user_tags() {
1129        let mut config = empty_config();
1130        let section = make_section();
1131
1132        // First sync: add host with provider tag
1133        let remote = vec![ProviderHost {
1134            server_id: "123".to_string(),
1135            name: "web-1".to_string(),
1136            ip: "1.2.3.4".to_string(),
1137            tags: vec!["nyc1".to_string()],
1138        }];
1139        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1140
1141        // User manually adds a tag
1142        config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
1143
1144        // Second sync: provider adds a new tag — user tag must be preserved
1145        let remote = vec![ProviderHost {
1146            server_id: "123".to_string(),
1147            name: "web-1".to_string(),
1148            ip: "1.2.3.4".to_string(),
1149            tags: vec!["nyc1".to_string(), "v2".to_string()],
1150        }];
1151        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1152        assert_eq!(result.updated, 1);
1153        let tags = &config.host_entries()[0].tags;
1154        assert!(tags.contains(&"nyc1".to_string()));
1155        assert!(tags.contains(&"critical".to_string()));
1156        assert!(tags.contains(&"v2".to_string()));
1157    }
1158
1159    #[test]
1160    fn test_sync_reset_tags_replaces_local_tags() {
1161        let mut config = empty_config();
1162        let section = make_section();
1163
1164        // First sync: add host with provider tag
1165        let remote = vec![ProviderHost {
1166            server_id: "123".to_string(),
1167            name: "web-1".to_string(),
1168            ip: "1.2.3.4".to_string(),
1169            tags: vec!["nyc1".to_string()],
1170        }];
1171        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1172
1173        // User manually adds a tag
1174        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1175        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1176
1177        // Sync with reset_tags: user tag "prod" is removed
1178        let result = sync_provider_with_options(
1179            &mut config, &MockProvider, &remote, &section, false, false, true,
1180        );
1181        assert_eq!(result.updated, 1);
1182        assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1183    }
1184
1185    #[test]
1186    fn test_sync_reset_tags_clears_stale_tags() {
1187        let mut config = empty_config();
1188        let section = make_section();
1189
1190        // First sync: host with tags
1191        let remote = vec![ProviderHost {
1192            server_id: "123".to_string(),
1193            name: "web-1".to_string(),
1194            ip: "1.2.3.4".to_string(),
1195            tags: vec!["staging".to_string()],
1196        }];
1197        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1198
1199        // Second sync with reset_tags: provider removed all tags
1200        let remote = vec![ProviderHost {
1201            server_id: "123".to_string(),
1202            name: "web-1".to_string(),
1203            ip: "1.2.3.4".to_string(),
1204            tags: Vec::new(),
1205        }];
1206        let result = sync_provider_with_options(
1207            &mut config, &MockProvider, &remote, &section, false, false, true,
1208        );
1209        assert_eq!(result.updated, 1);
1210        assert!(config.host_entries()[0].tags.is_empty());
1211    }
1212
1213    #[test]
1214    fn test_sync_reset_tags_unchanged_when_matching() {
1215        let mut config = empty_config();
1216        let section = make_section();
1217
1218        // Sync: add host with tags
1219        let remote = vec![ProviderHost {
1220            server_id: "123".to_string(),
1221            name: "web-1".to_string(),
1222            ip: "1.2.3.4".to_string(),
1223            tags: vec!["prod".to_string(), "nyc1".to_string()],
1224        }];
1225        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1226
1227        // Reset-tags sync with same tags (different order): unchanged
1228        let remote = vec![ProviderHost {
1229            server_id: "123".to_string(),
1230            name: "web-1".to_string(),
1231            ip: "1.2.3.4".to_string(),
1232            tags: vec!["nyc1".to_string(), "prod".to_string()],
1233        }];
1234        let result = sync_provider_with_options(
1235            &mut config, &MockProvider, &remote, &section, false, false, true,
1236        );
1237        assert_eq!(result.unchanged, 1);
1238    }
1239
1240    #[test]
1241    fn test_sync_merge_case_insensitive() {
1242        let mut config = empty_config();
1243        let section = make_section();
1244
1245        // First sync: add host with lowercase tag
1246        let remote = vec![ProviderHost {
1247            server_id: "123".to_string(),
1248            name: "web-1".to_string(),
1249            ip: "1.2.3.4".to_string(),
1250            tags: vec!["prod".to_string()],
1251        }];
1252        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1253        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1254
1255        // Second sync: provider returns same tag with different casing — no duplicate
1256        let remote = vec![ProviderHost {
1257            server_id: "123".to_string(),
1258            name: "web-1".to_string(),
1259            ip: "1.2.3.4".to_string(),
1260            tags: vec!["Prod".to_string()],
1261        }];
1262        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1263        assert_eq!(result.unchanged, 1);
1264        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1265    }
1266
1267    #[test]
1268    fn test_sync_reset_tags_case_insensitive_unchanged() {
1269        let mut config = empty_config();
1270        let section = make_section();
1271
1272        // Sync: add host with tag
1273        let remote = vec![ProviderHost {
1274            server_id: "123".to_string(),
1275            name: "web-1".to_string(),
1276            ip: "1.2.3.4".to_string(),
1277            tags: vec!["prod".to_string()],
1278        }];
1279        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1280
1281        // Reset-tags sync with different casing: unchanged (case-insensitive comparison)
1282        let remote = vec![ProviderHost {
1283            server_id: "123".to_string(),
1284            name: "web-1".to_string(),
1285            ip: "1.2.3.4".to_string(),
1286            tags: vec!["Prod".to_string()],
1287        }];
1288        let result = sync_provider_with_options(
1289            &mut config, &MockProvider, &remote, &section, false, false, true,
1290        );
1291        assert_eq!(result.unchanged, 1);
1292    }
1293
1294    // --- Empty IP (stopped/no-IP VM) tests ---
1295
1296    #[test]
1297    fn test_sync_empty_ip_not_added() {
1298        let mut config = empty_config();
1299        let section = make_section();
1300        let remote = vec![ProviderHost {
1301            server_id: "100".to_string(),
1302            name: "stopped-vm".to_string(),
1303            ip: String::new(),
1304            tags: Vec::new(),
1305        }];
1306        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1307        assert_eq!(result.added, 0);
1308        assert_eq!(config.host_entries().len(), 0);
1309    }
1310
1311    #[test]
1312    fn test_sync_empty_ip_existing_host_unchanged() {
1313        let mut config = empty_config();
1314        let section = make_section();
1315
1316        // First sync: add host with IP
1317        let remote = vec![ProviderHost {
1318            server_id: "100".to_string(),
1319            name: "web".to_string(),
1320            ip: "1.2.3.4".to_string(),
1321            tags: Vec::new(),
1322        }];
1323        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1324        assert_eq!(config.host_entries().len(), 1);
1325        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1326
1327        // Second sync: VM stopped, empty IP. Host should stay unchanged.
1328        let remote = vec![ProviderHost {
1329            server_id: "100".to_string(),
1330            name: "web".to_string(),
1331            ip: String::new(),
1332            tags: Vec::new(),
1333        }];
1334        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1335        assert_eq!(result.unchanged, 1);
1336        assert_eq!(result.updated, 0);
1337        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1338    }
1339
1340    #[test]
1341    fn test_sync_remove_skips_empty_ip_hosts() {
1342        let mut config = empty_config();
1343        let section = make_section();
1344
1345        // First sync: add two hosts
1346        let remote = vec![
1347            ProviderHost {
1348                server_id: "100".to_string(),
1349                name: "web".to_string(),
1350                ip: "1.2.3.4".to_string(),
1351                tags: Vec::new(),
1352            },
1353            ProviderHost {
1354                server_id: "200".to_string(),
1355                name: "db".to_string(),
1356                ip: "5.6.7.8".to_string(),
1357                tags: Vec::new(),
1358            },
1359        ];
1360        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1361        assert_eq!(config.host_entries().len(), 2);
1362
1363        // Second sync with --remove: web is running, db is stopped (empty IP).
1364        // db must NOT be removed.
1365        let remote = vec![
1366            ProviderHost {
1367                server_id: "100".to_string(),
1368                name: "web".to_string(),
1369                ip: "1.2.3.4".to_string(),
1370                tags: Vec::new(),
1371            },
1372            ProviderHost {
1373                server_id: "200".to_string(),
1374                name: "db".to_string(),
1375                ip: String::new(),
1376                tags: Vec::new(),
1377            },
1378        ];
1379        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1380        assert_eq!(result.removed, 0);
1381        assert_eq!(result.unchanged, 2);
1382        assert_eq!(config.host_entries().len(), 2);
1383    }
1384
1385    #[test]
1386    fn test_sync_remove_deletes_truly_gone_hosts() {
1387        let mut config = empty_config();
1388        let section = make_section();
1389
1390        // First sync: add two hosts
1391        let remote = vec![
1392            ProviderHost {
1393                server_id: "100".to_string(),
1394                name: "web".to_string(),
1395                ip: "1.2.3.4".to_string(),
1396                tags: Vec::new(),
1397            },
1398            ProviderHost {
1399                server_id: "200".to_string(),
1400                name: "db".to_string(),
1401                ip: "5.6.7.8".to_string(),
1402                tags: Vec::new(),
1403            },
1404        ];
1405        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1406        assert_eq!(config.host_entries().len(), 2);
1407
1408        // Second sync with --remove: only web exists. db is truly deleted.
1409        let remote = vec![ProviderHost {
1410            server_id: "100".to_string(),
1411            name: "web".to_string(),
1412            ip: "1.2.3.4".to_string(),
1413            tags: Vec::new(),
1414        }];
1415        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1416        assert_eq!(result.removed, 1);
1417        assert_eq!(config.host_entries().len(), 1);
1418        assert_eq!(config.host_entries()[0].alias, "do-web");
1419    }
1420
1421    #[test]
1422    fn test_sync_mixed_resolved_empty_and_missing() {
1423        let mut config = empty_config();
1424        let section = make_section();
1425
1426        // First sync: add three hosts
1427        let remote = vec![
1428            ProviderHost {
1429                server_id: "1".to_string(),
1430                name: "running".to_string(),
1431                ip: "1.1.1.1".to_string(),
1432                tags: Vec::new(),
1433            },
1434            ProviderHost {
1435                server_id: "2".to_string(),
1436                name: "stopped".to_string(),
1437                ip: "2.2.2.2".to_string(),
1438                tags: Vec::new(),
1439            },
1440            ProviderHost {
1441                server_id: "3".to_string(),
1442                name: "deleted".to_string(),
1443                ip: "3.3.3.3".to_string(),
1444                tags: Vec::new(),
1445            },
1446        ];
1447        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1448        assert_eq!(config.host_entries().len(), 3);
1449
1450        // Second sync with --remove:
1451        // - "running" has new IP (updated)
1452        // - "stopped" has empty IP (unchanged, not removed)
1453        // - "deleted" not in list (removed)
1454        let remote = vec![
1455            ProviderHost {
1456                server_id: "1".to_string(),
1457                name: "running".to_string(),
1458                ip: "9.9.9.9".to_string(),
1459                tags: Vec::new(),
1460            },
1461            ProviderHost {
1462                server_id: "2".to_string(),
1463                name: "stopped".to_string(),
1464                ip: String::new(),
1465                tags: Vec::new(),
1466            },
1467        ];
1468        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1469        assert_eq!(result.updated, 1);
1470        assert_eq!(result.unchanged, 1);
1471        assert_eq!(result.removed, 1);
1472
1473        let entries = config.host_entries();
1474        assert_eq!(entries.len(), 2);
1475        // Running host got new IP
1476        let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
1477        assert_eq!(running.hostname, "9.9.9.9");
1478        // Stopped host kept old IP
1479        let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
1480        assert_eq!(stopped.hostname, "2.2.2.2");
1481    }
1482}