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/// Whether a metadata key is volatile (changes frequently without user action).
50/// Volatile keys are excluded from the sync diff comparison so that a status
51/// change alone does not trigger an SSH config rewrite. The value is still
52/// stored and displayed when the host is updated for other reasons.
53fn is_volatile_meta(key: &str) -> bool {
54    key == "status"
55}
56
57/// Sync hosts from a cloud provider into the SSH config.
58pub fn sync_provider(
59    config: &mut SshConfigFile,
60    provider: &dyn Provider,
61    remote_hosts: &[ProviderHost],
62    section: &ProviderSection,
63    remove_deleted: bool,
64    dry_run: bool,
65) -> SyncResult {
66    sync_provider_with_options(
67        config,
68        provider,
69        remote_hosts,
70        section,
71        remove_deleted,
72        dry_run,
73        false,
74    )
75}
76
77/// Sync hosts from a cloud provider into the SSH config.
78/// When `reset_tags` is true, local tags are replaced with provider tags
79/// instead of being merged (cleans up stale tags).
80pub fn sync_provider_with_options(
81    config: &mut SshConfigFile,
82    provider: &dyn Provider,
83    remote_hosts: &[ProviderHost],
84    section: &ProviderSection,
85    remove_deleted: bool,
86    dry_run: bool,
87    reset_tags: bool,
88) -> SyncResult {
89    let mut result = SyncResult::default();
90
91    // Build map of server_id -> alias (top-level only, no Include files).
92    // Keep first occurrence if duplicate provider markers exist (e.g. manual copy).
93    let existing = config.find_hosts_by_provider(provider.name());
94    let mut existing_map: HashMap<String, String> = HashMap::new();
95    for (alias, server_id) in &existing {
96        existing_map
97            .entry(server_id.clone())
98            .or_insert_with(|| alias.clone());
99    }
100
101    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
102    let entries_map: HashMap<String, HostEntry> = config
103        .host_entries()
104        .into_iter()
105        .map(|e| (e.alias.clone(), e))
106        .collect();
107
108    // Track which server IDs are still in the remote set (also deduplicates)
109    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
110
111    // Only add group header if this provider has no existing hosts in config
112    let mut needs_header = !dry_run && existing_map.is_empty();
113
114    for remote in remote_hosts {
115        if !remote_ids.insert(remote.server_id.clone()) {
116            continue; // Skip duplicate server_id in same response
117        }
118
119        // Empty IP means the resource exists but has no resolvable address
120        // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
121        // won't delete it, but skip add/update.
122        if remote.ip.is_empty() {
123            if existing_map.contains_key(&remote.server_id) {
124                result.unchanged += 1;
125            }
126            continue;
127        }
128
129        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
130            // Host exists, check if alias, IP or tags changed
131            if let Some(entry) = entries_map.get(existing_alias) {
132                // Included hosts are read-only; recognize them for dedup but skip mutations
133                if entry.source_file.is_some() {
134                    result.unchanged += 1;
135                    continue;
136                }
137
138                // Check if alias prefix changed (e.g. "do" → "ocean")
139                let sanitized = sanitize_name(&remote.name);
140                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
141                let alias_changed = *existing_alias != expected_alias;
142
143                let ip_changed = entry.hostname != remote.ip;
144                let meta_changed = {
145                    let mut local: Vec<(&str, &str)> = entry
146                        .provider_meta
147                        .iter()
148                        .filter(|(k, _)| !is_volatile_meta(k))
149                        .map(|(k, v)| (k.as_str(), v.as_str()))
150                        .collect();
151                    local.sort();
152                    let mut remote_m: Vec<(&str, &str)> = remote
153                        .metadata
154                        .iter()
155                        .filter(|(k, _)| !is_volatile_meta(k))
156                        .map(|(k, v)| (k.as_str(), v.as_str()))
157                        .collect();
158                    remote_m.sort();
159                    local != remote_m
160                };
161                let trimmed_remote: Vec<String> =
162                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
163                let tags_changed = if reset_tags {
164                    // Exact comparison (case-insensitive): replace local tags with provider tags
165                    let mut sorted_local: Vec<String> =
166                        entry.tags.iter().map(|t| t.to_lowercase()).collect();
167                    sorted_local.sort();
168                    let mut sorted_remote: Vec<String> =
169                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
170                    sorted_remote.sort();
171                    sorted_local != sorted_remote
172                } else {
173                    // Subset check (case-insensitive): only trigger when provider tags are missing locally
174                    trimmed_remote.iter().any(|rt| {
175                        !entry
176                            .tags
177                            .iter()
178                            .any(|lt| lt.eq_ignore_ascii_case(rt))
179                    })
180                };
181                if alias_changed || ip_changed || tags_changed || meta_changed {
182                    if dry_run {
183                        result.updated += 1;
184                    } else {
185                        // Compute the final alias (dedup handles collisions,
186                        // excluding the host being renamed so it doesn't collide with itself)
187                        let new_alias = if alias_changed {
188                            config.deduplicate_alias_excluding(
189                                &expected_alias,
190                                Some(existing_alias),
191                            )
192                        } else {
193                            existing_alias.clone()
194                        };
195                        // Re-evaluate: dedup may resolve back to the current alias
196                        let alias_changed = new_alias != *existing_alias;
197
198                        if alias_changed || ip_changed || tags_changed || meta_changed {
199                            if alias_changed || ip_changed {
200                                let updated = HostEntry {
201                                    alias: new_alias.clone(),
202                                    hostname: remote.ip.clone(),
203                                    ..entry.clone()
204                                };
205                                config.update_host(existing_alias, &updated);
206                            }
207                            // Tags lookup uses the new alias after rename
208                            let tags_alias =
209                                if alias_changed { &new_alias } else { existing_alias };
210                            if tags_changed {
211                                if reset_tags {
212                                    config.set_host_tags(tags_alias, &trimmed_remote);
213                                } else {
214                                    // Merge (case-insensitive): keep existing local tags, add missing remote tags
215                                    let mut merged = entry.tags.clone();
216                                    for rt in &trimmed_remote {
217                                        if !merged.iter().any(|t| t.eq_ignore_ascii_case(rt)) {
218                                            merged.push(rt.clone());
219                                        }
220                                    }
221                                    config.set_host_tags(tags_alias, &merged);
222                                }
223                            }
224                            // Update provider marker with new alias
225                            if alias_changed {
226                                config.set_host_provider(
227                                    &new_alias,
228                                    provider.name(),
229                                    &remote.server_id,
230                                );
231                                result.renames.push((existing_alias.clone(), new_alias.clone()));
232                            }
233                            // Update metadata
234                            if meta_changed {
235                                config.set_host_meta(tags_alias, &remote.metadata);
236                            }
237                            result.updated += 1;
238                        } else {
239                            result.unchanged += 1;
240                        }
241                    }
242                } else {
243                    result.unchanged += 1;
244                }
245            } else {
246                result.unchanged += 1;
247            }
248        } else {
249            // New host
250            let sanitized = sanitize_name(&remote.name);
251            let base_alias = build_alias(&section.alias_prefix, &sanitized);
252            let alias = if dry_run {
253                base_alias
254            } else {
255                config.deduplicate_alias(&base_alias)
256            };
257
258            if !dry_run {
259                // Add group header before the very first host for this provider
260                let wrote_header = needs_header;
261                if needs_header {
262                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
263                        config
264                            .elements
265                            .push(ConfigElement::GlobalLine(String::new()));
266                    }
267                    config
268                        .elements
269                        .push(ConfigElement::GlobalLine(format!(
270                            "# purple:group {}",
271                            super::provider_display_name(provider.name())
272                        )));
273                    needs_header = false;
274                }
275
276                let entry = HostEntry {
277                    alias: alias.clone(),
278                    hostname: remote.ip.clone(),
279                    user: section.user.clone(),
280                    identity_file: section.identity_file.clone(),
281                    tags: remote.tags.clone(),
282                    provider: Some(provider.name().to_string()),
283                    ..Default::default()
284                };
285
286                // Add blank line separator before host (skip when preceded by group header
287                // so the header stays adjacent to the first host)
288                if !wrote_header
289                    && !config.elements.is_empty()
290                    && !config.last_element_has_trailing_blank()
291                {
292                    config
293                        .elements
294                        .push(ConfigElement::GlobalLine(String::new()));
295                }
296
297                let block = SshConfigFile::entry_to_block(&entry);
298                config.elements.push(ConfigElement::HostBlock(block));
299                config.set_host_provider(&alias, provider.name(), &remote.server_id);
300                if !remote.tags.is_empty() {
301                    config.set_host_tags(&alias, &remote.tags);
302                }
303                if !remote.metadata.is_empty() {
304                    config.set_host_meta(&alias, &remote.metadata);
305                }
306            }
307
308            result.added += 1;
309        }
310    }
311
312    // Remove deleted hosts (skip included hosts which are read-only)
313    if remove_deleted && !dry_run {
314        let to_remove: Vec<String> = existing_map
315            .iter()
316            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
317            .filter(|(_, alias)| {
318                entries_map
319                    .get(alias.as_str())
320                    .is_none_or(|e| e.source_file.is_none())
321            })
322            .map(|(_, alias)| alias.clone())
323            .collect();
324        for alias in &to_remove {
325            config.delete_host(alias);
326        }
327        result.removed = to_remove.len();
328
329        // Clean up orphan provider header if all hosts for this provider were removed
330        if config.find_hosts_by_provider(provider.name()).is_empty() {
331            let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
332            config
333                .elements
334                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
335        }
336    } else if remove_deleted {
337        result.removed = existing_map
338            .iter()
339            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
340            .filter(|(_, alias)| {
341                entries_map
342                    .get(alias.as_str())
343                    .is_none_or(|e| e.source_file.is_none())
344            })
345            .count();
346    }
347
348    result
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::path::PathBuf;
355
356    fn empty_config() -> SshConfigFile {
357        SshConfigFile {
358            elements: Vec::new(),
359            path: PathBuf::from("/tmp/test_config"),
360            crlf: false,
361        }
362    }
363
364    fn make_section() -> ProviderSection {
365        ProviderSection {
366            provider: "digitalocean".to_string(),
367            token: "test".to_string(),
368            alias_prefix: "do".to_string(),
369            user: "root".to_string(),
370            identity_file: String::new(),
371            url: String::new(),
372            verify_tls: true,
373            auto_sync: true,
374            profile: String::new(),
375            regions: String::new(),
376            project: String::new(),
377        }
378    }
379
380    struct MockProvider;
381    impl Provider for MockProvider {
382        fn name(&self) -> &str {
383            "digitalocean"
384        }
385        fn short_label(&self) -> &str {
386            "do"
387        }
388        fn fetch_hosts_cancellable(
389            &self,
390            _token: &str,
391            _cancel: &std::sync::atomic::AtomicBool,
392        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
393            Ok(Vec::new())
394        }
395    }
396
397    #[test]
398    fn test_build_alias() {
399        assert_eq!(build_alias("do", "web-1"), "do-web-1");
400        assert_eq!(build_alias("", "web-1"), "web-1");
401        assert_eq!(build_alias("ocean", "db"), "ocean-db");
402    }
403
404    #[test]
405    fn test_sanitize_name() {
406        assert_eq!(sanitize_name("web-1"), "web-1");
407        assert_eq!(sanitize_name("My Server"), "my-server");
408        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
409        assert_eq!(sanitize_name("--weird--"), "weird");
410        assert_eq!(sanitize_name("UPPER"), "upper");
411        assert_eq!(sanitize_name("a--b"), "a-b");
412        assert_eq!(sanitize_name(""), "server");
413        assert_eq!(sanitize_name("..."), "server");
414    }
415
416    #[test]
417    fn test_sync_adds_new_hosts() {
418        let mut config = empty_config();
419        let section = make_section();
420        let remote = vec![
421            ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
422            ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
423        ];
424
425        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
426        assert_eq!(result.added, 2);
427        assert_eq!(result.updated, 0);
428        assert_eq!(result.unchanged, 0);
429
430        let entries = config.host_entries();
431        assert_eq!(entries.len(), 2);
432        assert_eq!(entries[0].alias, "do-web-1");
433        assert_eq!(entries[0].hostname, "1.2.3.4");
434        assert_eq!(entries[1].alias, "do-db-1");
435    }
436
437    #[test]
438    fn test_sync_updates_changed_ip() {
439        let mut config = empty_config();
440        let section = make_section();
441
442        // First sync: add host
443        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
444        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
445
446        // Second sync: IP changed
447        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.8.7.6".to_string(), Vec::new())];
448        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
449        assert_eq!(result.updated, 1);
450        assert_eq!(result.added, 0);
451
452        let entries = config.host_entries();
453        assert_eq!(entries[0].hostname, "9.8.7.6");
454    }
455
456    #[test]
457    fn test_sync_unchanged() {
458        let mut config = empty_config();
459        let section = make_section();
460
461        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
462        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
463
464        // Same data again
465        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
466        assert_eq!(result.unchanged, 1);
467        assert_eq!(result.added, 0);
468        assert_eq!(result.updated, 0);
469    }
470
471    #[test]
472    fn test_sync_removes_deleted() {
473        let mut config = empty_config();
474        let section = make_section();
475
476        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
477        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
478        assert_eq!(config.host_entries().len(), 1);
479
480        // Sync with empty remote list + remove_deleted
481        let result =
482            sync_provider(&mut config, &MockProvider, &[], &section, true, false);
483        assert_eq!(result.removed, 1);
484        assert_eq!(config.host_entries().len(), 0);
485    }
486
487    #[test]
488    fn test_sync_dry_run_no_mutations() {
489        let mut config = empty_config();
490        let section = make_section();
491
492        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
493
494        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
495        assert_eq!(result.added, 1);
496        assert_eq!(config.host_entries().len(), 0); // No actual changes
497    }
498
499    #[test]
500    fn test_sync_dedup_server_id_in_response() {
501        let mut config = empty_config();
502        let section = make_section();
503        let remote = vec![
504            ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
505            ProviderHost::new("123".to_string(), "web-1-dup".to_string(), "5.6.7.8".to_string(), Vec::new()),
506        ];
507
508        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
509        assert_eq!(result.added, 1);
510        assert_eq!(config.host_entries().len(), 1);
511        assert_eq!(config.host_entries()[0].alias, "do-web-1");
512    }
513
514    #[test]
515    fn test_sync_duplicate_local_server_id_keeps_first() {
516        // If duplicate provider markers exist locally, sync should use the first alias
517        let content = "\
518Host do-web-1
519  HostName 1.2.3.4
520  # purple:provider digitalocean:123
521
522Host do-web-1-copy
523  HostName 1.2.3.4
524  # purple:provider digitalocean:123
525";
526        let mut config = SshConfigFile {
527            elements: SshConfigFile::parse_content(content),
528            path: PathBuf::from("/tmp/test_config"),
529            crlf: false,
530        };
531        let section = make_section();
532
533        // Remote has same server_id with updated IP
534        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "5.6.7.8".to_string(), Vec::new())];
535
536        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
537        // Should update the first alias (do-web-1), not the copy
538        assert_eq!(result.updated, 1);
539        assert_eq!(result.added, 0);
540        let entries = config.host_entries();
541        let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
542        assert_eq!(first.hostname, "5.6.7.8");
543        // Copy should remain unchanged
544        let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
545        assert_eq!(copy.hostname, "1.2.3.4");
546    }
547
548    #[test]
549    fn test_sync_no_duplicate_header_on_repeated_sync() {
550        let mut config = empty_config();
551        let section = make_section();
552
553        // First sync: adds header + host
554        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
555        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
556
557        // Second sync: new host added at provider
558        let remote = vec![
559            ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
560            ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
561        ];
562        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
563
564        // Should have exactly one header
565        let header_count = config
566            .elements
567            .iter()
568            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
569            .count();
570        assert_eq!(header_count, 1);
571        assert_eq!(config.host_entries().len(), 2);
572    }
573
574    #[test]
575    fn test_sync_removes_orphan_header() {
576        let mut config = empty_config();
577        let section = make_section();
578
579        // Add a host
580        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
581        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
582
583        // Verify header exists
584        let has_header = config
585            .elements
586            .iter()
587            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
588        assert!(has_header);
589
590        // Remove all hosts (empty remote + remove_deleted)
591        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
592        assert_eq!(result.removed, 1);
593
594        // Header should be cleaned up
595        let has_header = config
596            .elements
597            .iter()
598            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
599        assert!(!has_header);
600    }
601
602    #[test]
603    fn test_sync_writes_provider_tags() {
604        let mut config = empty_config();
605        let section = make_section();
606        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
607
608        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
609
610        let entries = config.host_entries();
611        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
612    }
613
614    #[test]
615    fn test_sync_updates_changed_tags() {
616        let mut config = empty_config();
617        let section = make_section();
618
619        // First sync: add with tags
620        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
621        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
622        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
623
624        // Second sync: new provider tags added — existing tags are preserved (merge)
625        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
626        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
627        assert_eq!(result.updated, 1);
628        assert_eq!(
629            config.host_entries()[0].tags,
630            vec!["staging", "production", "us-east"]
631        );
632    }
633
634    #[test]
635    fn test_sync_combined_add_update_remove() {
636        let mut config = empty_config();
637        let section = make_section();
638
639        // First sync: add two hosts
640        let remote = vec![
641            ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
642            ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
643        ];
644        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
645        assert_eq!(config.host_entries().len(), 2);
646
647        // Second sync: host 1 IP changed, host 2 removed, host 3 added
648        let remote = vec![
649            ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new()),
650            ProviderHost::new("3".to_string(), "cache".to_string(), "3.3.3.3".to_string(), Vec::new()),
651        ];
652        let result =
653            sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
654        assert_eq!(result.updated, 1);
655        assert_eq!(result.added, 1);
656        assert_eq!(result.removed, 1);
657
658        let entries = config.host_entries();
659        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
660        assert_eq!(entries[0].alias, "do-web");
661        assert_eq!(entries[0].hostname, "9.9.9.9");
662        assert_eq!(entries[1].alias, "do-cache");
663    }
664
665    #[test]
666    fn test_sync_tag_order_insensitive() {
667        let mut config = empty_config();
668        let section = make_section();
669
670        // First sync: tags in one order
671        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["beta".to_string(), "alpha".to_string()])];
672        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
673
674        // Second sync: same tags, different order
675        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["alpha".to_string(), "beta".to_string()])];
676        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
677        assert_eq!(result.unchanged, 1);
678        assert_eq!(result.updated, 0);
679    }
680
681    fn config_with_include_provider_host() -> SshConfigFile {
682        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
683
684        // Build an included host block with provider marker
685        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
686        let included_elements = SshConfigFile::parse_content(content);
687
688        SshConfigFile {
689            elements: vec![ConfigElement::Include(IncludeDirective {
690                raw_line: "Include conf.d/*".to_string(),
691                pattern: "conf.d/*".to_string(),
692                resolved_files: vec![IncludedFile {
693                    path: PathBuf::from("/tmp/included.conf"),
694                    elements: included_elements,
695                }],
696            })],
697            path: PathBuf::from("/tmp/test_config"),
698            crlf: false,
699        }
700    }
701
702    #[test]
703    fn test_sync_include_host_skips_update() {
704        let mut config = config_with_include_provider_host();
705        let section = make_section();
706
707        // Remote has same server with different IP — should NOT update included host
708        let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "9.9.9.9".to_string(), Vec::new())];
709        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
710        assert_eq!(result.unchanged, 1);
711        assert_eq!(result.updated, 0);
712        assert_eq!(result.added, 0);
713
714        // Verify IP was NOT changed
715        let entries = config.host_entries();
716        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
717        assert_eq!(included.hostname, "1.2.3.4");
718    }
719
720    #[test]
721    fn test_sync_include_host_skips_remove() {
722        let mut config = config_with_include_provider_host();
723        let section = make_section();
724
725        // Empty remote + remove_deleted — should NOT remove included host
726        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
727        assert_eq!(result.removed, 0);
728        assert_eq!(config.host_entries().len(), 1);
729    }
730
731    #[test]
732    fn test_sync_dry_run_remove_count() {
733        let mut config = empty_config();
734        let section = make_section();
735
736        // Add two hosts
737        let remote = vec![
738            ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
739            ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
740        ];
741        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
742        assert_eq!(config.host_entries().len(), 2);
743
744        // Dry-run remove with empty remote — should count but not mutate
745        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
746        assert_eq!(result.removed, 2);
747        assert_eq!(config.host_entries().len(), 2); // Still there
748    }
749
750    #[test]
751    fn test_sync_tags_cleared_remotely_preserved_locally() {
752        let mut config = empty_config();
753        let section = make_section();
754
755        // First sync: host with tags
756        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
757        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
758        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
759
760        // Second sync: remote tags empty — local tags preserved (may be user-added)
761        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
762        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
763        assert_eq!(result.unchanged, 1);
764        assert_eq!(config.host_entries()[0].tags, vec!["production"]);
765    }
766
767    #[test]
768    fn test_sync_deduplicates_alias() {
769        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
770        let mut config = SshConfigFile {
771            elements: SshConfigFile::parse_content(content),
772            path: PathBuf::from("/tmp/test_config"),
773            crlf: false,
774        };
775        let section = make_section();
776
777        let remote = vec![ProviderHost::new("999".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
778
779        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
780
781        let entries = config.host_entries();
782        // Should have the original + a deduplicated one
783        assert_eq!(entries.len(), 2);
784        assert_eq!(entries[0].alias, "do-web-1");
785        assert_eq!(entries[1].alias, "do-web-1-2");
786    }
787
788    #[test]
789    fn test_sync_renames_on_prefix_change() {
790        let mut config = empty_config();
791        let section = make_section(); // prefix = "do"
792
793        // First sync: add host with "do" prefix
794        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
795        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
796        assert_eq!(config.host_entries()[0].alias, "do-web-1");
797
798        // Second sync: prefix changed to "ocean"
799        let new_section = ProviderSection {
800            alias_prefix: "ocean".to_string(),
801            ..section
802        };
803        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
804        assert_eq!(result.updated, 1);
805        assert_eq!(result.unchanged, 0);
806
807        let entries = config.host_entries();
808        assert_eq!(entries.len(), 1);
809        assert_eq!(entries[0].alias, "ocean-web-1");
810        assert_eq!(entries[0].hostname, "1.2.3.4");
811    }
812
813    #[test]
814    fn test_sync_rename_and_ip_change() {
815        let mut config = empty_config();
816        let section = make_section();
817
818        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
819        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
820
821        // Change both prefix and IP
822        let new_section = ProviderSection {
823            alias_prefix: "ocean".to_string(),
824            ..section
825        };
826        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.9.9.9".to_string(), Vec::new())];
827        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
828        assert_eq!(result.updated, 1);
829
830        let entries = config.host_entries();
831        assert_eq!(entries[0].alias, "ocean-web-1");
832        assert_eq!(entries[0].hostname, "9.9.9.9");
833    }
834
835    #[test]
836    fn test_sync_rename_dry_run_no_mutation() {
837        let mut config = empty_config();
838        let section = make_section();
839
840        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
841        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
842
843        let new_section = ProviderSection {
844            alias_prefix: "ocean".to_string(),
845            ..section
846        };
847        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
848        assert_eq!(result.updated, 1);
849
850        // Config should be unchanged (dry run)
851        assert_eq!(config.host_entries()[0].alias, "do-web-1");
852    }
853
854    #[test]
855    fn test_sync_no_rename_when_prefix_unchanged() {
856        let mut config = empty_config();
857        let section = make_section();
858
859        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
860        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
861
862        // Same prefix, same everything — should be unchanged
863        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
864        assert_eq!(result.unchanged, 1);
865        assert_eq!(result.updated, 0);
866        assert_eq!(config.host_entries()[0].alias, "do-web-1");
867    }
868
869    #[test]
870    fn test_sync_manual_comment_survives_cleanup() {
871        // A manual "# DigitalOcean" comment (without purple:group prefix)
872        // should NOT be removed when provider hosts are deleted
873        let content = "# DigitalOcean\nHost do-web\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
874        let mut config = SshConfigFile {
875            elements: SshConfigFile::parse_content(content),
876            path: PathBuf::from("/tmp/test_config"),
877            crlf: false,
878        };
879        let section = make_section();
880
881        // Remove all hosts (empty remote + remove_deleted)
882        sync_provider(&mut config, &MockProvider, &[], &section, true, false);
883
884        // The manual "# DigitalOcean" comment should survive (it doesn't have purple:group prefix)
885        let has_manual = config
886            .elements
887            .iter()
888            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
889        assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
890    }
891
892    #[test]
893    fn test_sync_rename_skips_included_host() {
894        let mut config = config_with_include_provider_host();
895
896        let new_section = ProviderSection {
897            provider: "digitalocean".to_string(),
898            token: "test".to_string(),
899            alias_prefix: "ocean".to_string(), // Different prefix
900            user: "root".to_string(),
901            identity_file: String::new(),
902            url: String::new(),
903            verify_tls: true,
904            auto_sync: true,
905            profile: String::new(),
906            regions: String::new(),
907            project: String::new(),
908        };
909
910        // Remote has the included host's server_id with a different prefix
911        let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "1.2.3.4".to_string(), Vec::new())];
912        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
913        assert_eq!(result.unchanged, 1);
914        assert_eq!(result.updated, 0);
915
916        // Alias should remain unchanged (included hosts are read-only)
917        assert_eq!(config.host_entries()[0].alias, "do-included");
918    }
919
920    #[test]
921    fn test_sync_rename_stable_with_manual_collision() {
922        let mut config = empty_config();
923        let section = make_section(); // prefix = "do"
924
925        // First sync: add provider host
926        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
927        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
928        assert_eq!(config.host_entries()[0].alias, "do-web-1");
929
930        // Manually add a host that will collide with the renamed alias
931        let manual = HostEntry {
932            alias: "ocean-web-1".to_string(),
933            hostname: "5.5.5.5".to_string(),
934            ..Default::default()
935        };
936        config.add_host(&manual);
937
938        // Second sync: prefix changes to "ocean", collides with manual host
939        let new_section = ProviderSection {
940            alias_prefix: "ocean".to_string(),
941            ..section.clone()
942        };
943        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
944        assert_eq!(result.updated, 1);
945
946        let entries = config.host_entries();
947        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
948        assert_eq!(provider_host.alias, "ocean-web-1-2");
949
950        // Third sync: same state. Should be stable (not flip to -3)
951        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
952        assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
953
954        let entries = config.host_entries();
955        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
956        assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
957    }
958
959    #[test]
960    fn test_sync_preserves_user_tags() {
961        let mut config = empty_config();
962        let section = make_section();
963
964        // First sync: add host with provider tag
965        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
966        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
967        assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
968
969        // User manually adds a tag via the TUI
970        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
971        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
972
973        // Second sync: same provider tags — user tag "prod" must survive
974        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
975        assert_eq!(result.unchanged, 1);
976        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
977    }
978
979    #[test]
980    fn test_sync_merges_new_provider_tag_with_user_tags() {
981        let mut config = empty_config();
982        let section = make_section();
983
984        // First sync: add host with provider tag
985        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
986        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
987
988        // User manually adds a tag
989        config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
990
991        // Second sync: provider adds a new tag — user tag must be preserved
992        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "v2".to_string()])];
993        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
994        assert_eq!(result.updated, 1);
995        let tags = &config.host_entries()[0].tags;
996        assert!(tags.contains(&"nyc1".to_string()));
997        assert!(tags.contains(&"critical".to_string()));
998        assert!(tags.contains(&"v2".to_string()));
999    }
1000
1001    #[test]
1002    fn test_sync_reset_tags_replaces_local_tags() {
1003        let mut config = empty_config();
1004        let section = make_section();
1005
1006        // First sync: add host with provider tag
1007        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
1008        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1009
1010        // User manually adds a tag
1011        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1012        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1013
1014        // Sync with reset_tags: user tag "prod" is removed
1015        let result = sync_provider_with_options(
1016            &mut config, &MockProvider, &remote, &section, false, false, true,
1017        );
1018        assert_eq!(result.updated, 1);
1019        assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1020    }
1021
1022    #[test]
1023    fn test_sync_reset_tags_clears_stale_tags() {
1024        let mut config = empty_config();
1025        let section = make_section();
1026
1027        // First sync: host with tags
1028        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1029        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1030
1031        // Second sync with reset_tags: provider removed all tags
1032        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
1033        let result = sync_provider_with_options(
1034            &mut config, &MockProvider, &remote, &section, false, false, true,
1035        );
1036        assert_eq!(result.updated, 1);
1037        assert!(config.host_entries()[0].tags.is_empty());
1038    }
1039
1040    #[test]
1041    fn test_sync_reset_tags_unchanged_when_matching() {
1042        let mut config = empty_config();
1043        let section = make_section();
1044
1045        // Sync: add host with tags
1046        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "nyc1".to_string()])];
1047        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1048
1049        // Reset-tags sync with same tags (different order): unchanged
1050        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "prod".to_string()])];
1051        let result = sync_provider_with_options(
1052            &mut config, &MockProvider, &remote, &section, false, false, true,
1053        );
1054        assert_eq!(result.unchanged, 1);
1055    }
1056
1057    #[test]
1058    fn test_sync_merge_case_insensitive() {
1059        let mut config = empty_config();
1060        let section = make_section();
1061
1062        // First sync: add host with lowercase tag
1063        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1064        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1065        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1066
1067        // Second sync: provider returns same tag with different casing — no duplicate
1068        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1069        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1070        assert_eq!(result.unchanged, 1);
1071        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1072    }
1073
1074    #[test]
1075    fn test_sync_reset_tags_case_insensitive_unchanged() {
1076        let mut config = empty_config();
1077        let section = make_section();
1078
1079        // Sync: add host with tag
1080        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1081        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1082
1083        // Reset-tags sync with different casing: unchanged (case-insensitive comparison)
1084        let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1085        let result = sync_provider_with_options(
1086            &mut config, &MockProvider, &remote, &section, false, false, true,
1087        );
1088        assert_eq!(result.unchanged, 1);
1089    }
1090
1091    // --- Empty IP (stopped/no-IP VM) tests ---
1092
1093    #[test]
1094    fn test_sync_empty_ip_not_added() {
1095        let mut config = empty_config();
1096        let section = make_section();
1097        let remote = vec![ProviderHost::new("100".to_string(), "stopped-vm".to_string(), String::new(), Vec::new())];
1098        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1099        assert_eq!(result.added, 0);
1100        assert_eq!(config.host_entries().len(), 0);
1101    }
1102
1103    #[test]
1104    fn test_sync_empty_ip_existing_host_unchanged() {
1105        let mut config = empty_config();
1106        let section = make_section();
1107
1108        // First sync: add host with IP
1109        let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1110        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1111        assert_eq!(config.host_entries().len(), 1);
1112        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1113
1114        // Second sync: VM stopped, empty IP. Host should stay unchanged.
1115        let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), String::new(), Vec::new())];
1116        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1117        assert_eq!(result.unchanged, 1);
1118        assert_eq!(result.updated, 0);
1119        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1120    }
1121
1122    #[test]
1123    fn test_sync_remove_skips_empty_ip_hosts() {
1124        let mut config = empty_config();
1125        let section = make_section();
1126
1127        // First sync: add two hosts
1128        let remote = vec![
1129            ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1130            ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1131        ];
1132        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1133        assert_eq!(config.host_entries().len(), 2);
1134
1135        // Second sync with --remove: web is running, db is stopped (empty IP).
1136        // db must NOT be removed.
1137        let remote = vec![
1138            ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1139            ProviderHost::new("200".to_string(), "db".to_string(), String::new(), Vec::new()),
1140        ];
1141        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1142        assert_eq!(result.removed, 0);
1143        assert_eq!(result.unchanged, 2);
1144        assert_eq!(config.host_entries().len(), 2);
1145    }
1146
1147    #[test]
1148    fn test_sync_remove_deletes_truly_gone_hosts() {
1149        let mut config = empty_config();
1150        let section = make_section();
1151
1152        // First sync: add two hosts
1153        let remote = vec![
1154            ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1155            ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1156        ];
1157        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1158        assert_eq!(config.host_entries().len(), 2);
1159
1160        // Second sync with --remove: only web exists. db is truly deleted.
1161        let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1162        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1163        assert_eq!(result.removed, 1);
1164        assert_eq!(config.host_entries().len(), 1);
1165        assert_eq!(config.host_entries()[0].alias, "do-web");
1166    }
1167
1168    #[test]
1169    fn test_sync_mixed_resolved_empty_and_missing() {
1170        let mut config = empty_config();
1171        let section = make_section();
1172
1173        // First sync: add three hosts
1174        let remote = vec![
1175            ProviderHost::new("1".to_string(), "running".to_string(), "1.1.1.1".to_string(), Vec::new()),
1176            ProviderHost::new("2".to_string(), "stopped".to_string(), "2.2.2.2".to_string(), Vec::new()),
1177            ProviderHost::new("3".to_string(), "deleted".to_string(), "3.3.3.3".to_string(), Vec::new()),
1178        ];
1179        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1180        assert_eq!(config.host_entries().len(), 3);
1181
1182        // Second sync with --remove:
1183        // - "running" has new IP (updated)
1184        // - "stopped" has empty IP (unchanged, not removed)
1185        // - "deleted" not in list (removed)
1186        let remote = vec![
1187            ProviderHost::new("1".to_string(), "running".to_string(), "9.9.9.9".to_string(), Vec::new()),
1188            ProviderHost::new("2".to_string(), "stopped".to_string(), String::new(), Vec::new()),
1189        ];
1190        let result = sync_provider(&mut config, &MockProvider, &remote, &section, true, false);
1191        assert_eq!(result.updated, 1);
1192        assert_eq!(result.unchanged, 1);
1193        assert_eq!(result.removed, 1);
1194
1195        let entries = config.host_entries();
1196        assert_eq!(entries.len(), 2);
1197        // Running host got new IP
1198        let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
1199        assert_eq!(running.hostname, "9.9.9.9");
1200        // Stopped host kept old IP
1201        let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
1202        assert_eq!(stopped.hostname, "2.2.2.2");
1203    }
1204
1205    // =========================================================================
1206    // sanitize_name edge cases
1207    // =========================================================================
1208
1209    #[test]
1210    fn test_sanitize_name_unicode() {
1211        // Unicode chars become hyphens, collapsed
1212        assert_eq!(sanitize_name("서버-1"), "1");
1213    }
1214
1215    #[test]
1216    fn test_sanitize_name_numbers_only() {
1217        assert_eq!(sanitize_name("12345"), "12345");
1218    }
1219
1220    #[test]
1221    fn test_sanitize_name_mixed_special_chars() {
1222        assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
1223    }
1224
1225    #[test]
1226    fn test_sanitize_name_tabs_and_newlines() {
1227        assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
1228    }
1229
1230    #[test]
1231    fn test_sanitize_name_consecutive_specials() {
1232        assert_eq!(sanitize_name("a!!!b"), "a-b");
1233    }
1234
1235    #[test]
1236    fn test_sanitize_name_trailing_special() {
1237        assert_eq!(sanitize_name("web-"), "web");
1238    }
1239
1240    #[test]
1241    fn test_sanitize_name_leading_special() {
1242        assert_eq!(sanitize_name("-web"), "web");
1243    }
1244
1245    // =========================================================================
1246    // build_alias edge cases
1247    // =========================================================================
1248
1249    #[test]
1250    fn test_build_alias_prefix_with_hyphen() {
1251        // If prefix already ends with hyphen, double hyphen results
1252        // The caller is expected to provide clean prefixes
1253        assert_eq!(build_alias("do-", "web-1"), "do--web-1");
1254    }
1255
1256    #[test]
1257    fn test_build_alias_long_names() {
1258        assert_eq!(build_alias("my-provider", "my-very-long-server-name"), "my-provider-my-very-long-server-name");
1259    }
1260
1261    // =========================================================================
1262    // sync with user and identity_file
1263    // =========================================================================
1264
1265    #[test]
1266    fn test_sync_applies_user_from_section() {
1267        let mut config = empty_config();
1268        let mut section = make_section();
1269        section.user = "admin".to_string();
1270        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1271        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1272        let entries = config.host_entries();
1273        assert_eq!(entries[0].user, "admin");
1274    }
1275
1276    #[test]
1277    fn test_sync_applies_identity_file_from_section() {
1278        let mut config = empty_config();
1279        let mut section = make_section();
1280        section.identity_file = "~/.ssh/id_rsa".to_string();
1281        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1282        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1283        let entries = config.host_entries();
1284        assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
1285    }
1286
1287    #[test]
1288    fn test_sync_empty_user_not_set() {
1289        let mut config = empty_config();
1290        let mut section = make_section();
1291        section.user = String::new(); // explicitly clear user
1292        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1293        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1294        let entries = config.host_entries();
1295        assert!(entries[0].user.is_empty());
1296    }
1297
1298    // =========================================================================
1299    // SyncResult struct
1300    // =========================================================================
1301
1302    #[test]
1303    fn test_sync_result_default() {
1304        let result = SyncResult::default();
1305        assert_eq!(result.added, 0);
1306        assert_eq!(result.updated, 0);
1307        assert_eq!(result.removed, 0);
1308        assert_eq!(result.unchanged, 0);
1309        assert!(result.renames.is_empty());
1310    }
1311
1312    // =========================================================================
1313    // sync with multiple operations in one call
1314    // =========================================================================
1315
1316    #[test]
1317    fn test_sync_server_name_change_updates_alias() {
1318        let mut config = empty_config();
1319        let section = make_section();
1320        // Add initial host
1321        let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1322        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1323        assert_eq!(config.host_entries()[0].alias, "do-old-name");
1324
1325        // Sync with new name (same server_id)
1326        let remote_renamed = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1327        let result = sync_provider(&mut config, &MockProvider, &remote_renamed, &section, false, false);
1328        // Should rename the alias
1329        assert!(!result.renames.is_empty() || result.updated > 0);
1330    }
1331
1332    #[test]
1333    fn test_sync_idempotent_same_data() {
1334        let mut config = empty_config();
1335        let section = make_section();
1336        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1337        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1338        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1339        assert_eq!(result.added, 0);
1340        assert_eq!(result.updated, 0);
1341        assert_eq!(result.unchanged, 1);
1342    }
1343
1344    // =========================================================================
1345    // Tag merge edge cases
1346    // =========================================================================
1347
1348    #[test]
1349    fn test_sync_tag_merge_case_insensitive_no_duplicate() {
1350        let mut config = empty_config();
1351        let section = make_section();
1352        // Add host with tag "Prod"
1353        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1354        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1355
1356        // Sync again with "prod" (lowercase) - should NOT add duplicate
1357        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1358        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1359        assert_eq!(result.unchanged, 1);
1360        assert_eq!(result.updated, 0);
1361    }
1362
1363    #[test]
1364    fn test_sync_tag_merge_adds_new_remote_tag() {
1365        let mut config = empty_config();
1366        let section = make_section();
1367        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1368        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1369
1370        // Sync with additional tag "us-east"
1371        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "us-east".to_string()])];
1372        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1373        assert_eq!(result.updated, 1);
1374
1375        // Verify both tags present
1376        let entries = config.host_entries();
1377        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1378        assert!(entry.tags.iter().any(|t| t == "prod"));
1379        assert!(entry.tags.iter().any(|t| t == "us-east"));
1380    }
1381
1382    #[test]
1383    fn test_sync_tag_merge_preserves_local_tags() {
1384        let mut config = empty_config();
1385        let section = make_section();
1386        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1387        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1388
1389        // Manually add a local tag
1390        config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1391
1392        // Sync again with only "prod" - local "my-custom" should survive
1393        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1394        assert_eq!(result.unchanged, 1);
1395        let entries = config.host_entries();
1396        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1397        assert!(entry.tags.iter().any(|t| t == "my-custom"));
1398    }
1399
1400    #[test]
1401    fn test_sync_reset_tags_replaces_local() {
1402        let mut config = empty_config();
1403        let section = make_section();
1404        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1405        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1406
1407        // Add local-only tag
1408        config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1409
1410        // Sync with reset_tags=true
1411        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "new-tag".to_string()])];
1412        let result = sync_provider_with_options(&mut config, &MockProvider, &remote2, &section, false, false, true);
1413        assert_eq!(result.updated, 1);
1414
1415        let entries = config.host_entries();
1416        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1417        assert!(entry.tags.iter().any(|t| t == "new-tag"));
1418        // "my-custom" should be gone with reset_tags
1419        assert!(!entry.tags.iter().any(|t| t == "my-custom"));
1420    }
1421
1422    // =========================================================================
1423    // Rename + tag change simultaneously
1424    // =========================================================================
1425
1426    #[test]
1427    fn test_sync_rename_and_ip_change_simultaneously() {
1428        let mut config = empty_config();
1429        let section = make_section();
1430        let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1431        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1432
1433        // Both name and IP change
1434        let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "9.8.7.6".to_string(), Vec::new())];
1435        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1436        assert_eq!(result.updated, 1);
1437        assert_eq!(result.renames.len(), 1);
1438        assert_eq!(result.renames[0].0, "do-old-name");
1439        assert_eq!(result.renames[0].1, "do-new-name");
1440
1441        let entries = config.host_entries();
1442        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1443        assert_eq!(entry.hostname, "9.8.7.6");
1444    }
1445
1446    // =========================================================================
1447    // Duplicate server_id in remote response
1448    // =========================================================================
1449
1450    #[test]
1451    fn test_sync_duplicate_server_id_deduped() {
1452        let mut config = empty_config();
1453        let section = make_section();
1454        let remote = vec![
1455            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1456            ProviderHost::new("1".to_string(), "web-copy".to_string(), "5.6.7.8".to_string(), Vec::new()), // duplicate server_id
1457        ];
1458        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1459        assert_eq!(result.added, 1); // Only first one added
1460        assert_eq!(config.host_entries().len(), 1);
1461    }
1462
1463    // =========================================================================
1464    // Empty remote list with remove_deleted
1465    // =========================================================================
1466
1467    #[test]
1468    fn test_sync_remove_all_when_remote_empty() {
1469        let mut config = empty_config();
1470        let section = make_section();
1471        let remote = vec![
1472            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1473            ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1474        ];
1475        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1476        assert_eq!(config.host_entries().len(), 2);
1477
1478        // Sync with empty remote list and remove_deleted
1479        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
1480        assert_eq!(result.removed, 2);
1481        assert_eq!(config.host_entries().len(), 0);
1482    }
1483
1484    // =========================================================================
1485    // Header management
1486    // =========================================================================
1487
1488    #[test]
1489    fn test_sync_adds_group_header_on_first_host() {
1490        let mut config = empty_config();
1491        let section = make_section();
1492        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1493        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1494
1495        // Check that a GlobalLine with group header exists
1496        let has_header = config.elements.iter().any(|e| {
1497            matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1498        });
1499        assert!(has_header);
1500    }
1501
1502    #[test]
1503    fn test_sync_removes_header_when_all_hosts_deleted() {
1504        let mut config = empty_config();
1505        let section = make_section();
1506        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1507        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1508
1509        // Remove all hosts
1510        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
1511        assert_eq!(result.removed, 1);
1512
1513        // Header should be cleaned up
1514        let has_header = config.elements.iter().any(|e| {
1515            matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1516        });
1517        assert!(!has_header);
1518    }
1519
1520    // =========================================================================
1521    // Identity file applied on new hosts
1522    // =========================================================================
1523
1524    #[test]
1525    fn test_sync_identity_file_set_on_new_host() {
1526        let mut config = empty_config();
1527        let mut section = make_section();
1528        section.identity_file = "~/.ssh/do_key".to_string();
1529        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1530        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1531        let entries = config.host_entries();
1532        assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
1533    }
1534
1535    // =========================================================================
1536    // Alias collision deduplication
1537    // =========================================================================
1538
1539    #[test]
1540    fn test_sync_alias_collision_dedup() {
1541        let mut config = empty_config();
1542        let section = make_section();
1543        // Two remote hosts with same sanitized name but different server_ids
1544        let remote = vec![
1545            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1546            ProviderHost::new("2".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new()), // same name, different server
1547        ];
1548        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1549        assert_eq!(result.added, 2);
1550
1551        let entries = config.host_entries();
1552        let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
1553        assert!(aliases.contains(&"do-web"));
1554        assert!(aliases.contains(&"do-web-2")); // Deduped with suffix
1555    }
1556
1557    // =========================================================================
1558    // Empty alias_prefix
1559    // =========================================================================
1560
1561    #[test]
1562    fn test_sync_empty_alias_prefix() {
1563        let mut config = empty_config();
1564        let mut section = make_section();
1565        section.alias_prefix = String::new();
1566        let remote = vec![ProviderHost::new("1".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
1567        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1568        let entries = config.host_entries();
1569        assert_eq!(entries[0].alias, "web-1"); // No prefix, just sanitized name
1570    }
1571
1572    // =========================================================================
1573    // Dry-run counts consistency
1574    // =========================================================================
1575
1576    #[test]
1577    fn test_sync_dry_run_add_count() {
1578        let mut config = empty_config();
1579        let section = make_section();
1580        let remote = vec![
1581            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1582            ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1583        ];
1584        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, true);
1585        assert_eq!(result.added, 2);
1586        // Config should be unchanged in dry-run
1587        assert_eq!(config.host_entries().len(), 0);
1588    }
1589
1590    #[test]
1591    fn test_sync_dry_run_remove_count_preserves_config() {
1592        let mut config = empty_config();
1593        let section = make_section();
1594        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1595        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1596        assert_eq!(config.host_entries().len(), 1);
1597
1598        // Dry-run remove
1599        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
1600        assert_eq!(result.removed, 1);
1601        // Config should still have the host
1602        assert_eq!(config.host_entries().len(), 1);
1603    }
1604
1605    // =========================================================================
1606    // Result struct
1607    // =========================================================================
1608
1609    #[test]
1610    fn test_sync_result_counts_add_up() {
1611        let mut config = empty_config();
1612        let section = make_section();
1613        // Add 3 hosts
1614        let remote = vec![
1615            ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1616            ProviderHost::new("2".to_string(), "b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1617            ProviderHost::new("3".to_string(), "c".to_string(), "3.3.3.3".to_string(), Vec::new()),
1618        ];
1619        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1620
1621        // Sync with: 1 unchanged, 1 ip changed, 1 removed (missing from remote)
1622        let remote2 = vec![
1623            ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()), // unchanged
1624            ProviderHost::new("2".to_string(), "b".to_string(), "9.9.9.9".to_string(), Vec::new()), // IP changed
1625            // server_id "3" missing -> removed
1626        ];
1627        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, true, false);
1628        assert_eq!(result.unchanged, 1);
1629        assert_eq!(result.updated, 1);
1630        assert_eq!(result.removed, 1);
1631        assert_eq!(result.added, 0);
1632    }
1633
1634    // =========================================================================
1635    // Multiple renames in single sync
1636    // =========================================================================
1637
1638    #[test]
1639    fn test_sync_multiple_renames() {
1640        let mut config = empty_config();
1641        let section = make_section();
1642        let remote = vec![
1643            ProviderHost::new("1".to_string(), "old-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1644            ProviderHost::new("2".to_string(), "old-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1645        ];
1646        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1647
1648        let remote2 = vec![
1649            ProviderHost::new("1".to_string(), "new-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1650            ProviderHost::new("2".to_string(), "new-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1651        ];
1652        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1653        assert_eq!(result.renames.len(), 2);
1654        assert_eq!(result.updated, 2);
1655    }
1656
1657    // =========================================================================
1658    // Tag whitespace trimming
1659    // =========================================================================
1660
1661    #[test]
1662    fn test_sync_tag_whitespace_trimmed_on_store() {
1663        let mut config = empty_config();
1664        let section = make_section();
1665        // Tags with whitespace get trimmed when written to config and parsed back
1666        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["  production  ".to_string(), " us-east ".to_string()])];
1667        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1668        let entries = config.host_entries();
1669        // Tags are trimmed during the write+parse roundtrip via set_host_tags
1670        assert_eq!(entries[0].tags, vec!["production", "us-east"]);
1671    }
1672
1673    #[test]
1674    fn test_sync_tag_trimmed_remote_triggers_merge() {
1675        let mut config = empty_config();
1676        let section = make_section();
1677        // First sync: clean tags
1678        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
1679        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1680
1681        // Second sync: same tag but trimmed comparison works correctly
1682        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["  production  ".to_string()])]; // whitespace trimmed before comparison
1683        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1684        // Trimmed "production" matches existing "production" case-insensitively
1685        assert_eq!(result.unchanged, 1);
1686    }
1687
1688    // =========================================================================
1689    // Cross-provider coexistence
1690    // =========================================================================
1691
1692    struct MockProvider2;
1693    impl Provider for MockProvider2 {
1694        fn name(&self) -> &str {
1695            "vultr"
1696        }
1697        fn short_label(&self) -> &str {
1698            "vultr"
1699        }
1700        fn fetch_hosts_cancellable(
1701            &self,
1702            _token: &str,
1703            _cancel: &std::sync::atomic::AtomicBool,
1704        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
1705            Ok(Vec::new())
1706        }
1707    }
1708
1709    #[test]
1710    fn test_sync_two_providers_independent() {
1711        let mut config = empty_config();
1712
1713        let do_section = make_section(); // prefix = "do"
1714        let vultr_section = ProviderSection {
1715            provider: "vultr".to_string(),
1716            token: "test".to_string(),
1717            alias_prefix: "vultr".to_string(),
1718            user: String::new(),
1719            identity_file: String::new(),
1720            url: String::new(),
1721            verify_tls: true,
1722            auto_sync: true,
1723            profile: String::new(),
1724            regions: String::new(),
1725            project: String::new(),
1726        };
1727
1728        // Sync DO hosts
1729        let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1730        sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
1731
1732        // Sync Vultr hosts
1733        let vultr_remote = vec![ProviderHost::new("abc".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new())];
1734        sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
1735
1736        let entries = config.host_entries();
1737        assert_eq!(entries.len(), 2);
1738        let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
1739        assert!(aliases.contains(&"do-web"));
1740        assert!(aliases.contains(&"vultr-web"));
1741    }
1742
1743    #[test]
1744    fn test_sync_remove_only_affects_own_provider() {
1745        let mut config = empty_config();
1746        let do_section = make_section();
1747        let vultr_section = ProviderSection {
1748            provider: "vultr".to_string(),
1749            token: "test".to_string(),
1750            alias_prefix: "vultr".to_string(),
1751            user: String::new(),
1752            identity_file: String::new(),
1753            url: String::new(),
1754            verify_tls: true,
1755            auto_sync: true,
1756            profile: String::new(),
1757            regions: String::new(),
1758            project: String::new(),
1759        };
1760
1761        // Add hosts from both providers
1762        let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1763        sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
1764
1765        let vultr_remote = vec![ProviderHost::new("abc".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new())];
1766        sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
1767        assert_eq!(config.host_entries().len(), 2);
1768
1769        // Remove all DO hosts - Vultr host should survive
1770        let result = sync_provider(&mut config, &MockProvider, &[], &do_section, true, false);
1771        assert_eq!(result.removed, 1);
1772        let entries = config.host_entries();
1773        assert_eq!(entries.len(), 1);
1774        assert_eq!(entries[0].alias, "vultr-db");
1775    }
1776
1777    // =========================================================================
1778    // Rename + tag change simultaneously
1779    // =========================================================================
1780
1781    #[test]
1782    fn test_sync_rename_and_tag_change_simultaneously() {
1783        let mut config = empty_config();
1784        let section = make_section();
1785        let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1786        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1787        assert_eq!(config.host_entries()[0].alias, "do-old-name");
1788        assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
1789
1790        // Change name and add new tag
1791        let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string(), "prod".to_string()])];
1792        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1793        assert_eq!(result.updated, 1);
1794        assert_eq!(result.renames.len(), 1);
1795
1796        let entries = config.host_entries();
1797        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1798        assert!(entry.tags.contains(&"staging".to_string()));
1799        assert!(entry.tags.contains(&"prod".to_string()));
1800    }
1801
1802    // =========================================================================
1803    // All-symbol server name fallback
1804    // =========================================================================
1805
1806    #[test]
1807    fn test_sync_all_symbol_name_uses_server_fallback() {
1808        let mut config = empty_config();
1809        let section = make_section();
1810        let remote = vec![ProviderHost::new("1".to_string(), "!!!".to_string(), "1.2.3.4".to_string(), Vec::new())];
1811        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1812        let entries = config.host_entries();
1813        assert_eq!(entries[0].alias, "do-server");
1814    }
1815
1816    #[test]
1817    fn test_sync_unicode_name_uses_ascii_fallback() {
1818        let mut config = empty_config();
1819        let section = make_section();
1820        let remote = vec![ProviderHost::new("1".to_string(), "서버".to_string(), "1.2.3.4".to_string(), Vec::new())];
1821        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1822        let entries = config.host_entries();
1823        // Korean chars stripped, fallback to "server"
1824        assert_eq!(entries[0].alias, "do-server");
1825    }
1826
1827    // =========================================================================
1828    // Dry-run update doesn't mutate
1829    // =========================================================================
1830
1831    #[test]
1832    fn test_sync_dry_run_update_preserves_config() {
1833        let mut config = empty_config();
1834        let section = make_section();
1835        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1836        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1837
1838        // Dry-run with IP change
1839        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
1840        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, true);
1841        assert_eq!(result.updated, 1);
1842        // Config should still have old IP
1843        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1844    }
1845
1846    // =========================================================================
1847    // No-op sync on empty config with empty remote
1848    // =========================================================================
1849
1850    #[test]
1851    fn test_sync_empty_remote_empty_config_noop() {
1852        let mut config = empty_config();
1853        let section = make_section();
1854        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
1855        assert_eq!(result.added, 0);
1856        assert_eq!(result.updated, 0);
1857        assert_eq!(result.removed, 0);
1858        assert_eq!(result.unchanged, 0);
1859        assert!(config.host_entries().is_empty());
1860    }
1861
1862    // =========================================================================
1863    // Large batch sync
1864    // =========================================================================
1865
1866    #[test]
1867    fn test_sync_large_batch() {
1868        let mut config = empty_config();
1869        let section = make_section();
1870        let remote: Vec<ProviderHost> = (0..100)
1871            .map(|i| ProviderHost::new(format!("{}", i), format!("server-{}", i), format!("10.0.0.{}", i % 256), vec!["batch".to_string()]))
1872            .collect();
1873        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1874        assert_eq!(result.added, 100);
1875        assert_eq!(config.host_entries().len(), 100);
1876
1877        // Re-sync unchanged
1878        let result2 = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1879        assert_eq!(result2.unchanged, 100);
1880        assert_eq!(result2.added, 0);
1881    }
1882
1883    // =========================================================================
1884    // Rename collision with self-exclusion
1885    // =========================================================================
1886
1887    #[test]
1888    fn test_sync_rename_self_exclusion_no_collision() {
1889        // When renaming and the expected alias is already taken by this host itself,
1890        // deduplicate_alias_excluding should handle it (no -2 suffix)
1891        let mut config = empty_config();
1892        let section = make_section();
1893        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1894        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1895        assert_eq!(config.host_entries()[0].alias, "do-web");
1896
1897        // Re-sync with same name but different IP -> update, no rename
1898        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
1899        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
1900        assert_eq!(result.updated, 1);
1901        assert!(result.renames.is_empty());
1902        assert_eq!(config.host_entries()[0].alias, "do-web"); // No suffix
1903    }
1904
1905    // =========================================================================
1906    // Reset tags with rename: tags applied to new alias
1907    // =========================================================================
1908
1909    #[test]
1910    fn test_sync_reset_tags_with_rename() {
1911        let mut config = empty_config();
1912        let section = make_section();
1913        let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1914        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1915        config.set_host_tags("do-old-name", &["staging".to_string(), "custom".to_string()]);
1916
1917        // Rename + reset_tags
1918        let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
1919        let result = sync_provider_with_options(
1920            &mut config, &MockProvider, &remote2, &section, false, false, true,
1921        );
1922        assert_eq!(result.updated, 1);
1923        assert_eq!(result.renames.len(), 1);
1924
1925        let entries = config.host_entries();
1926        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1927        assert_eq!(entry.tags, vec!["production"]);
1928        assert!(!entry.tags.contains(&"custom".to_string()));
1929    }
1930
1931    // =========================================================================
1932    // Empty IP in first sync never added
1933    // =========================================================================
1934
1935    #[test]
1936    fn test_sync_empty_ip_with_tags_not_added() {
1937        let mut config = empty_config();
1938        let section = make_section();
1939        let remote = vec![ProviderHost::new("1".to_string(), "stopped".to_string(), String::new(), vec!["prod".to_string()])];
1940        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1941        assert_eq!(result.added, 0);
1942        assert!(config.host_entries().is_empty());
1943    }
1944
1945    // =========================================================================
1946    // Existing host not in entries_map (orphaned provider marker)
1947    // =========================================================================
1948
1949    #[test]
1950    fn test_sync_orphaned_provider_marker_counts_unchanged() {
1951        // If a provider marker exists but the host block is somehow broken/missing
1952        // from host_entries(), the code path at line 217 counts it as unchanged.
1953        // This is hard to trigger naturally, but we can verify the behavior with
1954        // a host that has a provider marker but also exists in entries_map.
1955        let content = "\
1956Host do-web
1957  HostName 1.2.3.4
1958  # purple:provider digitalocean:123
1959";
1960        let mut config = SshConfigFile {
1961            elements: SshConfigFile::parse_content(content),
1962            path: PathBuf::from("/tmp/test_config"),
1963            crlf: false,
1964        };
1965        let section = make_section();
1966        let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1967        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1968        assert_eq!(result.unchanged, 1);
1969    }
1970
1971    // =========================================================================
1972    // Separator between hosts (no double blank lines)
1973    // =========================================================================
1974
1975    #[test]
1976    fn test_sync_no_double_blank_between_hosts() {
1977        let mut config = empty_config();
1978        let section = make_section();
1979        let remote = vec![
1980            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1981            ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1982        ];
1983        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
1984
1985        // Verify no consecutive blank GlobalLines
1986        let mut prev_blank = false;
1987        for elem in &config.elements {
1988            if let ConfigElement::GlobalLine(line) = elem {
1989                let is_blank = line.trim().is_empty();
1990                assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
1991                prev_blank = is_blank;
1992            } else {
1993                prev_blank = false;
1994            }
1995        }
1996    }
1997
1998    // =========================================================================
1999    // Remove without remove_deleted flag does nothing
2000    // =========================================================================
2001
2002    #[test]
2003    fn test_sync_without_remove_flag_keeps_deleted() {
2004        let mut config = empty_config();
2005        let section = make_section();
2006        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2007        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2008
2009        // Sync without remove_deleted - host 1 gone from remote
2010        let result = sync_provider(&mut config, &MockProvider, &[], &section, false, false);
2011        assert_eq!(result.removed, 0);
2012        assert_eq!(config.host_entries().len(), 1); // Still there
2013    }
2014
2015    // =========================================================================
2016    // Dry-run rename doesn't track renames
2017    // =========================================================================
2018
2019    #[test]
2020    fn test_sync_dry_run_rename_no_renames_tracked() {
2021        let mut config = empty_config();
2022        let section = make_section();
2023        let remote = vec![ProviderHost::new("1".to_string(), "old".to_string(), "1.2.3.4".to_string(), Vec::new())];
2024        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2025
2026        let new_section = ProviderSection {
2027            alias_prefix: "ocean".to_string(),
2028            ..section
2029        };
2030        let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
2031        assert_eq!(result.updated, 1);
2032        // Dry-run: renames vec stays empty since no actual mutation
2033        assert!(result.renames.is_empty());
2034    }
2035
2036    // =========================================================================
2037    // sanitize_name additional edge cases
2038    // =========================================================================
2039
2040    #[test]
2041    fn test_sanitize_name_whitespace_only() {
2042        assert_eq!(sanitize_name("   "), "server");
2043    }
2044
2045    #[test]
2046    fn test_sanitize_name_single_char() {
2047        assert_eq!(sanitize_name("a"), "a");
2048        assert_eq!(sanitize_name("Z"), "z");
2049        assert_eq!(sanitize_name("5"), "5");
2050    }
2051
2052    #[test]
2053    fn test_sanitize_name_single_special_char() {
2054        assert_eq!(sanitize_name("!"), "server");
2055        assert_eq!(sanitize_name("-"), "server");
2056        assert_eq!(sanitize_name("."), "server");
2057    }
2058
2059    #[test]
2060    fn test_sanitize_name_emoji() {
2061        assert_eq!(sanitize_name("server🚀"), "server");
2062        assert_eq!(sanitize_name("🔥hot🔥"), "hot");
2063    }
2064
2065    #[test]
2066    fn test_sanitize_name_long_mixed_separators() {
2067        assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
2068    }
2069
2070    #[test]
2071    fn test_sanitize_name_dots_and_underscores() {
2072        assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
2073    }
2074
2075    // =========================================================================
2076    // find_hosts_by_provider with includes
2077    // =========================================================================
2078
2079    #[test]
2080    fn test_find_hosts_by_provider_in_includes() {
2081        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2082
2083        let include_content = "Host do-included\n  HostName 1.2.3.4\n  # purple:provider digitalocean:inc1\n";
2084        let included_elements = SshConfigFile::parse_content(include_content);
2085
2086        let config = SshConfigFile {
2087            elements: vec![ConfigElement::Include(IncludeDirective {
2088                raw_line: "Include conf.d/*".to_string(),
2089                pattern: "conf.d/*".to_string(),
2090                resolved_files: vec![IncludedFile {
2091                    path: PathBuf::from("/tmp/included.conf"),
2092                    elements: included_elements,
2093                }],
2094            })],
2095            path: PathBuf::from("/tmp/test_config"),
2096            crlf: false,
2097        };
2098
2099        let hosts = config.find_hosts_by_provider("digitalocean");
2100        assert_eq!(hosts.len(), 1);
2101        assert_eq!(hosts[0].0, "do-included");
2102        assert_eq!(hosts[0].1, "inc1");
2103    }
2104
2105    #[test]
2106    fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
2107        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2108
2109        // Top-level host
2110        let top_content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:1\n";
2111        let top_elements = SshConfigFile::parse_content(top_content);
2112
2113        // Included host
2114        let inc_content = "Host do-db\n  HostName 5.6.7.8\n  # purple:provider digitalocean:2\n";
2115        let inc_elements = SshConfigFile::parse_content(inc_content);
2116
2117        let mut elements = top_elements;
2118        elements.push(ConfigElement::Include(IncludeDirective {
2119            raw_line: "Include conf.d/*".to_string(),
2120            pattern: "conf.d/*".to_string(),
2121            resolved_files: vec![IncludedFile {
2122                path: PathBuf::from("/tmp/included.conf"),
2123                elements: inc_elements,
2124            }],
2125        }));
2126
2127        let config = SshConfigFile {
2128            elements,
2129            path: PathBuf::from("/tmp/test_config"),
2130            crlf: false,
2131        };
2132
2133        let hosts = config.find_hosts_by_provider("digitalocean");
2134        assert_eq!(hosts.len(), 2);
2135    }
2136
2137    #[test]
2138    fn test_find_hosts_by_provider_empty_includes() {
2139        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2140
2141        let config = SshConfigFile {
2142            elements: vec![ConfigElement::Include(IncludeDirective {
2143                raw_line: "Include conf.d/*".to_string(),
2144                pattern: "conf.d/*".to_string(),
2145                resolved_files: vec![IncludedFile {
2146                    path: PathBuf::from("/tmp/empty.conf"),
2147                    elements: vec![],
2148                }],
2149            })],
2150            path: PathBuf::from("/tmp/test_config"),
2151            crlf: false,
2152        };
2153
2154        let hosts = config.find_hosts_by_provider("digitalocean");
2155        assert!(hosts.is_empty());
2156    }
2157
2158    #[test]
2159    fn test_find_hosts_by_provider_wrong_provider_name() {
2160        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:1\n";
2161        let config = SshConfigFile {
2162            elements: SshConfigFile::parse_content(content),
2163            path: PathBuf::from("/tmp/test_config"),
2164            crlf: false,
2165        };
2166
2167        let hosts = config.find_hosts_by_provider("vultr");
2168        assert!(hosts.is_empty());
2169    }
2170
2171    // =========================================================================
2172    // deduplicate_alias_excluding
2173    // =========================================================================
2174
2175    #[test]
2176    fn test_deduplicate_alias_excluding_self() {
2177        // When renaming do-web to do-web (same alias), exclude prevents collision
2178        let content = "Host do-web\n  HostName 1.2.3.4\n";
2179        let config = SshConfigFile {
2180            elements: SshConfigFile::parse_content(content),
2181            path: PathBuf::from("/tmp/test_config"),
2182            crlf: false,
2183        };
2184
2185        let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2186        assert_eq!(alias, "do-web"); // Self-excluded, no collision
2187    }
2188
2189    #[test]
2190    fn test_deduplicate_alias_excluding_other() {
2191        // do-web exists, exclude is "do-db" (not the colliding one)
2192        let content = "Host do-web\n  HostName 1.2.3.4\n";
2193        let config = SshConfigFile {
2194            elements: SshConfigFile::parse_content(content),
2195            path: PathBuf::from("/tmp/test_config"),
2196            crlf: false,
2197        };
2198
2199        let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
2200        assert_eq!(alias, "do-web-2"); // do-web is taken, do-db doesn't help
2201    }
2202
2203    #[test]
2204    fn test_deduplicate_alias_excluding_chain() {
2205        // do-web and do-web-2 exist, exclude is "do-web"
2206        let content = "Host do-web\n  HostName 1.1.1.1\n\nHost do-web-2\n  HostName 2.2.2.2\n";
2207        let config = SshConfigFile {
2208            elements: SshConfigFile::parse_content(content),
2209            path: PathBuf::from("/tmp/test_config"),
2210            crlf: false,
2211        };
2212
2213        let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2214        // do-web is excluded, so it's "available" → returns do-web
2215        assert_eq!(alias, "do-web");
2216    }
2217
2218    #[test]
2219    fn test_deduplicate_alias_excluding_none() {
2220        let content = "Host do-web\n  HostName 1.2.3.4\n";
2221        let config = SshConfigFile {
2222            elements: SshConfigFile::parse_content(content),
2223            path: PathBuf::from("/tmp/test_config"),
2224            crlf: false,
2225        };
2226
2227        // None exclude means normal deduplication
2228        let alias = config.deduplicate_alias_excluding("do-web", None);
2229        assert_eq!(alias, "do-web-2");
2230    }
2231
2232    // =========================================================================
2233    // set_host_tags with empty tags
2234    // =========================================================================
2235
2236    #[test]
2237    fn test_set_host_tags_empty_clears_tags() {
2238        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:tags prod,staging\n";
2239        let mut config = SshConfigFile {
2240            elements: SshConfigFile::parse_content(content),
2241            path: PathBuf::from("/tmp/test_config"),
2242            crlf: false,
2243        };
2244
2245        config.set_host_tags("do-web", &[]);
2246        let entries = config.host_entries();
2247        assert!(entries[0].tags.is_empty());
2248    }
2249
2250    #[test]
2251    fn test_set_host_provider_updates_existing() {
2252        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:old-id\n";
2253        let mut config = SshConfigFile {
2254            elements: SshConfigFile::parse_content(content),
2255            path: PathBuf::from("/tmp/test_config"),
2256            crlf: false,
2257        };
2258
2259        config.set_host_provider("do-web", "digitalocean", "new-id");
2260        let hosts = config.find_hosts_by_provider("digitalocean");
2261        assert_eq!(hosts.len(), 1);
2262        assert_eq!(hosts[0].1, "new-id");
2263    }
2264
2265    // =========================================================================
2266    // Sync with provider hosts in includes (read-only recognized)
2267    // =========================================================================
2268
2269    #[test]
2270    fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
2271        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2272
2273        let include_content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n";
2274        let included_elements = SshConfigFile::parse_content(include_content);
2275
2276        let mut config = SshConfigFile {
2277            elements: vec![ConfigElement::Include(IncludeDirective {
2278                raw_line: "Include conf.d/*".to_string(),
2279                pattern: "conf.d/*".to_string(),
2280                resolved_files: vec![IncludedFile {
2281                    path: PathBuf::from("/tmp/included.conf"),
2282                    elements: included_elements,
2283                }],
2284            })],
2285            path: PathBuf::from("/tmp/test_config"),
2286            crlf: false,
2287        };
2288
2289        let section = make_section();
2290        let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2291
2292        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2293        assert_eq!(result.unchanged, 1);
2294        assert_eq!(result.added, 0);
2295        // The host should NOT be duplicated in main config
2296        let top_hosts = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
2297        assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
2298    }
2299
2300    // =========================================================================
2301    // Dedup resolves back to the same alias -> counted as unchanged
2302    // =========================================================================
2303
2304    #[test]
2305    fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
2306        let mut config = empty_config();
2307        let section = make_section();
2308
2309        // Add a host with name "web" -> alias "do-web"
2310        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2311        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2312        assert_eq!(config.host_entries()[0].alias, "do-web");
2313
2314        // Manually add another host "do-new-web" that would collide after rename
2315        let other = vec![ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new())];
2316        sync_provider(&mut config, &MockProvider, &other, &section, false, false);
2317
2318        // Now rename the remote host "1" to "new-web", but alias "do-new-web" is taken by host "2".
2319        // dedup will produce "do-new-web-2". This is not the same as "do-web" so it IS a rename.
2320        // But let's create a scenario where dedup resolves back:
2321        // Change prefix so expected alias = "do-web" (same as existing)
2322        // This tests the else branch where alias_changed is initially true (prefix changed)
2323        // but dedup resolves to the same alias.
2324        // Actually, let's test it differently: rename where nothing else changes
2325        let remote_same = vec![
2326            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
2327            ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new()),
2328        ];
2329        let result = sync_provider(&mut config, &MockProvider, &remote_same, &section, false, false);
2330        assert_eq!(result.unchanged, 2);
2331        assert_eq!(result.updated, 0);
2332        assert!(result.renames.is_empty());
2333    }
2334
2335    // =========================================================================
2336    // Orphan server_id: existing_map has alias not found in entries_map
2337    // =========================================================================
2338
2339    #[test]
2340    fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
2341        // When two hosts have the same server name, the second gets a -2 suffix.
2342        // Test that deduplicate_alias handles this correctly.
2343        let mut config = empty_config();
2344        let section = make_section();
2345
2346        let remote = vec![
2347            ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
2348            ProviderHost::new("2".to_string(), "web".to_string(), "2.2.2.2".to_string(), Vec::new()),
2349        ];
2350        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2351        assert_eq!(result.added, 2);
2352
2353        let entries = config.host_entries();
2354        assert_eq!(entries[0].alias, "do-web");
2355        assert_eq!(entries[1].alias, "do-web-2");
2356
2357        // Re-sync: both should be unchanged
2358        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2359        assert_eq!(result.unchanged, 2);
2360    }
2361
2362    // =========================================================================
2363    // Dry-run remove with included hosts: included hosts NOT counted in remove
2364    // =========================================================================
2365
2366    #[test]
2367    fn test_sync_dry_run_remove_excludes_included_hosts() {
2368        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2369
2370        let include_content =
2371            "Host do-included\n  HostName 1.1.1.1\n  # purple:provider digitalocean:inc1\n";
2372        let included_elements = SshConfigFile::parse_content(include_content);
2373
2374        // Top-level host
2375        let mut config = SshConfigFile {
2376            elements: vec![ConfigElement::Include(IncludeDirective {
2377                raw_line: "Include conf.d/*".to_string(),
2378                pattern: "conf.d/*".to_string(),
2379                resolved_files: vec![IncludedFile {
2380                    path: PathBuf::from("/tmp/included.conf"),
2381                    elements: included_elements,
2382                }],
2383            })],
2384            path: PathBuf::from("/tmp/test_config"),
2385            crlf: false,
2386        };
2387
2388        // Add a non-included host
2389        let section = make_section();
2390        let remote = vec![ProviderHost::new("top1".to_string(), "toplevel".to_string(), "2.2.2.2".to_string(), Vec::new())];
2391        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2392
2393        // Dry-run with empty remote (both hosts would be "deleted")
2394        // Only the top-level host should be counted, NOT the included one
2395        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, true);
2396        assert_eq!(result.removed, 1, "Only top-level host counted in dry-run remove");
2397    }
2398
2399    // =========================================================================
2400    // Group header: config already has trailing blank (no extra added)
2401    // =========================================================================
2402
2403    #[test]
2404    fn test_sync_group_header_with_existing_trailing_blank() {
2405        let mut config = empty_config();
2406        // Add a pre-existing global line followed by a blank
2407        config.elements.push(ConfigElement::GlobalLine("# some comment".to_string()));
2408        config.elements.push(ConfigElement::GlobalLine(String::new()));
2409
2410        let section = make_section();
2411        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2412        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2413        assert_eq!(result.added, 1);
2414
2415        // Count blank lines: there should be exactly one blank line before the group header
2416        // (the pre-existing one), NOT two
2417        let blank_count = config
2418            .elements
2419            .iter()
2420            .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
2421            .count();
2422        assert_eq!(blank_count, 1, "No extra blank line when one already exists");
2423    }
2424
2425    // =========================================================================
2426    // Adding second host to existing provider: no group header added
2427    // =========================================================================
2428
2429    #[test]
2430    fn test_sync_no_group_header_for_second_host() {
2431        let mut config = empty_config();
2432        let section = make_section();
2433
2434        // First sync: one host, group header added
2435        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2436        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2437
2438        let header_count_before = config
2439            .elements
2440            .iter()
2441            .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
2442            .count();
2443        assert_eq!(header_count_before, 1);
2444
2445        // Second sync: add another host
2446        let remote2 = vec![
2447            ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
2448            ProviderHost::new("2".to_string(), "db".to_string(), "5.5.5.5".to_string(), Vec::new()),
2449        ];
2450        sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
2451
2452        // Still only one group header
2453        let header_count_after = config
2454            .elements
2455            .iter()
2456            .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
2457            .count();
2458        assert_eq!(header_count_after, 1, "No duplicate group header");
2459    }
2460
2461    // =========================================================================
2462    // Duplicate server_id in remote is skipped
2463    // =========================================================================
2464
2465    #[test]
2466    fn test_sync_duplicate_server_id_in_remote_skipped() {
2467        let mut config = empty_config();
2468        let section = make_section();
2469
2470        // Remote with duplicate server_id
2471        let remote = vec![
2472            ProviderHost::new("dup".to_string(), "first".to_string(), "1.1.1.1".to_string(), Vec::new()),
2473            ProviderHost::new("dup".to_string(), "second".to_string(), "2.2.2.2".to_string(), Vec::new()),
2474        ];
2475        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2476        assert_eq!(result.added, 1, "Only the first instance is added");
2477        assert_eq!(config.host_entries()[0].alias, "do-first");
2478    }
2479
2480    // =========================================================================
2481    // Empty IP existing host counted as unchanged (no removal)
2482    // =========================================================================
2483
2484    #[test]
2485    fn test_sync_empty_ip_existing_host_counted_unchanged() {
2486        let mut config = empty_config();
2487        let section = make_section();
2488
2489        // Add host
2490        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2491        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2492
2493        // Re-sync with empty IP (VM stopped)
2494        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), String::new(), Vec::new())];
2495        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, true);
2496        assert_eq!(result.unchanged, 1);
2497        assert_eq!(result.removed, 0, "Host with empty IP not removed");
2498        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
2499    }
2500
2501    // =========================================================================
2502    // Reset tags exact comparison (case-insensitive)
2503    // =========================================================================
2504
2505    #[test]
2506    fn test_sync_reset_tags_case_insensitive_no_update() {
2507        let mut config = empty_config();
2508        let section = make_section();
2509
2510        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Production".to_string()])];
2511        sync_provider_with_options(
2512            &mut config, &MockProvider, &remote, &section, false, false, true,
2513        );
2514
2515        // Same tag but different case -> unchanged with reset_tags
2516        let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
2517        let result = sync_provider_with_options(
2518            &mut config, &MockProvider, &remote2, &section, false, false, true,
2519        );
2520        assert_eq!(result.unchanged, 1, "Case-insensitive tag match = unchanged");
2521    }
2522
2523    // =========================================================================
2524    // Remove deletes group header when all hosts removed
2525    // =========================================================================
2526
2527    #[test]
2528    fn test_sync_remove_cleans_up_group_header() {
2529        let mut config = empty_config();
2530        let section = make_section();
2531
2532        let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2533        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2534
2535        // Verify group header exists
2536        let has_header = config.elements.iter().any(|e| {
2537            matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
2538        });
2539        assert!(has_header, "Group header present after add");
2540
2541        // Remove all hosts (empty remote + remove_deleted=true, dry_run=false)
2542        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false);
2543        assert_eq!(result.removed, 1);
2544
2545        // Group header should be cleaned up
2546        let has_header_after = config.elements.iter().any(|e| {
2547            matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
2548        });
2549        assert!(!has_header_after, "Group header removed when all hosts gone");
2550    }
2551
2552    // =========================================================================
2553    // Metadata sync tests
2554    // =========================================================================
2555
2556    #[test]
2557    fn test_sync_adds_host_with_metadata() {
2558        let mut config = empty_config();
2559        let section = make_section();
2560        let remote = vec![ProviderHost {
2561            server_id: "1".to_string(),
2562            name: "web".to_string(),
2563            ip: "1.2.3.4".to_string(),
2564            tags: Vec::new(),
2565            metadata: vec![
2566                ("region".to_string(), "nyc3".to_string()),
2567                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2568            ],
2569        }];
2570        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2571        assert_eq!(result.added, 1);
2572        let entries = config.host_entries();
2573        assert_eq!(entries[0].provider_meta.len(), 2);
2574        assert_eq!(entries[0].provider_meta[0], ("region".to_string(), "nyc3".to_string()));
2575        assert_eq!(entries[0].provider_meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2576    }
2577
2578    #[test]
2579    fn test_sync_updates_changed_metadata() {
2580        let mut config = empty_config();
2581        let section = make_section();
2582        let remote = vec![ProviderHost {
2583            server_id: "1".to_string(),
2584            name: "web".to_string(),
2585            ip: "1.2.3.4".to_string(),
2586            tags: Vec::new(),
2587            metadata: vec![("region".to_string(), "nyc3".to_string())],
2588        }];
2589        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2590
2591        // Update metadata (region changed, plan added)
2592        let remote2 = vec![ProviderHost {
2593            server_id: "1".to_string(),
2594            name: "web".to_string(),
2595            ip: "1.2.3.4".to_string(),
2596            tags: Vec::new(),
2597            metadata: vec![
2598                ("region".to_string(), "sfo3".to_string()),
2599                ("plan".to_string(), "s-2vcpu-2gb".to_string()),
2600            ],
2601        }];
2602        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
2603        assert_eq!(result.updated, 1);
2604        let entries = config.host_entries();
2605        assert_eq!(entries[0].provider_meta.len(), 2);
2606        assert_eq!(entries[0].provider_meta[0].1, "sfo3");
2607        assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
2608    }
2609
2610    #[test]
2611    fn test_sync_metadata_unchanged_no_update() {
2612        let mut config = empty_config();
2613        let section = make_section();
2614        let remote = vec![ProviderHost {
2615            server_id: "1".to_string(),
2616            name: "web".to_string(),
2617            ip: "1.2.3.4".to_string(),
2618            tags: Vec::new(),
2619            metadata: vec![("region".to_string(), "nyc3".to_string())],
2620        }];
2621        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2622
2623        // Same metadata again
2624        let result = sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2625        assert_eq!(result.unchanged, 1);
2626        assert_eq!(result.updated, 0);
2627    }
2628
2629    #[test]
2630    fn test_sync_metadata_order_insensitive() {
2631        let mut config = empty_config();
2632        let section = make_section();
2633        let remote = vec![ProviderHost {
2634            server_id: "1".to_string(),
2635            name: "web".to_string(),
2636            ip: "1.2.3.4".to_string(),
2637            tags: Vec::new(),
2638            metadata: vec![
2639                ("region".to_string(), "nyc3".to_string()),
2640                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2641            ],
2642        }];
2643        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2644
2645        // Same metadata, different order
2646        let remote2 = vec![ProviderHost {
2647            server_id: "1".to_string(),
2648            name: "web".to_string(),
2649            ip: "1.2.3.4".to_string(),
2650            tags: Vec::new(),
2651            metadata: vec![
2652                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2653                ("region".to_string(), "nyc3".to_string()),
2654            ],
2655        }];
2656        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
2657        assert_eq!(result.unchanged, 1);
2658        assert_eq!(result.updated, 0);
2659    }
2660
2661    #[test]
2662    fn test_sync_metadata_with_rename() {
2663        let mut config = empty_config();
2664        let section = make_section();
2665        let remote = vec![ProviderHost {
2666            server_id: "1".to_string(),
2667            name: "old-name".to_string(),
2668            ip: "1.2.3.4".to_string(),
2669            tags: Vec::new(),
2670            metadata: vec![("region".to_string(), "nyc3".to_string())],
2671        }];
2672        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2673        assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
2674
2675        // Rename + metadata change
2676        let remote2 = vec![ProviderHost {
2677            server_id: "1".to_string(),
2678            name: "new-name".to_string(),
2679            ip: "1.2.3.4".to_string(),
2680            tags: Vec::new(),
2681            metadata: vec![("region".to_string(), "sfo3".to_string())],
2682        }];
2683        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
2684        assert_eq!(result.updated, 1);
2685        assert!(!result.renames.is_empty());
2686        let entries = config.host_entries();
2687        assert_eq!(entries[0].alias, "do-new-name");
2688        assert_eq!(entries[0].provider_meta[0].1, "sfo3");
2689    }
2690
2691    #[test]
2692    fn test_sync_metadata_dry_run_no_mutation() {
2693        let mut config = empty_config();
2694        let section = make_section();
2695        let remote = vec![ProviderHost {
2696            server_id: "1".to_string(),
2697            name: "web".to_string(),
2698            ip: "1.2.3.4".to_string(),
2699            tags: Vec::new(),
2700            metadata: vec![("region".to_string(), "nyc3".to_string())],
2701        }];
2702        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2703
2704        // Dry-run with metadata change
2705        let remote2 = vec![ProviderHost {
2706            server_id: "1".to_string(),
2707            name: "web".to_string(),
2708            ip: "1.2.3.4".to_string(),
2709            tags: Vec::new(),
2710            metadata: vec![("region".to_string(), "sfo3".to_string())],
2711        }];
2712        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, true);
2713        assert_eq!(result.updated, 1);
2714        // Config should still have old metadata
2715        assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
2716    }
2717
2718    #[test]
2719    fn test_sync_metadata_only_change_triggers_update() {
2720        let mut config = empty_config();
2721        let section = make_section();
2722        let remote = vec![ProviderHost {
2723            server_id: "1".to_string(),
2724            name: "web".to_string(),
2725            ip: "1.2.3.4".to_string(),
2726            tags: vec!["prod".to_string()],
2727            metadata: vec![("region".to_string(), "nyc3".to_string())],
2728        }];
2729        sync_provider(&mut config, &MockProvider, &remote, &section, false, false);
2730
2731        // Only metadata changes (IP, tags, alias all the same)
2732        let remote2 = vec![ProviderHost {
2733            server_id: "1".to_string(),
2734            name: "web".to_string(),
2735            ip: "1.2.3.4".to_string(),
2736            tags: vec!["prod".to_string()],
2737            metadata: vec![
2738                ("region".to_string(), "nyc3".to_string()),
2739                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2740            ],
2741        }];
2742        let result = sync_provider(&mut config, &MockProvider, &remote2, &section, false, false);
2743        assert_eq!(result.updated, 1);
2744        assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
2745    }
2746}