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    /// Hosts marked stale (disappeared from provider but not hard-deleted).
16    pub stale: usize,
17    /// Alias renames: (old_alias, new_alias) pairs.
18    pub renames: Vec<(String, String)>,
19}
20
21/// Sanitize a server name into a valid SSH alias component.
22/// Lowercase, non-alphanumeric chars become hyphens, collapse consecutive hyphens.
23/// Falls back to "server" if the result would be empty (all-symbol/unicode names).
24fn sanitize_name(name: &str) -> String {
25    let mut result = String::new();
26    for c in name.chars() {
27        if c.is_ascii_alphanumeric() {
28            result.push(c.to_ascii_lowercase());
29        } else if !result.ends_with('-') {
30            result.push('-');
31        }
32    }
33    let trimmed = result.trim_matches('-').to_string();
34    if trimmed.is_empty() {
35        "server".to_string()
36    } else {
37        trimmed
38    }
39}
40
41/// Build an alias from prefix + sanitized name.
42/// If prefix is empty, uses just the sanitized name (no leading hyphen).
43fn build_alias(prefix: &str, sanitized: &str) -> String {
44    if prefix.is_empty() {
45        sanitized.to_string()
46    } else {
47        format!("{}-{}", prefix, sanitized)
48    }
49}
50
51/// Whether a metadata key is volatile (changes frequently without user action).
52/// Volatile keys are excluded from the sync diff comparison so that a status
53/// change alone does not trigger an SSH config rewrite. The value is still
54/// stored and displayed when the host is updated for other reasons.
55fn is_volatile_meta(key: &str) -> bool {
56    key == "status"
57}
58
59/// Sync hosts from a cloud provider into the SSH config.
60/// Provider tags are always stored in `# purple:provider_tags` and exactly
61/// mirror the remote state. User tags in `# purple:tags` are preserved.
62pub fn sync_provider(
63    config: &mut SshConfigFile,
64    provider: &dyn Provider,
65    remote_hosts: &[ProviderHost],
66    section: &ProviderSection,
67    remove_deleted: bool,
68    suppress_stale: bool,
69    dry_run: bool,
70) -> SyncResult {
71    let mut result = SyncResult::default();
72
73    // Build map of server_id -> alias (top-level only, no Include files).
74    // Keep first occurrence if duplicate provider markers exist (e.g. manual copy).
75    let existing = config.find_hosts_by_provider(provider.name());
76    let mut existing_map: HashMap<String, String> = HashMap::new();
77    for (alias, server_id) in &existing {
78        existing_map
79            .entry(server_id.clone())
80            .or_insert_with(|| alias.clone());
81    }
82
83    // Build alias -> HostEntry lookup once (avoids quadratic host_entries() calls)
84    let entries_map: HashMap<String, HostEntry> = config
85        .host_entries()
86        .into_iter()
87        .map(|e| (e.alias.clone(), e))
88        .collect();
89
90    // Track which server IDs are still in the remote set (also deduplicates)
91    let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
92
93    // Only add group header if this provider has no existing hosts in config
94    let mut needs_header = !dry_run && existing_map.is_empty();
95
96    for remote in remote_hosts {
97        if !remote_ids.insert(remote.server_id.clone()) {
98            continue; // Skip duplicate server_id in same response
99        }
100
101        // Empty IP means the resource exists but has no resolvable address
102        // (e.g. stopped VM, no static IP). Count it in remote_ids so --remove
103        // won't delete it, but skip add/update. Still clear stale if the host
104        // reappeared (it exists in the provider, just has no IP).
105        if remote.ip.is_empty() {
106            if let Some(alias) = existing_map.get(&remote.server_id) {
107                if let Some(entry) = entries_map.get(alias.as_str()) {
108                    if entry.stale.is_some() {
109                        if !dry_run {
110                            config.clear_host_stale(alias);
111                        }
112                        result.updated += 1;
113                        continue;
114                    }
115                }
116                result.unchanged += 1;
117            }
118            continue;
119        }
120
121        if let Some(existing_alias) = existing_map.get(&remote.server_id) {
122            // Host exists, check if alias, IP or tags changed
123            if let Some(entry) = entries_map.get(existing_alias) {
124                // Included hosts are read-only; recognize them for dedup but skip mutations
125                if entry.source_file.is_some() {
126                    result.unchanged += 1;
127                    continue;
128                }
129
130                // Host reappeared: clear stale marking
131                let was_stale = entry.stale.is_some();
132                if was_stale && !dry_run {
133                    config.clear_host_stale(existing_alias);
134                }
135
136                // Check if alias prefix changed (e.g. "do" → "ocean")
137                let sanitized = sanitize_name(&remote.name);
138                let expected_alias = build_alias(&section.alias_prefix, &sanitized);
139                let alias_changed = *existing_alias != expected_alias;
140
141                let ip_changed = entry.hostname != remote.ip;
142                let meta_changed = {
143                    let mut local: Vec<(&str, &str)> = entry
144                        .provider_meta
145                        .iter()
146                        .filter(|(k, _)| !is_volatile_meta(k))
147                        .map(|(k, v)| (k.as_str(), v.as_str()))
148                        .collect();
149                    local.sort();
150                    let mut remote_m: Vec<(&str, &str)> = remote
151                        .metadata
152                        .iter()
153                        .filter(|(k, _)| !is_volatile_meta(k))
154                        .map(|(k, v)| (k.as_str(), v.as_str()))
155                        .collect();
156                    remote_m.sort();
157                    local != remote_m
158                };
159                let trimmed_remote: Vec<String> =
160                    remote.tags.iter().map(|t| t.trim().to_string()).collect();
161                let tags_changed = {
162                    // Compare provider_tags with remote (case-insensitive, sorted)
163                    let mut sorted_local: Vec<String> = entry
164                        .provider_tags
165                        .iter()
166                        .map(|t| t.trim().to_lowercase())
167                        .collect();
168                    sorted_local.sort();
169                    let mut sorted_remote: Vec<String> =
170                        trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
171                    sorted_remote.sort();
172                    sorted_local != sorted_remote
173                };
174                // First migration: host has old-format tags (# purple:tags) but
175                // no # purple:provider_tags comment yet. Tags need splitting.
176                let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
177
178                // After first migration: check if user tags overlap with provider tags
179                let user_tags_overlap = !first_migration
180                    && !trimmed_remote.is_empty()
181                    && entry.tags.iter().any(|t| {
182                        trimmed_remote
183                            .iter()
184                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
185                    });
186
187                if alias_changed
188                    || ip_changed
189                    || tags_changed
190                    || meta_changed
191                    || user_tags_overlap
192                    || first_migration
193                    || was_stale
194                {
195                    if dry_run {
196                        result.updated += 1;
197                    } else {
198                        // Compute the final alias (dedup handles collisions,
199                        // excluding the host being renamed so it doesn't collide with itself)
200                        let new_alias = if alias_changed {
201                            config
202                                .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
203                        } else {
204                            existing_alias.clone()
205                        };
206                        // Re-evaluate: dedup may resolve back to the current alias
207                        let alias_changed = new_alias != *existing_alias;
208
209                        if alias_changed
210                            || ip_changed
211                            || tags_changed
212                            || meta_changed
213                            || user_tags_overlap
214                            || first_migration
215                            || was_stale
216                        {
217                            if alias_changed || ip_changed {
218                                let updated = HostEntry {
219                                    alias: new_alias.clone(),
220                                    hostname: remote.ip.clone(),
221                                    ..entry.clone()
222                                };
223                                config.update_host(existing_alias, &updated);
224                            }
225                            // Tags lookup uses the new alias after rename
226                            let tags_alias = if alias_changed {
227                                &new_alias
228                            } else {
229                                existing_alias
230                            };
231                            if tags_changed || first_migration {
232                                config.set_host_provider_tags(tags_alias, &trimmed_remote);
233                            }
234                            // Migration cleanup
235                            if first_migration {
236                                // First migration: old # purple:tags had both provider
237                                // and user tags mixed. Keep only tags NOT in remote
238                                // (those must be user-added). Provider tags move to
239                                // # purple:provider_tags.
240                                let user_only: Vec<String> = entry
241                                    .tags
242                                    .iter()
243                                    .filter(|t| {
244                                        !trimmed_remote
245                                            .iter()
246                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
247                                    })
248                                    .cloned()
249                                    .collect();
250                                config.set_host_tags(tags_alias, &user_only);
251                            } else if tags_changed || user_tags_overlap {
252                                // Ongoing: remove user tags that overlap with provider tags
253                                let cleaned: Vec<String> = entry
254                                    .tags
255                                    .iter()
256                                    .filter(|t| {
257                                        !trimmed_remote
258                                            .iter()
259                                            .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
260                                    })
261                                    .cloned()
262                                    .collect();
263                                if cleaned.len() != entry.tags.len() {
264                                    config.set_host_tags(tags_alias, &cleaned);
265                                }
266                            }
267                            // Update provider marker with new alias
268                            if alias_changed {
269                                config.set_host_provider(
270                                    &new_alias,
271                                    provider.name(),
272                                    &remote.server_id,
273                                );
274                                result
275                                    .renames
276                                    .push((existing_alias.clone(), new_alias.clone()));
277                            }
278                            // Update metadata
279                            if meta_changed {
280                                config.set_host_meta(tags_alias, &remote.metadata);
281                            }
282                            result.updated += 1;
283                        } else {
284                            result.unchanged += 1;
285                        }
286                    }
287                } else {
288                    result.unchanged += 1;
289                }
290            } else {
291                result.unchanged += 1;
292            }
293        } else {
294            // New host
295            let sanitized = sanitize_name(&remote.name);
296            let base_alias = build_alias(&section.alias_prefix, &sanitized);
297            let alias = if dry_run {
298                base_alias
299            } else {
300                config.deduplicate_alias(&base_alias)
301            };
302
303            if !dry_run {
304                // Add group header before the very first host for this provider
305                let wrote_header = needs_header;
306                if needs_header {
307                    if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
308                        config
309                            .elements
310                            .push(ConfigElement::GlobalLine(String::new()));
311                    }
312                    config.elements.push(ConfigElement::GlobalLine(format!(
313                        "# purple:group {}",
314                        super::provider_display_name(provider.name())
315                    )));
316                    needs_header = false;
317                }
318
319                let entry = HostEntry {
320                    alias: alias.clone(),
321                    hostname: remote.ip.clone(),
322                    user: section.user.clone(),
323                    identity_file: section.identity_file.clone(),
324                    provider: Some(provider.name().to_string()),
325                    ..Default::default()
326                };
327
328                let block = SshConfigFile::entry_to_block(&entry);
329
330                // Insert adjacent to existing provider hosts (keeps groups together).
331                // For the very first host (wrote_header), fall through to push at end.
332                let insert_pos = if !wrote_header {
333                    config.find_provider_insert_position(provider.name())
334                } else {
335                    None
336                };
337
338                if let Some(pos) = insert_pos {
339                    // Insert after last provider host with blank line separation.
340                    config.elements.insert(pos, ConfigElement::HostBlock(block));
341                    // Ensure blank line after the new block if the next element
342                    // is not already a blank (prevents hosts running into group
343                    // headers or other host blocks without visual separation).
344                    let after = pos + 1;
345                    let needs_trailing_blank = config.elements.get(after).is_some_and(
346                        |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
347                    );
348                    if needs_trailing_blank {
349                        config
350                            .elements
351                            .insert(after, ConfigElement::GlobalLine(String::new()));
352                    }
353                } else {
354                    // No existing group or first host: append at end with separator
355                    if !wrote_header
356                        && !config.elements.is_empty()
357                        && !config.last_element_has_trailing_blank()
358                    {
359                        config
360                            .elements
361                            .push(ConfigElement::GlobalLine(String::new()));
362                    }
363                    config.elements.push(ConfigElement::HostBlock(block));
364                }
365
366                config.set_host_provider(&alias, provider.name(), &remote.server_id);
367                if !remote.tags.is_empty() {
368                    config.set_host_provider_tags(&alias, &remote.tags);
369                }
370                if !remote.metadata.is_empty() {
371                    config.set_host_meta(&alias, &remote.metadata);
372                }
373            }
374
375            result.added += 1;
376        }
377    }
378
379    // Remove deleted hosts (skip included hosts which are read-only)
380    if remove_deleted && !dry_run {
381        let to_remove: Vec<String> = existing_map
382            .iter()
383            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
384            .filter(|(_, alias)| {
385                entries_map
386                    .get(alias.as_str())
387                    .is_none_or(|e| e.source_file.is_none())
388            })
389            .map(|(_, alias)| alias.clone())
390            .collect();
391        for alias in &to_remove {
392            config.delete_host(alias);
393        }
394        result.removed = to_remove.len();
395
396        // Clean up orphan provider header if all hosts for this provider were removed
397        if config.find_hosts_by_provider(provider.name()).is_empty() {
398            let header_text = format!(
399                "# purple:group {}",
400                super::provider_display_name(provider.name())
401            );
402            config
403                .elements
404                .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
405        }
406    } else if remove_deleted {
407        result.removed = existing_map
408            .iter()
409            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
410            .filter(|(_, alias)| {
411                entries_map
412                    .get(alias.as_str())
413                    .is_none_or(|e| e.source_file.is_none())
414            })
415            .count();
416    }
417
418    // Soft-delete: mark disappeared hosts as stale (when not hard-deleting)
419    if !remove_deleted && !suppress_stale {
420        let to_stale: Vec<String> = existing_map
421            .iter()
422            .filter(|(id, _)| !remote_ids.contains(id.as_str()))
423            .filter(|(_, alias)| {
424                entries_map
425                    .get(alias.as_str())
426                    .is_none_or(|e| e.source_file.is_none())
427            })
428            .map(|(_, alias)| alias.clone())
429            .collect();
430        if !dry_run {
431            let now = std::time::SystemTime::now()
432                .duration_since(std::time::UNIX_EPOCH)
433                .unwrap_or_default()
434                .as_secs();
435            for alias in &to_stale {
436                // Preserve original timestamp if already stale
437                if entries_map
438                    .get(alias.as_str())
439                    .is_none_or(|e| e.stale.is_none())
440                {
441                    config.set_host_stale(alias, now);
442                }
443            }
444        }
445        result.stale = to_stale.len();
446    }
447
448    result
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::path::PathBuf;
455
456    fn empty_config() -> SshConfigFile {
457        SshConfigFile {
458            elements: Vec::new(),
459            path: PathBuf::from("/tmp/test_config"),
460            crlf: false,
461            bom: false,
462        }
463    }
464
465    fn make_section() -> ProviderSection {
466        ProviderSection {
467            provider: "digitalocean".to_string(),
468            token: "test".to_string(),
469            alias_prefix: "do".to_string(),
470            user: "root".to_string(),
471            identity_file: String::new(),
472            url: String::new(),
473            verify_tls: true,
474            auto_sync: true,
475            profile: String::new(),
476            regions: String::new(),
477            project: String::new(),
478            compartment: String::new(),
479            vault_role: String::new(),
480            vault_addr: String::new(),
481        }
482    }
483
484    struct MockProvider;
485    impl Provider for MockProvider {
486        fn name(&self) -> &str {
487            "digitalocean"
488        }
489        fn short_label(&self) -> &str {
490            "do"
491        }
492        fn fetch_hosts_cancellable(
493            &self,
494            _token: &str,
495            _cancel: &std::sync::atomic::AtomicBool,
496        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
497            Ok(Vec::new())
498        }
499    }
500
501    #[test]
502    fn test_build_alias() {
503        assert_eq!(build_alias("do", "web-1"), "do-web-1");
504        assert_eq!(build_alias("", "web-1"), "web-1");
505        assert_eq!(build_alias("ocean", "db"), "ocean-db");
506    }
507
508    #[test]
509    fn test_sanitize_name() {
510        assert_eq!(sanitize_name("web-1"), "web-1");
511        assert_eq!(sanitize_name("My Server"), "my-server");
512        assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
513        assert_eq!(sanitize_name("--weird--"), "weird");
514        assert_eq!(sanitize_name("UPPER"), "upper");
515        assert_eq!(sanitize_name("a--b"), "a-b");
516        assert_eq!(sanitize_name(""), "server");
517        assert_eq!(sanitize_name("..."), "server");
518    }
519
520    #[test]
521    fn test_sync_adds_new_hosts() {
522        let mut config = empty_config();
523        let section = make_section();
524        let remote = vec![
525            ProviderHost::new(
526                "123".to_string(),
527                "web-1".to_string(),
528                "1.2.3.4".to_string(),
529                Vec::new(),
530            ),
531            ProviderHost::new(
532                "456".to_string(),
533                "db-1".to_string(),
534                "5.6.7.8".to_string(),
535                Vec::new(),
536            ),
537        ];
538
539        let result = sync_provider(
540            &mut config,
541            &MockProvider,
542            &remote,
543            &section,
544            false,
545            false,
546            false,
547        );
548        assert_eq!(result.added, 2);
549        assert_eq!(result.updated, 0);
550        assert_eq!(result.unchanged, 0);
551
552        let entries = config.host_entries();
553        assert_eq!(entries.len(), 2);
554        assert_eq!(entries[0].alias, "do-web-1");
555        assert_eq!(entries[0].hostname, "1.2.3.4");
556        assert_eq!(entries[1].alias, "do-db-1");
557    }
558
559    #[test]
560    fn test_sync_updates_changed_ip() {
561        let mut config = empty_config();
562        let section = make_section();
563
564        // First sync: add host
565        let remote = vec![ProviderHost::new(
566            "123".to_string(),
567            "web-1".to_string(),
568            "1.2.3.4".to_string(),
569            Vec::new(),
570        )];
571        sync_provider(
572            &mut config,
573            &MockProvider,
574            &remote,
575            &section,
576            false,
577            false,
578            false,
579        );
580
581        // Second sync: IP changed
582        let remote = vec![ProviderHost::new(
583            "123".to_string(),
584            "web-1".to_string(),
585            "9.8.7.6".to_string(),
586            Vec::new(),
587        )];
588        let result = sync_provider(
589            &mut config,
590            &MockProvider,
591            &remote,
592            &section,
593            false,
594            false,
595            false,
596        );
597        assert_eq!(result.updated, 1);
598        assert_eq!(result.added, 0);
599
600        let entries = config.host_entries();
601        assert_eq!(entries[0].hostname, "9.8.7.6");
602    }
603
604    #[test]
605    fn test_sync_unchanged() {
606        let mut config = empty_config();
607        let section = make_section();
608
609        let remote = vec![ProviderHost::new(
610            "123".to_string(),
611            "web-1".to_string(),
612            "1.2.3.4".to_string(),
613            Vec::new(),
614        )];
615        sync_provider(
616            &mut config,
617            &MockProvider,
618            &remote,
619            &section,
620            false,
621            false,
622            false,
623        );
624
625        // Same data again
626        let result = sync_provider(
627            &mut config,
628            &MockProvider,
629            &remote,
630            &section,
631            false,
632            false,
633            false,
634        );
635        assert_eq!(result.unchanged, 1);
636        assert_eq!(result.added, 0);
637        assert_eq!(result.updated, 0);
638    }
639
640    #[test]
641    fn test_sync_removes_deleted() {
642        let mut config = empty_config();
643        let section = make_section();
644
645        let remote = vec![ProviderHost::new(
646            "123".to_string(),
647            "web-1".to_string(),
648            "1.2.3.4".to_string(),
649            Vec::new(),
650        )];
651        sync_provider(
652            &mut config,
653            &MockProvider,
654            &remote,
655            &section,
656            false,
657            false,
658            false,
659        );
660        assert_eq!(config.host_entries().len(), 1);
661
662        // Sync with empty remote list + remove_deleted
663        let result = sync_provider(
664            &mut config,
665            &MockProvider,
666            &[],
667            &section,
668            true,
669            false,
670            false,
671        );
672        assert_eq!(result.removed, 1);
673        assert_eq!(config.host_entries().len(), 0);
674    }
675
676    #[test]
677    fn test_sync_dry_run_no_mutations() {
678        let mut config = empty_config();
679        let section = make_section();
680
681        let remote = vec![ProviderHost::new(
682            "123".to_string(),
683            "web-1".to_string(),
684            "1.2.3.4".to_string(),
685            Vec::new(),
686        )];
687
688        let result = sync_provider(
689            &mut config,
690            &MockProvider,
691            &remote,
692            &section,
693            false,
694            false,
695            true,
696        );
697        assert_eq!(result.added, 1);
698        assert_eq!(config.host_entries().len(), 0); // No actual changes
699    }
700
701    #[test]
702    fn test_sync_dedup_server_id_in_response() {
703        let mut config = empty_config();
704        let section = make_section();
705        let remote = vec![
706            ProviderHost::new(
707                "123".to_string(),
708                "web-1".to_string(),
709                "1.2.3.4".to_string(),
710                Vec::new(),
711            ),
712            ProviderHost::new(
713                "123".to_string(),
714                "web-1-dup".to_string(),
715                "5.6.7.8".to_string(),
716                Vec::new(),
717            ),
718        ];
719
720        let result = sync_provider(
721            &mut config,
722            &MockProvider,
723            &remote,
724            &section,
725            false,
726            false,
727            false,
728        );
729        assert_eq!(result.added, 1);
730        assert_eq!(config.host_entries().len(), 1);
731        assert_eq!(config.host_entries()[0].alias, "do-web-1");
732    }
733
734    #[test]
735    fn test_sync_duplicate_local_server_id_keeps_first() {
736        // If duplicate provider markers exist locally, sync should use the first alias
737        let content = "\
738Host do-web-1
739  HostName 1.2.3.4
740  # purple:provider digitalocean:123
741
742Host do-web-1-copy
743  HostName 1.2.3.4
744  # purple:provider digitalocean:123
745";
746        let mut config = SshConfigFile {
747            elements: SshConfigFile::parse_content(content),
748            path: PathBuf::from("/tmp/test_config"),
749            crlf: false,
750            bom: false,
751        };
752        let section = make_section();
753
754        // Remote has same server_id with updated IP
755        let remote = vec![ProviderHost::new(
756            "123".to_string(),
757            "web-1".to_string(),
758            "5.6.7.8".to_string(),
759            Vec::new(),
760        )];
761
762        let result = sync_provider(
763            &mut config,
764            &MockProvider,
765            &remote,
766            &section,
767            false,
768            false,
769            false,
770        );
771        // Should update the first alias (do-web-1), not the copy
772        assert_eq!(result.updated, 1);
773        assert_eq!(result.added, 0);
774        let entries = config.host_entries();
775        let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
776        assert_eq!(first.hostname, "5.6.7.8");
777        // Copy should remain unchanged
778        let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
779        assert_eq!(copy.hostname, "1.2.3.4");
780    }
781
782    #[test]
783    fn test_sync_no_duplicate_header_on_repeated_sync() {
784        let mut config = empty_config();
785        let section = make_section();
786
787        // First sync: adds header + host
788        let remote = vec![ProviderHost::new(
789            "123".to_string(),
790            "web-1".to_string(),
791            "1.2.3.4".to_string(),
792            Vec::new(),
793        )];
794        sync_provider(
795            &mut config,
796            &MockProvider,
797            &remote,
798            &section,
799            false,
800            false,
801            false,
802        );
803
804        // Second sync: new host added at provider
805        let remote = vec![
806            ProviderHost::new(
807                "123".to_string(),
808                "web-1".to_string(),
809                "1.2.3.4".to_string(),
810                Vec::new(),
811            ),
812            ProviderHost::new(
813                "456".to_string(),
814                "db-1".to_string(),
815                "5.6.7.8".to_string(),
816                Vec::new(),
817            ),
818        ];
819        sync_provider(
820            &mut config,
821            &MockProvider,
822            &remote,
823            &section,
824            false,
825            false,
826            false,
827        );
828
829        // Should have exactly one header
830        let header_count = config
831            .elements
832            .iter()
833            .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
834            .count();
835        assert_eq!(header_count, 1);
836        assert_eq!(config.host_entries().len(), 2);
837    }
838
839    #[test]
840    fn test_sync_removes_orphan_header() {
841        let mut config = empty_config();
842        let section = make_section();
843
844        // Add a host
845        let remote = vec![ProviderHost::new(
846            "123".to_string(),
847            "web-1".to_string(),
848            "1.2.3.4".to_string(),
849            Vec::new(),
850        )];
851        sync_provider(
852            &mut config,
853            &MockProvider,
854            &remote,
855            &section,
856            false,
857            false,
858            false,
859        );
860
861        // Verify header exists
862        let has_header = config
863            .elements
864            .iter()
865            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
866        assert!(has_header);
867
868        // Remove all hosts (empty remote + remove_deleted)
869        let result = sync_provider(
870            &mut config,
871            &MockProvider,
872            &[],
873            &section,
874            true,
875            false,
876            false,
877        );
878        assert_eq!(result.removed, 1);
879
880        // Header should be cleaned up
881        let has_header = config
882            .elements
883            .iter()
884            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
885        assert!(!has_header);
886    }
887
888    #[test]
889    fn test_sync_writes_provider_tags() {
890        let mut config = empty_config();
891        let section = make_section();
892        let remote = vec![ProviderHost::new(
893            "123".to_string(),
894            "web-1".to_string(),
895            "1.2.3.4".to_string(),
896            vec!["production".to_string(), "us-east".to_string()],
897        )];
898
899        sync_provider(
900            &mut config,
901            &MockProvider,
902            &remote,
903            &section,
904            false,
905            false,
906            false,
907        );
908
909        let entries = config.host_entries();
910        assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
911    }
912
913    #[test]
914    fn test_sync_updates_changed_tags() {
915        let mut config = empty_config();
916        let section = make_section();
917
918        // First sync: add with tags
919        let remote = vec![ProviderHost::new(
920            "123".to_string(),
921            "web-1".to_string(),
922            "1.2.3.4".to_string(),
923            vec!["staging".to_string()],
924        )];
925        sync_provider(
926            &mut config,
927            &MockProvider,
928            &remote,
929            &section,
930            false,
931            false,
932            false,
933        );
934        assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
935
936        // Second sync: provider tags replaced exactly
937        let remote = vec![ProviderHost::new(
938            "123".to_string(),
939            "web-1".to_string(),
940            "1.2.3.4".to_string(),
941            vec!["production".to_string(), "us-east".to_string()],
942        )];
943        let result = sync_provider(
944            &mut config,
945            &MockProvider,
946            &remote,
947            &section,
948            false,
949            false,
950            false,
951        );
952        assert_eq!(result.updated, 1);
953        assert_eq!(
954            config.host_entries()[0].provider_tags,
955            vec!["production", "us-east"]
956        );
957    }
958
959    #[test]
960    fn test_sync_combined_add_update_remove() {
961        let mut config = empty_config();
962        let section = make_section();
963
964        // First sync: add two hosts
965        let remote = vec![
966            ProviderHost::new(
967                "1".to_string(),
968                "web".to_string(),
969                "1.1.1.1".to_string(),
970                Vec::new(),
971            ),
972            ProviderHost::new(
973                "2".to_string(),
974                "db".to_string(),
975                "2.2.2.2".to_string(),
976                Vec::new(),
977            ),
978        ];
979        sync_provider(
980            &mut config,
981            &MockProvider,
982            &remote,
983            &section,
984            false,
985            false,
986            false,
987        );
988        assert_eq!(config.host_entries().len(), 2);
989
990        // Second sync: host 1 IP changed, host 2 removed, host 3 added
991        let remote = vec![
992            ProviderHost::new(
993                "1".to_string(),
994                "web".to_string(),
995                "9.9.9.9".to_string(),
996                Vec::new(),
997            ),
998            ProviderHost::new(
999                "3".to_string(),
1000                "cache".to_string(),
1001                "3.3.3.3".to_string(),
1002                Vec::new(),
1003            ),
1004        ];
1005        let result = sync_provider(
1006            &mut config,
1007            &MockProvider,
1008            &remote,
1009            &section,
1010            true,
1011            false,
1012            false,
1013        );
1014        assert_eq!(result.updated, 1);
1015        assert_eq!(result.added, 1);
1016        assert_eq!(result.removed, 1);
1017
1018        let entries = config.host_entries();
1019        assert_eq!(entries.len(), 2); // web (updated) + cache (added), db removed
1020        assert_eq!(entries[0].alias, "do-web");
1021        assert_eq!(entries[0].hostname, "9.9.9.9");
1022        assert_eq!(entries[1].alias, "do-cache");
1023    }
1024
1025    #[test]
1026    fn test_sync_tag_order_insensitive() {
1027        let mut config = empty_config();
1028        let section = make_section();
1029
1030        // First sync: tags in one order
1031        let remote = vec![ProviderHost::new(
1032            "123".to_string(),
1033            "web-1".to_string(),
1034            "1.2.3.4".to_string(),
1035            vec!["beta".to_string(), "alpha".to_string()],
1036        )];
1037        sync_provider(
1038            &mut config,
1039            &MockProvider,
1040            &remote,
1041            &section,
1042            false,
1043            false,
1044            false,
1045        );
1046
1047        // Second sync: same tags, different order
1048        let remote = vec![ProviderHost::new(
1049            "123".to_string(),
1050            "web-1".to_string(),
1051            "1.2.3.4".to_string(),
1052            vec!["alpha".to_string(), "beta".to_string()],
1053        )];
1054        let result = sync_provider(
1055            &mut config,
1056            &MockProvider,
1057            &remote,
1058            &section,
1059            false,
1060            false,
1061            false,
1062        );
1063        assert_eq!(result.unchanged, 1);
1064        assert_eq!(result.updated, 0);
1065    }
1066
1067    fn config_with_include_provider_host() -> SshConfigFile {
1068        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
1069
1070        // Build an included host block with provider marker
1071        let content = "Host do-included\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:inc1\n";
1072        let included_elements = SshConfigFile::parse_content(content);
1073
1074        SshConfigFile {
1075            elements: vec![ConfigElement::Include(IncludeDirective {
1076                raw_line: "Include conf.d/*".to_string(),
1077                pattern: "conf.d/*".to_string(),
1078                resolved_files: vec![IncludedFile {
1079                    path: PathBuf::from("/tmp/included.conf"),
1080                    elements: included_elements,
1081                }],
1082            })],
1083            path: PathBuf::from("/tmp/test_config"),
1084            crlf: false,
1085            bom: false,
1086        }
1087    }
1088
1089    #[test]
1090    fn test_sync_include_host_skips_update() {
1091        let mut config = config_with_include_provider_host();
1092        let section = make_section();
1093
1094        // Remote has same server with different IP — should NOT update included host
1095        let remote = vec![ProviderHost::new(
1096            "inc1".to_string(),
1097            "included".to_string(),
1098            "9.9.9.9".to_string(),
1099            Vec::new(),
1100        )];
1101        let result = sync_provider(
1102            &mut config,
1103            &MockProvider,
1104            &remote,
1105            &section,
1106            false,
1107            false,
1108            false,
1109        );
1110        assert_eq!(result.unchanged, 1);
1111        assert_eq!(result.updated, 0);
1112        assert_eq!(result.added, 0);
1113
1114        // Verify IP was NOT changed
1115        let entries = config.host_entries();
1116        let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
1117        assert_eq!(included.hostname, "1.2.3.4");
1118    }
1119
1120    #[test]
1121    fn test_sync_include_host_skips_remove() {
1122        let mut config = config_with_include_provider_host();
1123        let section = make_section();
1124
1125        // Empty remote + remove_deleted — should NOT remove included host
1126        let result = sync_provider(
1127            &mut config,
1128            &MockProvider,
1129            &[],
1130            &section,
1131            true,
1132            false,
1133            false,
1134        );
1135        assert_eq!(result.removed, 0);
1136        assert_eq!(config.host_entries().len(), 1);
1137    }
1138
1139    #[test]
1140    fn test_sync_dry_run_remove_count() {
1141        let mut config = empty_config();
1142        let section = make_section();
1143
1144        // Add two hosts
1145        let remote = vec![
1146            ProviderHost::new(
1147                "1".to_string(),
1148                "web".to_string(),
1149                "1.1.1.1".to_string(),
1150                Vec::new(),
1151            ),
1152            ProviderHost::new(
1153                "2".to_string(),
1154                "db".to_string(),
1155                "2.2.2.2".to_string(),
1156                Vec::new(),
1157            ),
1158        ];
1159        sync_provider(
1160            &mut config,
1161            &MockProvider,
1162            &remote,
1163            &section,
1164            false,
1165            false,
1166            false,
1167        );
1168        assert_eq!(config.host_entries().len(), 2);
1169
1170        // Dry-run remove with empty remote — should count but not mutate
1171        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false, true);
1172        assert_eq!(result.removed, 2);
1173        assert_eq!(config.host_entries().len(), 2); // Still there
1174    }
1175
1176    #[test]
1177    fn test_sync_tags_cleared_remotely_preserved_locally() {
1178        let mut config = empty_config();
1179        let section = make_section();
1180
1181        // First sync: host with tags
1182        let remote = vec![ProviderHost::new(
1183            "123".to_string(),
1184            "web-1".to_string(),
1185            "1.2.3.4".to_string(),
1186            vec!["production".to_string()],
1187        )];
1188        sync_provider(
1189            &mut config,
1190            &MockProvider,
1191            &remote,
1192            &section,
1193            false,
1194            false,
1195            false,
1196        );
1197        assert_eq!(config.host_entries()[0].provider_tags, vec!["production"]);
1198
1199        // Second sync: remote tags empty — provider_tags cleared
1200        let remote = vec![ProviderHost::new(
1201            "123".to_string(),
1202            "web-1".to_string(),
1203            "1.2.3.4".to_string(),
1204            Vec::new(),
1205        )];
1206        let result = sync_provider(
1207            &mut config,
1208            &MockProvider,
1209            &remote,
1210            &section,
1211            false,
1212            false,
1213            false,
1214        );
1215        assert_eq!(result.updated, 1);
1216        assert!(config.host_entries()[0].provider_tags.is_empty());
1217    }
1218
1219    #[test]
1220    fn test_sync_deduplicates_alias() {
1221        let content = "Host do-web-1\n  HostName 10.0.0.1\n";
1222        let mut config = SshConfigFile {
1223            elements: SshConfigFile::parse_content(content),
1224            path: PathBuf::from("/tmp/test_config"),
1225            crlf: false,
1226            bom: false,
1227        };
1228        let section = make_section();
1229
1230        let remote = vec![ProviderHost::new(
1231            "999".to_string(),
1232            "web-1".to_string(),
1233            "1.2.3.4".to_string(),
1234            Vec::new(),
1235        )];
1236
1237        sync_provider(
1238            &mut config,
1239            &MockProvider,
1240            &remote,
1241            &section,
1242            false,
1243            false,
1244            false,
1245        );
1246
1247        let entries = config.host_entries();
1248        // Should have the original + a deduplicated one
1249        assert_eq!(entries.len(), 2);
1250        assert_eq!(entries[0].alias, "do-web-1");
1251        assert_eq!(entries[1].alias, "do-web-1-2");
1252    }
1253
1254    #[test]
1255    fn test_sync_renames_on_prefix_change() {
1256        let mut config = empty_config();
1257        let section = make_section(); // prefix = "do"
1258
1259        // First sync: add host with "do" prefix
1260        let remote = vec![ProviderHost::new(
1261            "123".to_string(),
1262            "web-1".to_string(),
1263            "1.2.3.4".to_string(),
1264            Vec::new(),
1265        )];
1266        sync_provider(
1267            &mut config,
1268            &MockProvider,
1269            &remote,
1270            &section,
1271            false,
1272            false,
1273            false,
1274        );
1275        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1276
1277        // Second sync: prefix changed to "ocean"
1278        let new_section = ProviderSection {
1279            alias_prefix: "ocean".to_string(),
1280            ..section
1281        };
1282        let result = sync_provider(
1283            &mut config,
1284            &MockProvider,
1285            &remote,
1286            &new_section,
1287            false,
1288            false,
1289            false,
1290        );
1291        assert_eq!(result.updated, 1);
1292        assert_eq!(result.unchanged, 0);
1293
1294        let entries = config.host_entries();
1295        assert_eq!(entries.len(), 1);
1296        assert_eq!(entries[0].alias, "ocean-web-1");
1297        assert_eq!(entries[0].hostname, "1.2.3.4");
1298    }
1299
1300    #[test]
1301    fn test_sync_rename_and_ip_change() {
1302        let mut config = empty_config();
1303        let section = make_section();
1304
1305        let remote = vec![ProviderHost::new(
1306            "123".to_string(),
1307            "web-1".to_string(),
1308            "1.2.3.4".to_string(),
1309            Vec::new(),
1310        )];
1311        sync_provider(
1312            &mut config,
1313            &MockProvider,
1314            &remote,
1315            &section,
1316            false,
1317            false,
1318            false,
1319        );
1320
1321        // Change both prefix and IP
1322        let new_section = ProviderSection {
1323            alias_prefix: "ocean".to_string(),
1324            ..section
1325        };
1326        let remote = vec![ProviderHost::new(
1327            "123".to_string(),
1328            "web-1".to_string(),
1329            "9.9.9.9".to_string(),
1330            Vec::new(),
1331        )];
1332        let result = sync_provider(
1333            &mut config,
1334            &MockProvider,
1335            &remote,
1336            &new_section,
1337            false,
1338            false,
1339            false,
1340        );
1341        assert_eq!(result.updated, 1);
1342
1343        let entries = config.host_entries();
1344        assert_eq!(entries[0].alias, "ocean-web-1");
1345        assert_eq!(entries[0].hostname, "9.9.9.9");
1346    }
1347
1348    #[test]
1349    fn test_sync_rename_dry_run_no_mutation() {
1350        let mut config = empty_config();
1351        let section = make_section();
1352
1353        let remote = vec![ProviderHost::new(
1354            "123".to_string(),
1355            "web-1".to_string(),
1356            "1.2.3.4".to_string(),
1357            Vec::new(),
1358        )];
1359        sync_provider(
1360            &mut config,
1361            &MockProvider,
1362            &remote,
1363            &section,
1364            false,
1365            false,
1366            false,
1367        );
1368
1369        let new_section = ProviderSection {
1370            alias_prefix: "ocean".to_string(),
1371            ..section
1372        };
1373        let result = sync_provider(
1374            &mut config,
1375            &MockProvider,
1376            &remote,
1377            &new_section,
1378            false,
1379            false,
1380            true,
1381        );
1382        assert_eq!(result.updated, 1);
1383
1384        // Config should be unchanged (dry run)
1385        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1386    }
1387
1388    #[test]
1389    fn test_sync_no_rename_when_prefix_unchanged() {
1390        let mut config = empty_config();
1391        let section = make_section();
1392
1393        let remote = vec![ProviderHost::new(
1394            "123".to_string(),
1395            "web-1".to_string(),
1396            "1.2.3.4".to_string(),
1397            Vec::new(),
1398        )];
1399        sync_provider(
1400            &mut config,
1401            &MockProvider,
1402            &remote,
1403            &section,
1404            false,
1405            false,
1406            false,
1407        );
1408
1409        // Same prefix, same everything — should be unchanged
1410        let result = sync_provider(
1411            &mut config,
1412            &MockProvider,
1413            &remote,
1414            &section,
1415            false,
1416            false,
1417            false,
1418        );
1419        assert_eq!(result.unchanged, 1);
1420        assert_eq!(result.updated, 0);
1421        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1422    }
1423
1424    #[test]
1425    fn test_sync_manual_comment_survives_cleanup() {
1426        // A manual "# DigitalOcean" comment (without purple:group prefix)
1427        // should NOT be removed when provider hosts are deleted
1428        let content = "# DigitalOcean\nHost do-web\n  HostName 1.2.3.4\n  User root\n  # purple:provider digitalocean:123\n";
1429        let mut config = SshConfigFile {
1430            elements: SshConfigFile::parse_content(content),
1431            path: PathBuf::from("/tmp/test_config"),
1432            crlf: false,
1433            bom: false,
1434        };
1435        let section = make_section();
1436
1437        // Remove all hosts (empty remote + remove_deleted)
1438        sync_provider(
1439            &mut config,
1440            &MockProvider,
1441            &[],
1442            &section,
1443            true,
1444            false,
1445            false,
1446        );
1447
1448        // The manual "# DigitalOcean" comment should survive (it doesn't have purple:group prefix)
1449        let has_manual = config
1450            .elements
1451            .iter()
1452            .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
1453        assert!(
1454            has_manual,
1455            "Manual comment without purple:group prefix should survive cleanup"
1456        );
1457    }
1458
1459    #[test]
1460    fn test_sync_rename_skips_included_host() {
1461        let mut config = config_with_include_provider_host();
1462
1463        let new_section = ProviderSection {
1464            provider: "digitalocean".to_string(),
1465            token: "test".to_string(),
1466            alias_prefix: "ocean".to_string(), // Different prefix
1467            user: "root".to_string(),
1468            identity_file: String::new(),
1469            url: String::new(),
1470            verify_tls: true,
1471            auto_sync: true,
1472            profile: String::new(),
1473            regions: String::new(),
1474            project: String::new(),
1475            compartment: String::new(),
1476            vault_role: String::new(),
1477            vault_addr: String::new(),
1478        };
1479
1480        // Remote has the included host's server_id with a different prefix
1481        let remote = vec![ProviderHost::new(
1482            "inc1".to_string(),
1483            "included".to_string(),
1484            "1.2.3.4".to_string(),
1485            Vec::new(),
1486        )];
1487        let result = sync_provider(
1488            &mut config,
1489            &MockProvider,
1490            &remote,
1491            &new_section,
1492            false,
1493            false,
1494            false,
1495        );
1496        assert_eq!(result.unchanged, 1);
1497        assert_eq!(result.updated, 0);
1498
1499        // Alias should remain unchanged (included hosts are read-only)
1500        assert_eq!(config.host_entries()[0].alias, "do-included");
1501    }
1502
1503    #[test]
1504    fn test_sync_rename_stable_with_manual_collision() {
1505        let mut config = empty_config();
1506        let section = make_section(); // prefix = "do"
1507
1508        // First sync: add provider host
1509        let remote = vec![ProviderHost::new(
1510            "123".to_string(),
1511            "web-1".to_string(),
1512            "1.2.3.4".to_string(),
1513            Vec::new(),
1514        )];
1515        sync_provider(
1516            &mut config,
1517            &MockProvider,
1518            &remote,
1519            &section,
1520            false,
1521            false,
1522            false,
1523        );
1524        assert_eq!(config.host_entries()[0].alias, "do-web-1");
1525
1526        // Manually add a host that will collide with the renamed alias
1527        let manual = HostEntry {
1528            alias: "ocean-web-1".to_string(),
1529            hostname: "5.5.5.5".to_string(),
1530            ..Default::default()
1531        };
1532        config.add_host(&manual);
1533
1534        // Second sync: prefix changes to "ocean", collides with manual host
1535        let new_section = ProviderSection {
1536            alias_prefix: "ocean".to_string(),
1537            ..section.clone()
1538        };
1539        let result = sync_provider(
1540            &mut config,
1541            &MockProvider,
1542            &remote,
1543            &new_section,
1544            false,
1545            false,
1546            false,
1547        );
1548        assert_eq!(result.updated, 1);
1549
1550        let entries = config.host_entries();
1551        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1552        assert_eq!(provider_host.alias, "ocean-web-1-2");
1553
1554        // Third sync: same state. Should be stable (not flip to -3)
1555        let result = sync_provider(
1556            &mut config,
1557            &MockProvider,
1558            &remote,
1559            &new_section,
1560            false,
1561            false,
1562            false,
1563        );
1564        assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1565
1566        let entries = config.host_entries();
1567        let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1568        assert_eq!(
1569            provider_host.alias, "ocean-web-1-2",
1570            "Alias should be stable across syncs"
1571        );
1572    }
1573
1574    #[test]
1575    fn test_sync_preserves_user_tags() {
1576        let mut config = empty_config();
1577        let section = make_section();
1578
1579        // First sync: add host with provider tag
1580        let remote = vec![ProviderHost::new(
1581            "123".to_string(),
1582            "web-1".to_string(),
1583            "1.2.3.4".to_string(),
1584            vec!["nyc1".to_string()],
1585        )];
1586        sync_provider(
1587            &mut config,
1588            &MockProvider,
1589            &remote,
1590            &section,
1591            false,
1592            false,
1593            false,
1594        );
1595        assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1596
1597        // User manually adds a tag via the TUI (including duplicate "nyc1")
1598        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1599        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1600
1601        // Second sync: provider tags unchanged but overlap detected, "nyc1" migrated out
1602        let result = sync_provider(
1603            &mut config,
1604            &MockProvider,
1605            &remote,
1606            &section,
1607            false,
1608            false,
1609            false,
1610        );
1611        assert_eq!(result.updated, 1);
1612        assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1613        // "nyc1" removed from user tags (overlap with provider), "prod" preserved
1614        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1615    }
1616
1617    #[test]
1618    fn test_sync_merges_new_provider_tag_with_user_tags() {
1619        let mut config = empty_config();
1620        let section = make_section();
1621
1622        // First sync: add host with provider tag
1623        let remote = vec![ProviderHost::new(
1624            "123".to_string(),
1625            "web-1".to_string(),
1626            "1.2.3.4".to_string(),
1627            vec!["nyc1".to_string()],
1628        )];
1629        sync_provider(
1630            &mut config,
1631            &MockProvider,
1632            &remote,
1633            &section,
1634            false,
1635            false,
1636            false,
1637        );
1638
1639        // User manually adds a tag
1640        config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
1641
1642        // Second sync: provider adds a new tag — user tag must be preserved
1643        let remote = vec![ProviderHost::new(
1644            "123".to_string(),
1645            "web-1".to_string(),
1646            "1.2.3.4".to_string(),
1647            vec!["nyc1".to_string(), "v2".to_string()],
1648        )];
1649        let result = sync_provider(
1650            &mut config,
1651            &MockProvider,
1652            &remote,
1653            &section,
1654            false,
1655            false,
1656            false,
1657        );
1658        assert_eq!(result.updated, 1);
1659        // Provider tags exactly mirror remote
1660        let ptags = &config.host_entries()[0].provider_tags;
1661        assert!(ptags.contains(&"nyc1".to_string()));
1662        assert!(ptags.contains(&"v2".to_string()));
1663        // User tag "critical" survives, "nyc1" migrated out of user tags
1664        let tags = &config.host_entries()[0].tags;
1665        assert!(tags.contains(&"critical".to_string()));
1666        assert!(!tags.contains(&"nyc1".to_string()));
1667    }
1668
1669    #[test]
1670    fn test_sync_migration_cleans_overlapping_user_tags() {
1671        let mut config = empty_config();
1672        let section = make_section();
1673
1674        // First sync: add host with provider tag
1675        let remote = vec![ProviderHost::new(
1676            "123".to_string(),
1677            "web-1".to_string(),
1678            "1.2.3.4".to_string(),
1679            vec!["nyc1".to_string()],
1680        )];
1681        sync_provider(
1682            &mut config,
1683            &MockProvider,
1684            &remote,
1685            &section,
1686            false,
1687            false,
1688            false,
1689        );
1690
1691        // User manually adds a tag
1692        config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1693        assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1694
1695        // Provider_tags match remote but user tags overlap — migration cleanup runs
1696        let result = sync_provider(
1697            &mut config,
1698            &MockProvider,
1699            &remote,
1700            &section,
1701            false,
1702            false,
1703            false,
1704        );
1705        assert_eq!(result.updated, 1);
1706        assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1707        // "nyc1" removed from user tags (overlap), "prod" preserved
1708        assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1709    }
1710
1711    #[test]
1712    fn test_sync_provider_tags_cleared_remotely() {
1713        let mut config = empty_config();
1714        let section = make_section();
1715
1716        // First sync: host with tags
1717        let remote = vec![ProviderHost::new(
1718            "123".to_string(),
1719            "web-1".to_string(),
1720            "1.2.3.4".to_string(),
1721            vec!["staging".to_string()],
1722        )];
1723        sync_provider(
1724            &mut config,
1725            &MockProvider,
1726            &remote,
1727            &section,
1728            false,
1729            false,
1730            false,
1731        );
1732
1733        // Second sync: provider removed all tags
1734        let remote = vec![ProviderHost::new(
1735            "123".to_string(),
1736            "web-1".to_string(),
1737            "1.2.3.4".to_string(),
1738            Vec::new(),
1739        )];
1740        let result = sync_provider(
1741            &mut config,
1742            &MockProvider,
1743            &remote,
1744            &section,
1745            false,
1746            false,
1747            false,
1748        );
1749        assert_eq!(result.updated, 1);
1750        assert!(config.host_entries()[0].tags.is_empty());
1751    }
1752
1753    #[test]
1754    fn test_sync_provider_tags_cleared_user_tags_survive() {
1755        let mut config = empty_config();
1756        let section = make_section();
1757
1758        // First sync: host with provider tag
1759        let remote = vec![ProviderHost::new(
1760            "123".to_string(),
1761            "web-1".to_string(),
1762            "1.2.3.4".to_string(),
1763            vec!["staging".to_string()],
1764        )];
1765        sync_provider(
1766            &mut config,
1767            &MockProvider,
1768            &remote,
1769            &section,
1770            false,
1771            false,
1772            false,
1773        );
1774
1775        // User adds their own tag
1776        config.set_host_tags("do-web-1", &["my-custom".to_string()]);
1777
1778        // Provider removes all tags
1779        let remote = vec![ProviderHost::new(
1780            "123".to_string(),
1781            "web-1".to_string(),
1782            "1.2.3.4".to_string(),
1783            Vec::new(),
1784        )];
1785        let result = sync_provider(
1786            &mut config,
1787            &MockProvider,
1788            &remote,
1789            &section,
1790            false,
1791            false,
1792            false,
1793        );
1794        assert_eq!(result.updated, 1);
1795        assert!(config.host_entries()[0].provider_tags.is_empty());
1796        // User tags survive even when provider tags are cleared
1797        assert_eq!(config.host_entries()[0].tags, vec!["my-custom"]);
1798    }
1799
1800    #[test]
1801    fn test_sync_provider_tags_exact_match_unchanged() {
1802        let mut config = empty_config();
1803        let section = make_section();
1804
1805        // Sync: add host with tags
1806        let remote = vec![ProviderHost::new(
1807            "123".to_string(),
1808            "web-1".to_string(),
1809            "1.2.3.4".to_string(),
1810            vec!["prod".to_string(), "nyc1".to_string()],
1811        )];
1812        sync_provider(
1813            &mut config,
1814            &MockProvider,
1815            &remote,
1816            &section,
1817            false,
1818            false,
1819            false,
1820        );
1821
1822        // Reset-tags sync with same tags (different order): unchanged
1823        let remote = vec![ProviderHost::new(
1824            "123".to_string(),
1825            "web-1".to_string(),
1826            "1.2.3.4".to_string(),
1827            vec!["nyc1".to_string(), "prod".to_string()],
1828        )];
1829        let result = sync_provider(
1830            &mut config,
1831            &MockProvider,
1832            &remote,
1833            &section,
1834            false,
1835            false,
1836            false,
1837        );
1838        assert_eq!(result.unchanged, 1);
1839    }
1840
1841    #[test]
1842    fn test_sync_merge_case_insensitive() {
1843        let mut config = empty_config();
1844        let section = make_section();
1845
1846        // First sync: add host with lowercase tag
1847        let remote = vec![ProviderHost::new(
1848            "123".to_string(),
1849            "web-1".to_string(),
1850            "1.2.3.4".to_string(),
1851            vec!["prod".to_string()],
1852        )];
1853        sync_provider(
1854            &mut config,
1855            &MockProvider,
1856            &remote,
1857            &section,
1858            false,
1859            false,
1860            false,
1861        );
1862        assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1863
1864        // Second sync: provider returns same tag with different casing — no duplicate
1865        let remote = vec![ProviderHost::new(
1866            "123".to_string(),
1867            "web-1".to_string(),
1868            "1.2.3.4".to_string(),
1869            vec!["Prod".to_string()],
1870        )];
1871        let result = sync_provider(
1872            &mut config,
1873            &MockProvider,
1874            &remote,
1875            &section,
1876            false,
1877            false,
1878            false,
1879        );
1880        assert_eq!(result.unchanged, 1);
1881        assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1882    }
1883
1884    #[test]
1885    fn test_sync_provider_tags_case_insensitive_unchanged() {
1886        let mut config = empty_config();
1887        let section = make_section();
1888
1889        // Sync: add host with tag
1890        let remote = vec![ProviderHost::new(
1891            "123".to_string(),
1892            "web-1".to_string(),
1893            "1.2.3.4".to_string(),
1894            vec!["prod".to_string()],
1895        )];
1896        sync_provider(
1897            &mut config,
1898            &MockProvider,
1899            &remote,
1900            &section,
1901            false,
1902            false,
1903            false,
1904        );
1905
1906        // Reset-tags sync with different casing: unchanged (case-insensitive comparison)
1907        let remote = vec![ProviderHost::new(
1908            "123".to_string(),
1909            "web-1".to_string(),
1910            "1.2.3.4".to_string(),
1911            vec!["Prod".to_string()],
1912        )];
1913        let result = sync_provider(
1914            &mut config,
1915            &MockProvider,
1916            &remote,
1917            &section,
1918            false,
1919            false,
1920            false,
1921        );
1922        assert_eq!(result.unchanged, 1);
1923    }
1924
1925    // --- Empty IP (stopped/no-IP VM) tests ---
1926
1927    #[test]
1928    fn test_sync_empty_ip_not_added() {
1929        let mut config = empty_config();
1930        let section = make_section();
1931        let remote = vec![ProviderHost::new(
1932            "100".to_string(),
1933            "stopped-vm".to_string(),
1934            String::new(),
1935            Vec::new(),
1936        )];
1937        let result = sync_provider(
1938            &mut config,
1939            &MockProvider,
1940            &remote,
1941            &section,
1942            false,
1943            false,
1944            false,
1945        );
1946        assert_eq!(result.added, 0);
1947        assert_eq!(config.host_entries().len(), 0);
1948    }
1949
1950    #[test]
1951    fn test_sync_empty_ip_existing_host_unchanged() {
1952        let mut config = empty_config();
1953        let section = make_section();
1954
1955        // First sync: add host with IP
1956        let remote = vec![ProviderHost::new(
1957            "100".to_string(),
1958            "web".to_string(),
1959            "1.2.3.4".to_string(),
1960            Vec::new(),
1961        )];
1962        sync_provider(
1963            &mut config,
1964            &MockProvider,
1965            &remote,
1966            &section,
1967            false,
1968            false,
1969            false,
1970        );
1971        assert_eq!(config.host_entries().len(), 1);
1972        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1973
1974        // Second sync: VM stopped, empty IP. Host should stay unchanged.
1975        let remote = vec![ProviderHost::new(
1976            "100".to_string(),
1977            "web".to_string(),
1978            String::new(),
1979            Vec::new(),
1980        )];
1981        let result = sync_provider(
1982            &mut config,
1983            &MockProvider,
1984            &remote,
1985            &section,
1986            false,
1987            false,
1988            false,
1989        );
1990        assert_eq!(result.unchanged, 1);
1991        assert_eq!(result.updated, 0);
1992        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1993    }
1994
1995    #[test]
1996    fn test_sync_remove_skips_empty_ip_hosts() {
1997        let mut config = empty_config();
1998        let section = make_section();
1999
2000        // First sync: add two hosts
2001        let remote = vec![
2002            ProviderHost::new(
2003                "100".to_string(),
2004                "web".to_string(),
2005                "1.2.3.4".to_string(),
2006                Vec::new(),
2007            ),
2008            ProviderHost::new(
2009                "200".to_string(),
2010                "db".to_string(),
2011                "5.6.7.8".to_string(),
2012                Vec::new(),
2013            ),
2014        ];
2015        sync_provider(
2016            &mut config,
2017            &MockProvider,
2018            &remote,
2019            &section,
2020            false,
2021            false,
2022            false,
2023        );
2024        assert_eq!(config.host_entries().len(), 2);
2025
2026        // Second sync with --remove: web is running, db is stopped (empty IP).
2027        // db must NOT be removed.
2028        let remote = vec![
2029            ProviderHost::new(
2030                "100".to_string(),
2031                "web".to_string(),
2032                "1.2.3.4".to_string(),
2033                Vec::new(),
2034            ),
2035            ProviderHost::new(
2036                "200".to_string(),
2037                "db".to_string(),
2038                String::new(),
2039                Vec::new(),
2040            ),
2041        ];
2042        let result = sync_provider(
2043            &mut config,
2044            &MockProvider,
2045            &remote,
2046            &section,
2047            true,
2048            false,
2049            false,
2050        );
2051        assert_eq!(result.removed, 0);
2052        assert_eq!(result.unchanged, 2);
2053        assert_eq!(config.host_entries().len(), 2);
2054    }
2055
2056    #[test]
2057    fn test_sync_remove_deletes_truly_gone_hosts() {
2058        let mut config = empty_config();
2059        let section = make_section();
2060
2061        // First sync: add two hosts
2062        let remote = vec![
2063            ProviderHost::new(
2064                "100".to_string(),
2065                "web".to_string(),
2066                "1.2.3.4".to_string(),
2067                Vec::new(),
2068            ),
2069            ProviderHost::new(
2070                "200".to_string(),
2071                "db".to_string(),
2072                "5.6.7.8".to_string(),
2073                Vec::new(),
2074            ),
2075        ];
2076        sync_provider(
2077            &mut config,
2078            &MockProvider,
2079            &remote,
2080            &section,
2081            false,
2082            false,
2083            false,
2084        );
2085        assert_eq!(config.host_entries().len(), 2);
2086
2087        // Second sync with --remove: only web exists. db is truly deleted.
2088        let remote = vec![ProviderHost::new(
2089            "100".to_string(),
2090            "web".to_string(),
2091            "1.2.3.4".to_string(),
2092            Vec::new(),
2093        )];
2094        let result = sync_provider(
2095            &mut config,
2096            &MockProvider,
2097            &remote,
2098            &section,
2099            true,
2100            false,
2101            false,
2102        );
2103        assert_eq!(result.removed, 1);
2104        assert_eq!(config.host_entries().len(), 1);
2105        assert_eq!(config.host_entries()[0].alias, "do-web");
2106    }
2107
2108    #[test]
2109    fn test_sync_mixed_resolved_empty_and_missing() {
2110        let mut config = empty_config();
2111        let section = make_section();
2112
2113        // First sync: add three hosts
2114        let remote = vec![
2115            ProviderHost::new(
2116                "1".to_string(),
2117                "running".to_string(),
2118                "1.1.1.1".to_string(),
2119                Vec::new(),
2120            ),
2121            ProviderHost::new(
2122                "2".to_string(),
2123                "stopped".to_string(),
2124                "2.2.2.2".to_string(),
2125                Vec::new(),
2126            ),
2127            ProviderHost::new(
2128                "3".to_string(),
2129                "deleted".to_string(),
2130                "3.3.3.3".to_string(),
2131                Vec::new(),
2132            ),
2133        ];
2134        sync_provider(
2135            &mut config,
2136            &MockProvider,
2137            &remote,
2138            &section,
2139            false,
2140            false,
2141            false,
2142        );
2143        assert_eq!(config.host_entries().len(), 3);
2144
2145        // Second sync with --remove:
2146        // - "running" has new IP (updated)
2147        // - "stopped" has empty IP (unchanged, not removed)
2148        // - "deleted" not in list (removed)
2149        let remote = vec![
2150            ProviderHost::new(
2151                "1".to_string(),
2152                "running".to_string(),
2153                "9.9.9.9".to_string(),
2154                Vec::new(),
2155            ),
2156            ProviderHost::new(
2157                "2".to_string(),
2158                "stopped".to_string(),
2159                String::new(),
2160                Vec::new(),
2161            ),
2162        ];
2163        let result = sync_provider(
2164            &mut config,
2165            &MockProvider,
2166            &remote,
2167            &section,
2168            true,
2169            false,
2170            false,
2171        );
2172        assert_eq!(result.updated, 1);
2173        assert_eq!(result.unchanged, 1);
2174        assert_eq!(result.removed, 1);
2175
2176        let entries = config.host_entries();
2177        assert_eq!(entries.len(), 2);
2178        // Running host got new IP
2179        let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
2180        assert_eq!(running.hostname, "9.9.9.9");
2181        // Stopped host kept old IP
2182        let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
2183        assert_eq!(stopped.hostname, "2.2.2.2");
2184    }
2185
2186    // =========================================================================
2187    // sanitize_name edge cases
2188    // =========================================================================
2189
2190    #[test]
2191    fn test_sanitize_name_unicode() {
2192        // Unicode chars become hyphens, collapsed
2193        assert_eq!(sanitize_name("서버-1"), "1");
2194    }
2195
2196    #[test]
2197    fn test_sanitize_name_numbers_only() {
2198        assert_eq!(sanitize_name("12345"), "12345");
2199    }
2200
2201    #[test]
2202    fn test_sanitize_name_mixed_special_chars() {
2203        assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
2204    }
2205
2206    #[test]
2207    fn test_sanitize_name_tabs_and_newlines() {
2208        assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
2209    }
2210
2211    #[test]
2212    fn test_sanitize_name_consecutive_specials() {
2213        assert_eq!(sanitize_name("a!!!b"), "a-b");
2214    }
2215
2216    #[test]
2217    fn test_sanitize_name_trailing_special() {
2218        assert_eq!(sanitize_name("web-"), "web");
2219    }
2220
2221    #[test]
2222    fn test_sanitize_name_leading_special() {
2223        assert_eq!(sanitize_name("-web"), "web");
2224    }
2225
2226    // =========================================================================
2227    // build_alias edge cases
2228    // =========================================================================
2229
2230    #[test]
2231    fn test_build_alias_prefix_with_hyphen() {
2232        // If prefix already ends with hyphen, double hyphen results
2233        // The caller is expected to provide clean prefixes
2234        assert_eq!(build_alias("do-", "web-1"), "do--web-1");
2235    }
2236
2237    #[test]
2238    fn test_build_alias_long_names() {
2239        assert_eq!(
2240            build_alias("my-provider", "my-very-long-server-name"),
2241            "my-provider-my-very-long-server-name"
2242        );
2243    }
2244
2245    // =========================================================================
2246    // sync with user and identity_file
2247    // =========================================================================
2248
2249    #[test]
2250    fn test_sync_applies_user_from_section() {
2251        let mut config = empty_config();
2252        let mut section = make_section();
2253        section.user = "admin".to_string();
2254        let remote = vec![ProviderHost::new(
2255            "1".to_string(),
2256            "web".to_string(),
2257            "1.2.3.4".to_string(),
2258            Vec::new(),
2259        )];
2260        sync_provider(
2261            &mut config,
2262            &MockProvider,
2263            &remote,
2264            &section,
2265            false,
2266            false,
2267            false,
2268        );
2269        let entries = config.host_entries();
2270        assert_eq!(entries[0].user, "admin");
2271    }
2272
2273    #[test]
2274    fn test_sync_applies_identity_file_from_section() {
2275        let mut config = empty_config();
2276        let mut section = make_section();
2277        section.identity_file = "~/.ssh/id_rsa".to_string();
2278        let remote = vec![ProviderHost::new(
2279            "1".to_string(),
2280            "web".to_string(),
2281            "1.2.3.4".to_string(),
2282            Vec::new(),
2283        )];
2284        sync_provider(
2285            &mut config,
2286            &MockProvider,
2287            &remote,
2288            &section,
2289            false,
2290            false,
2291            false,
2292        );
2293        let entries = config.host_entries();
2294        assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
2295    }
2296
2297    #[test]
2298    fn test_sync_empty_user_not_set() {
2299        let mut config = empty_config();
2300        let mut section = make_section();
2301        section.user = String::new(); // explicitly clear user
2302        let remote = vec![ProviderHost::new(
2303            "1".to_string(),
2304            "web".to_string(),
2305            "1.2.3.4".to_string(),
2306            Vec::new(),
2307        )];
2308        sync_provider(
2309            &mut config,
2310            &MockProvider,
2311            &remote,
2312            &section,
2313            false,
2314            false,
2315            false,
2316        );
2317        let entries = config.host_entries();
2318        assert!(entries[0].user.is_empty());
2319    }
2320
2321    // =========================================================================
2322    // SyncResult struct
2323    // =========================================================================
2324
2325    #[test]
2326    fn test_sync_result_default() {
2327        let result = SyncResult::default();
2328        assert_eq!(result.added, 0);
2329        assert_eq!(result.updated, 0);
2330        assert_eq!(result.removed, 0);
2331        assert_eq!(result.unchanged, 0);
2332        assert!(result.renames.is_empty());
2333    }
2334
2335    // =========================================================================
2336    // sync with multiple operations in one call
2337    // =========================================================================
2338
2339    #[test]
2340    fn test_sync_server_name_change_updates_alias() {
2341        let mut config = empty_config();
2342        let section = make_section();
2343        // Add initial host
2344        let remote = vec![ProviderHost::new(
2345            "1".to_string(),
2346            "old-name".to_string(),
2347            "1.2.3.4".to_string(),
2348            Vec::new(),
2349        )];
2350        sync_provider(
2351            &mut config,
2352            &MockProvider,
2353            &remote,
2354            &section,
2355            false,
2356            false,
2357            false,
2358        );
2359        assert_eq!(config.host_entries()[0].alias, "do-old-name");
2360
2361        // Sync with new name (same server_id)
2362        let remote_renamed = vec![ProviderHost::new(
2363            "1".to_string(),
2364            "new-name".to_string(),
2365            "1.2.3.4".to_string(),
2366            Vec::new(),
2367        )];
2368        let result = sync_provider(
2369            &mut config,
2370            &MockProvider,
2371            &remote_renamed,
2372            &section,
2373            false,
2374            false,
2375            false,
2376        );
2377        // Should rename the alias
2378        assert!(!result.renames.is_empty() || result.updated > 0);
2379    }
2380
2381    #[test]
2382    fn test_sync_idempotent_same_data() {
2383        let mut config = empty_config();
2384        let section = make_section();
2385        let remote = vec![ProviderHost::new(
2386            "1".to_string(),
2387            "web".to_string(),
2388            "1.2.3.4".to_string(),
2389            vec!["prod".to_string()],
2390        )];
2391        sync_provider(
2392            &mut config,
2393            &MockProvider,
2394            &remote,
2395            &section,
2396            false,
2397            false,
2398            false,
2399        );
2400        let result = sync_provider(
2401            &mut config,
2402            &MockProvider,
2403            &remote,
2404            &section,
2405            false,
2406            false,
2407            false,
2408        );
2409        assert_eq!(result.added, 0);
2410        assert_eq!(result.updated, 0);
2411        assert_eq!(result.unchanged, 1);
2412    }
2413
2414    // =========================================================================
2415    // Tag merge edge cases
2416    // =========================================================================
2417
2418    #[test]
2419    fn test_sync_tag_merge_case_insensitive_no_duplicate() {
2420        let mut config = empty_config();
2421        let section = make_section();
2422        // Add host with tag "Prod"
2423        let remote = vec![ProviderHost::new(
2424            "1".to_string(),
2425            "web".to_string(),
2426            "1.2.3.4".to_string(),
2427            vec!["Prod".to_string()],
2428        )];
2429        sync_provider(
2430            &mut config,
2431            &MockProvider,
2432            &remote,
2433            &section,
2434            false,
2435            false,
2436            false,
2437        );
2438
2439        // Sync again with "prod" (lowercase) - should NOT add duplicate
2440        let remote2 = vec![ProviderHost::new(
2441            "1".to_string(),
2442            "web".to_string(),
2443            "1.2.3.4".to_string(),
2444            vec!["prod".to_string()],
2445        )];
2446        let result = sync_provider(
2447            &mut config,
2448            &MockProvider,
2449            &remote2,
2450            &section,
2451            false,
2452            false,
2453            false,
2454        );
2455        assert_eq!(result.unchanged, 1);
2456        assert_eq!(result.updated, 0);
2457    }
2458
2459    #[test]
2460    fn test_sync_tag_merge_adds_new_remote_tag() {
2461        let mut config = empty_config();
2462        let section = make_section();
2463        let remote = vec![ProviderHost::new(
2464            "1".to_string(),
2465            "web".to_string(),
2466            "1.2.3.4".to_string(),
2467            vec!["prod".to_string()],
2468        )];
2469        sync_provider(
2470            &mut config,
2471            &MockProvider,
2472            &remote,
2473            &section,
2474            false,
2475            false,
2476            false,
2477        );
2478
2479        // Sync with additional tag "us-east"
2480        let remote2 = vec![ProviderHost::new(
2481            "1".to_string(),
2482            "web".to_string(),
2483            "1.2.3.4".to_string(),
2484            vec!["prod".to_string(), "us-east".to_string()],
2485        )];
2486        let result = sync_provider(
2487            &mut config,
2488            &MockProvider,
2489            &remote2,
2490            &section,
2491            false,
2492            false,
2493            false,
2494        );
2495        assert_eq!(result.updated, 1);
2496
2497        // Verify both provider tags present
2498        let entries = config.host_entries();
2499        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2500        assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2501        assert!(entry.provider_tags.iter().any(|t| t == "us-east"));
2502    }
2503
2504    #[test]
2505    fn test_sync_tag_merge_preserves_local_tags() {
2506        let mut config = empty_config();
2507        let section = make_section();
2508        let remote = vec![ProviderHost::new(
2509            "1".to_string(),
2510            "web".to_string(),
2511            "1.2.3.4".to_string(),
2512            vec!["prod".to_string()],
2513        )];
2514        sync_provider(
2515            &mut config,
2516            &MockProvider,
2517            &remote,
2518            &section,
2519            false,
2520            false,
2521            false,
2522        );
2523
2524        // Manually add a local tag
2525        config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2526
2527        // Sync again: "prod" overlap cleaned from user tags, "my-custom" preserved
2528        let result = sync_provider(
2529            &mut config,
2530            &MockProvider,
2531            &remote,
2532            &section,
2533            false,
2534            false,
2535            false,
2536        );
2537        assert_eq!(result.updated, 1);
2538        let entries = config.host_entries();
2539        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2540        assert!(entry.tags.iter().any(|t| t == "my-custom"));
2541        assert!(!entry.tags.iter().any(|t| t == "prod")); // migrated to provider_tags
2542    }
2543
2544    #[test]
2545    fn test_sync_provider_tags_replaces_with_migration() {
2546        let mut config = empty_config();
2547        let section = make_section();
2548        let remote = vec![ProviderHost::new(
2549            "1".to_string(),
2550            "web".to_string(),
2551            "1.2.3.4".to_string(),
2552            vec!["prod".to_string()],
2553        )];
2554        sync_provider(
2555            &mut config,
2556            &MockProvider,
2557            &remote,
2558            &section,
2559            false,
2560            false,
2561            false,
2562        );
2563
2564        // Add local-only tag
2565        config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2566
2567        // Sync: provider_tags replaced, user tags migrated
2568        let remote2 = vec![ProviderHost::new(
2569            "1".to_string(),
2570            "web".to_string(),
2571            "1.2.3.4".to_string(),
2572            vec!["prod".to_string(), "new-tag".to_string()],
2573        )];
2574        let result = sync_provider(
2575            &mut config,
2576            &MockProvider,
2577            &remote2,
2578            &section,
2579            false,
2580            false,
2581            false,
2582        );
2583        assert_eq!(result.updated, 1);
2584
2585        let entries = config.host_entries();
2586        let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2587        // Provider tags exactly mirror remote
2588        assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2589        assert!(entry.provider_tags.iter().any(|t| t == "new-tag"));
2590        // User tag "my-custom" survives, "prod" migrated to provider_tags
2591        assert!(!entry.tags.iter().any(|t| t == "prod"));
2592        assert!(entry.tags.iter().any(|t| t == "my-custom"));
2593    }
2594
2595    // =========================================================================
2596    // Rename + tag change simultaneously
2597    // =========================================================================
2598
2599    #[test]
2600    fn test_sync_rename_and_ip_change_simultaneously() {
2601        let mut config = empty_config();
2602        let section = make_section();
2603        let remote = vec![ProviderHost::new(
2604            "1".to_string(),
2605            "old-name".to_string(),
2606            "1.2.3.4".to_string(),
2607            Vec::new(),
2608        )];
2609        sync_provider(
2610            &mut config,
2611            &MockProvider,
2612            &remote,
2613            &section,
2614            false,
2615            false,
2616            false,
2617        );
2618
2619        // Both name and IP change
2620        let remote2 = vec![ProviderHost::new(
2621            "1".to_string(),
2622            "new-name".to_string(),
2623            "9.8.7.6".to_string(),
2624            Vec::new(),
2625        )];
2626        let result = sync_provider(
2627            &mut config,
2628            &MockProvider,
2629            &remote2,
2630            &section,
2631            false,
2632            false,
2633            false,
2634        );
2635        assert_eq!(result.updated, 1);
2636        assert_eq!(result.renames.len(), 1);
2637        assert_eq!(result.renames[0].0, "do-old-name");
2638        assert_eq!(result.renames[0].1, "do-new-name");
2639
2640        let entries = config.host_entries();
2641        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
2642        assert_eq!(entry.hostname, "9.8.7.6");
2643    }
2644
2645    // =========================================================================
2646    // Duplicate server_id in remote response
2647    // =========================================================================
2648
2649    #[test]
2650    fn test_sync_duplicate_server_id_deduped() {
2651        let mut config = empty_config();
2652        let section = make_section();
2653        let remote = vec![
2654            ProviderHost::new(
2655                "1".to_string(),
2656                "web".to_string(),
2657                "1.2.3.4".to_string(),
2658                Vec::new(),
2659            ),
2660            ProviderHost::new(
2661                "1".to_string(),
2662                "web-copy".to_string(),
2663                "5.6.7.8".to_string(),
2664                Vec::new(),
2665            ), // duplicate server_id
2666        ];
2667        let result = sync_provider(
2668            &mut config,
2669            &MockProvider,
2670            &remote,
2671            &section,
2672            false,
2673            false,
2674            false,
2675        );
2676        assert_eq!(result.added, 1); // Only first one added
2677        assert_eq!(config.host_entries().len(), 1);
2678    }
2679
2680    // =========================================================================
2681    // Empty remote list with remove_deleted
2682    // =========================================================================
2683
2684    #[test]
2685    fn test_sync_remove_all_when_remote_empty() {
2686        let mut config = empty_config();
2687        let section = make_section();
2688        let remote = vec![
2689            ProviderHost::new(
2690                "1".to_string(),
2691                "web".to_string(),
2692                "1.2.3.4".to_string(),
2693                Vec::new(),
2694            ),
2695            ProviderHost::new(
2696                "2".to_string(),
2697                "db".to_string(),
2698                "5.6.7.8".to_string(),
2699                Vec::new(),
2700            ),
2701        ];
2702        sync_provider(
2703            &mut config,
2704            &MockProvider,
2705            &remote,
2706            &section,
2707            false,
2708            false,
2709            false,
2710        );
2711        assert_eq!(config.host_entries().len(), 2);
2712
2713        // Sync with empty remote list and remove_deleted
2714        let result = sync_provider(
2715            &mut config,
2716            &MockProvider,
2717            &[],
2718            &section,
2719            true,
2720            false,
2721            false,
2722        );
2723        assert_eq!(result.removed, 2);
2724        assert_eq!(config.host_entries().len(), 0);
2725    }
2726
2727    // =========================================================================
2728    // Header management
2729    // =========================================================================
2730
2731    #[test]
2732    fn test_sync_adds_group_header_on_first_host() {
2733        let mut config = empty_config();
2734        let section = make_section();
2735        let remote = vec![ProviderHost::new(
2736            "1".to_string(),
2737            "web".to_string(),
2738            "1.2.3.4".to_string(),
2739            Vec::new(),
2740        )];
2741        sync_provider(
2742            &mut config,
2743            &MockProvider,
2744            &remote,
2745            &section,
2746            false,
2747            false,
2748            false,
2749        );
2750
2751        // Check that a GlobalLine with group header exists
2752        let has_header = config.elements.iter().any(|e| {
2753            matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2754        });
2755        assert!(has_header);
2756    }
2757
2758    #[test]
2759    fn test_sync_removes_header_when_all_hosts_deleted() {
2760        let mut config = empty_config();
2761        let section = make_section();
2762        let remote = vec![ProviderHost::new(
2763            "1".to_string(),
2764            "web".to_string(),
2765            "1.2.3.4".to_string(),
2766            Vec::new(),
2767        )];
2768        sync_provider(
2769            &mut config,
2770            &MockProvider,
2771            &remote,
2772            &section,
2773            false,
2774            false,
2775            false,
2776        );
2777
2778        // Remove all hosts
2779        let result = sync_provider(
2780            &mut config,
2781            &MockProvider,
2782            &[],
2783            &section,
2784            true,
2785            false,
2786            false,
2787        );
2788        assert_eq!(result.removed, 1);
2789
2790        // Header should be cleaned up
2791        let has_header = config.elements.iter().any(|e| {
2792            matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2793        });
2794        assert!(!has_header);
2795    }
2796
2797    // =========================================================================
2798    // Identity file applied on new hosts
2799    // =========================================================================
2800
2801    #[test]
2802    fn test_sync_identity_file_set_on_new_host() {
2803        let mut config = empty_config();
2804        let mut section = make_section();
2805        section.identity_file = "~/.ssh/do_key".to_string();
2806        let remote = vec![ProviderHost::new(
2807            "1".to_string(),
2808            "web".to_string(),
2809            "1.2.3.4".to_string(),
2810            Vec::new(),
2811        )];
2812        sync_provider(
2813            &mut config,
2814            &MockProvider,
2815            &remote,
2816            &section,
2817            false,
2818            false,
2819            false,
2820        );
2821        let entries = config.host_entries();
2822        assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
2823    }
2824
2825    // =========================================================================
2826    // Alias collision deduplication
2827    // =========================================================================
2828
2829    #[test]
2830    fn test_sync_alias_collision_dedup() {
2831        let mut config = empty_config();
2832        let section = make_section();
2833        // Two remote hosts with same sanitized name but different server_ids
2834        let remote = vec![
2835            ProviderHost::new(
2836                "1".to_string(),
2837                "web".to_string(),
2838                "1.2.3.4".to_string(),
2839                Vec::new(),
2840            ),
2841            ProviderHost::new(
2842                "2".to_string(),
2843                "web".to_string(),
2844                "5.6.7.8".to_string(),
2845                Vec::new(),
2846            ), // same name, different server
2847        ];
2848        let result = sync_provider(
2849            &mut config,
2850            &MockProvider,
2851            &remote,
2852            &section,
2853            false,
2854            false,
2855            false,
2856        );
2857        assert_eq!(result.added, 2);
2858
2859        let entries = config.host_entries();
2860        let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
2861        assert!(aliases.contains(&"do-web"));
2862        assert!(aliases.contains(&"do-web-2")); // Deduped with suffix
2863    }
2864
2865    // =========================================================================
2866    // Empty alias_prefix
2867    // =========================================================================
2868
2869    #[test]
2870    fn test_sync_empty_alias_prefix() {
2871        let mut config = empty_config();
2872        let mut section = make_section();
2873        section.alias_prefix = String::new();
2874        let remote = vec![ProviderHost::new(
2875            "1".to_string(),
2876            "web-1".to_string(),
2877            "1.2.3.4".to_string(),
2878            Vec::new(),
2879        )];
2880        sync_provider(
2881            &mut config,
2882            &MockProvider,
2883            &remote,
2884            &section,
2885            false,
2886            false,
2887            false,
2888        );
2889        let entries = config.host_entries();
2890        assert_eq!(entries[0].alias, "web-1"); // No prefix, just sanitized name
2891    }
2892
2893    // =========================================================================
2894    // Dry-run counts consistency
2895    // =========================================================================
2896
2897    #[test]
2898    fn test_sync_dry_run_add_count() {
2899        let mut config = empty_config();
2900        let section = make_section();
2901        let remote = vec![
2902            ProviderHost::new(
2903                "1".to_string(),
2904                "web".to_string(),
2905                "1.2.3.4".to_string(),
2906                Vec::new(),
2907            ),
2908            ProviderHost::new(
2909                "2".to_string(),
2910                "db".to_string(),
2911                "5.6.7.8".to_string(),
2912                Vec::new(),
2913            ),
2914        ];
2915        let result = sync_provider(
2916            &mut config,
2917            &MockProvider,
2918            &remote,
2919            &section,
2920            false,
2921            false,
2922            true,
2923        );
2924        assert_eq!(result.added, 2);
2925        // Config should be unchanged in dry-run
2926        assert_eq!(config.host_entries().len(), 0);
2927    }
2928
2929    #[test]
2930    fn test_sync_dry_run_remove_count_preserves_config() {
2931        let mut config = empty_config();
2932        let section = make_section();
2933        let remote = vec![ProviderHost::new(
2934            "1".to_string(),
2935            "web".to_string(),
2936            "1.2.3.4".to_string(),
2937            Vec::new(),
2938        )];
2939        sync_provider(
2940            &mut config,
2941            &MockProvider,
2942            &remote,
2943            &section,
2944            false,
2945            false,
2946            false,
2947        );
2948        assert_eq!(config.host_entries().len(), 1);
2949
2950        // Dry-run remove
2951        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false, true);
2952        assert_eq!(result.removed, 1);
2953        // Config should still have the host
2954        assert_eq!(config.host_entries().len(), 1);
2955    }
2956
2957    // =========================================================================
2958    // Result struct
2959    // =========================================================================
2960
2961    #[test]
2962    fn test_sync_result_counts_add_up() {
2963        let mut config = empty_config();
2964        let section = make_section();
2965        // Add 3 hosts
2966        let remote = vec![
2967            ProviderHost::new(
2968                "1".to_string(),
2969                "a".to_string(),
2970                "1.1.1.1".to_string(),
2971                Vec::new(),
2972            ),
2973            ProviderHost::new(
2974                "2".to_string(),
2975                "b".to_string(),
2976                "2.2.2.2".to_string(),
2977                Vec::new(),
2978            ),
2979            ProviderHost::new(
2980                "3".to_string(),
2981                "c".to_string(),
2982                "3.3.3.3".to_string(),
2983                Vec::new(),
2984            ),
2985        ];
2986        sync_provider(
2987            &mut config,
2988            &MockProvider,
2989            &remote,
2990            &section,
2991            false,
2992            false,
2993            false,
2994        );
2995
2996        // Sync with: 1 unchanged, 1 ip changed, 1 removed (missing from remote)
2997        let remote2 = vec![
2998            ProviderHost::new(
2999                "1".to_string(),
3000                "a".to_string(),
3001                "1.1.1.1".to_string(),
3002                Vec::new(),
3003            ), // unchanged
3004            ProviderHost::new(
3005                "2".to_string(),
3006                "b".to_string(),
3007                "9.9.9.9".to_string(),
3008                Vec::new(),
3009            ), // IP changed
3010               // server_id "3" missing -> removed
3011        ];
3012        let result = sync_provider(
3013            &mut config,
3014            &MockProvider,
3015            &remote2,
3016            &section,
3017            true,
3018            false,
3019            false,
3020        );
3021        assert_eq!(result.unchanged, 1);
3022        assert_eq!(result.updated, 1);
3023        assert_eq!(result.removed, 1);
3024        assert_eq!(result.added, 0);
3025    }
3026
3027    // =========================================================================
3028    // Multiple renames in single sync
3029    // =========================================================================
3030
3031    #[test]
3032    fn test_sync_multiple_renames() {
3033        let mut config = empty_config();
3034        let section = make_section();
3035        let remote = vec![
3036            ProviderHost::new(
3037                "1".to_string(),
3038                "old-a".to_string(),
3039                "1.1.1.1".to_string(),
3040                Vec::new(),
3041            ),
3042            ProviderHost::new(
3043                "2".to_string(),
3044                "old-b".to_string(),
3045                "2.2.2.2".to_string(),
3046                Vec::new(),
3047            ),
3048        ];
3049        sync_provider(
3050            &mut config,
3051            &MockProvider,
3052            &remote,
3053            &section,
3054            false,
3055            false,
3056            false,
3057        );
3058
3059        let remote2 = vec![
3060            ProviderHost::new(
3061                "1".to_string(),
3062                "new-a".to_string(),
3063                "1.1.1.1".to_string(),
3064                Vec::new(),
3065            ),
3066            ProviderHost::new(
3067                "2".to_string(),
3068                "new-b".to_string(),
3069                "2.2.2.2".to_string(),
3070                Vec::new(),
3071            ),
3072        ];
3073        let result = sync_provider(
3074            &mut config,
3075            &MockProvider,
3076            &remote2,
3077            &section,
3078            false,
3079            false,
3080            false,
3081        );
3082        assert_eq!(result.renames.len(), 2);
3083        assert_eq!(result.updated, 2);
3084    }
3085
3086    // =========================================================================
3087    // Tag whitespace trimming
3088    // =========================================================================
3089
3090    #[test]
3091    fn test_sync_tag_whitespace_trimmed_on_store() {
3092        let mut config = empty_config();
3093        let section = make_section();
3094        // Tags with whitespace get trimmed when written to config and parsed back
3095        let remote = vec![ProviderHost::new(
3096            "1".to_string(),
3097            "web".to_string(),
3098            "1.2.3.4".to_string(),
3099            vec!["  production  ".to_string(), " us-east ".to_string()],
3100        )];
3101        sync_provider(
3102            &mut config,
3103            &MockProvider,
3104            &remote,
3105            &section,
3106            false,
3107            false,
3108            false,
3109        );
3110        let entries = config.host_entries();
3111        // Tags are trimmed during the write+parse roundtrip via set_host_provider_tags
3112        assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
3113    }
3114
3115    #[test]
3116    fn test_sync_tag_trimmed_remote_triggers_merge() {
3117        let mut config = empty_config();
3118        let section = make_section();
3119        // First sync: clean tags
3120        let remote = vec![ProviderHost::new(
3121            "1".to_string(),
3122            "web".to_string(),
3123            "1.2.3.4".to_string(),
3124            vec!["production".to_string()],
3125        )];
3126        sync_provider(
3127            &mut config,
3128            &MockProvider,
3129            &remote,
3130            &section,
3131            false,
3132            false,
3133            false,
3134        );
3135
3136        // Second sync: same tag but trimmed comparison works correctly
3137        let remote2 = vec![ProviderHost::new(
3138            "1".to_string(),
3139            "web".to_string(),
3140            "1.2.3.4".to_string(),
3141            vec!["  production  ".to_string()],
3142        )]; // whitespace trimmed before comparison
3143        let result = sync_provider(
3144            &mut config,
3145            &MockProvider,
3146            &remote2,
3147            &section,
3148            false,
3149            false,
3150            false,
3151        );
3152        // Trimmed "production" matches existing "production" case-insensitively
3153        assert_eq!(result.unchanged, 1);
3154    }
3155
3156    // =========================================================================
3157    // Cross-provider coexistence
3158    // =========================================================================
3159
3160    struct MockProvider2;
3161    impl Provider for MockProvider2 {
3162        fn name(&self) -> &str {
3163            "vultr"
3164        }
3165        fn short_label(&self) -> &str {
3166            "vultr"
3167        }
3168        fn fetch_hosts_cancellable(
3169            &self,
3170            _token: &str,
3171            _cancel: &std::sync::atomic::AtomicBool,
3172        ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
3173            Ok(Vec::new())
3174        }
3175    }
3176
3177    #[test]
3178    fn test_sync_two_providers_independent() {
3179        let mut config = empty_config();
3180
3181        let do_section = make_section(); // prefix = "do"
3182        let vultr_section = ProviderSection {
3183            provider: "vultr".to_string(),
3184            token: "test".to_string(),
3185            alias_prefix: "vultr".to_string(),
3186            user: String::new(),
3187            identity_file: String::new(),
3188            url: String::new(),
3189            verify_tls: true,
3190            auto_sync: true,
3191            profile: String::new(),
3192            regions: String::new(),
3193            project: String::new(),
3194            compartment: String::new(),
3195            vault_role: String::new(),
3196            vault_addr: String::new(),
3197        };
3198
3199        // Sync DO hosts
3200        let do_remote = vec![ProviderHost::new(
3201            "1".to_string(),
3202            "web".to_string(),
3203            "1.2.3.4".to_string(),
3204            Vec::new(),
3205        )];
3206        sync_provider(
3207            &mut config,
3208            &MockProvider,
3209            &do_remote,
3210            &do_section,
3211            false,
3212            false,
3213            false,
3214        );
3215
3216        // Sync Vultr hosts
3217        let vultr_remote = vec![ProviderHost::new(
3218            "abc".to_string(),
3219            "web".to_string(),
3220            "5.6.7.8".to_string(),
3221            Vec::new(),
3222        )];
3223        sync_provider(
3224            &mut config,
3225            &MockProvider2,
3226            &vultr_remote,
3227            &vultr_section,
3228            false,
3229            false,
3230            false,
3231        );
3232
3233        let entries = config.host_entries();
3234        assert_eq!(entries.len(), 2);
3235        let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
3236        assert!(aliases.contains(&"do-web"));
3237        assert!(aliases.contains(&"vultr-web"));
3238    }
3239
3240    #[test]
3241    fn test_sync_remove_only_affects_own_provider() {
3242        let mut config = empty_config();
3243        let do_section = make_section();
3244        let vultr_section = ProviderSection {
3245            provider: "vultr".to_string(),
3246            token: "test".to_string(),
3247            alias_prefix: "vultr".to_string(),
3248            user: String::new(),
3249            identity_file: String::new(),
3250            url: String::new(),
3251            verify_tls: true,
3252            auto_sync: true,
3253            profile: String::new(),
3254            regions: String::new(),
3255            project: String::new(),
3256            compartment: String::new(),
3257            vault_role: String::new(),
3258            vault_addr: String::new(),
3259        };
3260
3261        // Add hosts from both providers
3262        let do_remote = vec![ProviderHost::new(
3263            "1".to_string(),
3264            "web".to_string(),
3265            "1.2.3.4".to_string(),
3266            Vec::new(),
3267        )];
3268        sync_provider(
3269            &mut config,
3270            &MockProvider,
3271            &do_remote,
3272            &do_section,
3273            false,
3274            false,
3275            false,
3276        );
3277
3278        let vultr_remote = vec![ProviderHost::new(
3279            "abc".to_string(),
3280            "db".to_string(),
3281            "5.6.7.8".to_string(),
3282            Vec::new(),
3283        )];
3284        sync_provider(
3285            &mut config,
3286            &MockProvider2,
3287            &vultr_remote,
3288            &vultr_section,
3289            false,
3290            false,
3291            false,
3292        );
3293        assert_eq!(config.host_entries().len(), 2);
3294
3295        // Remove all DO hosts - Vultr host should survive
3296        let result = sync_provider(
3297            &mut config,
3298            &MockProvider,
3299            &[],
3300            &do_section,
3301            true,
3302            false,
3303            false,
3304        );
3305        assert_eq!(result.removed, 1);
3306        let entries = config.host_entries();
3307        assert_eq!(entries.len(), 1);
3308        assert_eq!(entries[0].alias, "vultr-db");
3309    }
3310
3311    // =========================================================================
3312    // Rename + tag change simultaneously
3313    // =========================================================================
3314
3315    #[test]
3316    fn test_sync_rename_and_tag_change_simultaneously() {
3317        let mut config = empty_config();
3318        let section = make_section();
3319        let remote = vec![ProviderHost::new(
3320            "1".to_string(),
3321            "old-name".to_string(),
3322            "1.2.3.4".to_string(),
3323            vec!["staging".to_string()],
3324        )];
3325        sync_provider(
3326            &mut config,
3327            &MockProvider,
3328            &remote,
3329            &section,
3330            false,
3331            false,
3332            false,
3333        );
3334        assert_eq!(config.host_entries()[0].alias, "do-old-name");
3335        assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
3336
3337        // Change name and add new tag
3338        let remote2 = vec![ProviderHost::new(
3339            "1".to_string(),
3340            "new-name".to_string(),
3341            "1.2.3.4".to_string(),
3342            vec!["staging".to_string(), "prod".to_string()],
3343        )];
3344        let result = sync_provider(
3345            &mut config,
3346            &MockProvider,
3347            &remote2,
3348            &section,
3349            false,
3350            false,
3351            false,
3352        );
3353        assert_eq!(result.updated, 1);
3354        assert_eq!(result.renames.len(), 1);
3355
3356        let entries = config.host_entries();
3357        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3358        assert!(entry.provider_tags.contains(&"staging".to_string()));
3359        assert!(entry.provider_tags.contains(&"prod".to_string()));
3360    }
3361
3362    // =========================================================================
3363    // All-symbol server name fallback
3364    // =========================================================================
3365
3366    #[test]
3367    fn test_sync_all_symbol_name_uses_server_fallback() {
3368        let mut config = empty_config();
3369        let section = make_section();
3370        let remote = vec![ProviderHost::new(
3371            "1".to_string(),
3372            "!!!".to_string(),
3373            "1.2.3.4".to_string(),
3374            Vec::new(),
3375        )];
3376        sync_provider(
3377            &mut config,
3378            &MockProvider,
3379            &remote,
3380            &section,
3381            false,
3382            false,
3383            false,
3384        );
3385        let entries = config.host_entries();
3386        assert_eq!(entries[0].alias, "do-server");
3387    }
3388
3389    #[test]
3390    fn test_sync_unicode_name_uses_ascii_fallback() {
3391        let mut config = empty_config();
3392        let section = make_section();
3393        let remote = vec![ProviderHost::new(
3394            "1".to_string(),
3395            "서버".to_string(),
3396            "1.2.3.4".to_string(),
3397            Vec::new(),
3398        )];
3399        sync_provider(
3400            &mut config,
3401            &MockProvider,
3402            &remote,
3403            &section,
3404            false,
3405            false,
3406            false,
3407        );
3408        let entries = config.host_entries();
3409        // Korean chars stripped, fallback to "server"
3410        assert_eq!(entries[0].alias, "do-server");
3411    }
3412
3413    // =========================================================================
3414    // Dry-run update doesn't mutate
3415    // =========================================================================
3416
3417    #[test]
3418    fn test_sync_dry_run_update_preserves_config() {
3419        let mut config = empty_config();
3420        let section = make_section();
3421        let remote = vec![ProviderHost::new(
3422            "1".to_string(),
3423            "web".to_string(),
3424            "1.2.3.4".to_string(),
3425            Vec::new(),
3426        )];
3427        sync_provider(
3428            &mut config,
3429            &MockProvider,
3430            &remote,
3431            &section,
3432            false,
3433            false,
3434            false,
3435        );
3436
3437        // Dry-run with IP change
3438        let remote2 = vec![ProviderHost::new(
3439            "1".to_string(),
3440            "web".to_string(),
3441            "9.9.9.9".to_string(),
3442            Vec::new(),
3443        )];
3444        let result = sync_provider(
3445            &mut config,
3446            &MockProvider,
3447            &remote2,
3448            &section,
3449            false,
3450            false,
3451            true,
3452        );
3453        assert_eq!(result.updated, 1);
3454        // Config should still have old IP
3455        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
3456    }
3457
3458    // =========================================================================
3459    // No-op sync on empty config with empty remote
3460    // =========================================================================
3461
3462    #[test]
3463    fn test_sync_empty_remote_empty_config_noop() {
3464        let mut config = empty_config();
3465        let section = make_section();
3466        let result = sync_provider(
3467            &mut config,
3468            &MockProvider,
3469            &[],
3470            &section,
3471            true,
3472            false,
3473            false,
3474        );
3475        assert_eq!(result.added, 0);
3476        assert_eq!(result.updated, 0);
3477        assert_eq!(result.removed, 0);
3478        assert_eq!(result.unchanged, 0);
3479        assert!(config.host_entries().is_empty());
3480    }
3481
3482    // =========================================================================
3483    // Large batch sync
3484    // =========================================================================
3485
3486    #[test]
3487    fn test_sync_large_batch() {
3488        let mut config = empty_config();
3489        let section = make_section();
3490        let remote: Vec<ProviderHost> = (0..100)
3491            .map(|i| {
3492                ProviderHost::new(
3493                    format!("{}", i),
3494                    format!("server-{}", i),
3495                    format!("10.0.0.{}", i % 256),
3496                    vec!["batch".to_string()],
3497                )
3498            })
3499            .collect();
3500        let result = sync_provider(
3501            &mut config,
3502            &MockProvider,
3503            &remote,
3504            &section,
3505            false,
3506            false,
3507            false,
3508        );
3509        assert_eq!(result.added, 100);
3510        assert_eq!(config.host_entries().len(), 100);
3511
3512        // Re-sync unchanged
3513        let result2 = sync_provider(
3514            &mut config,
3515            &MockProvider,
3516            &remote,
3517            &section,
3518            false,
3519            false,
3520            false,
3521        );
3522        assert_eq!(result2.unchanged, 100);
3523        assert_eq!(result2.added, 0);
3524    }
3525
3526    // =========================================================================
3527    // Rename collision with self-exclusion
3528    // =========================================================================
3529
3530    #[test]
3531    fn test_sync_rename_self_exclusion_no_collision() {
3532        // When renaming and the expected alias is already taken by this host itself,
3533        // deduplicate_alias_excluding should handle it (no -2 suffix)
3534        let mut config = empty_config();
3535        let section = make_section();
3536        let remote = vec![ProviderHost::new(
3537            "1".to_string(),
3538            "web".to_string(),
3539            "1.2.3.4".to_string(),
3540            Vec::new(),
3541        )];
3542        sync_provider(
3543            &mut config,
3544            &MockProvider,
3545            &remote,
3546            &section,
3547            false,
3548            false,
3549            false,
3550        );
3551        assert_eq!(config.host_entries()[0].alias, "do-web");
3552
3553        // Re-sync with same name but different IP -> update, no rename
3554        let remote2 = vec![ProviderHost::new(
3555            "1".to_string(),
3556            "web".to_string(),
3557            "9.9.9.9".to_string(),
3558            Vec::new(),
3559        )];
3560        let result = sync_provider(
3561            &mut config,
3562            &MockProvider,
3563            &remote2,
3564            &section,
3565            false,
3566            false,
3567            false,
3568        );
3569        assert_eq!(result.updated, 1);
3570        assert!(result.renames.is_empty());
3571        assert_eq!(config.host_entries()[0].alias, "do-web"); // No suffix
3572    }
3573
3574    // =========================================================================
3575    // Reset tags with rename: tags applied to new alias
3576    // =========================================================================
3577
3578    #[test]
3579    fn test_sync_provider_tags_with_rename() {
3580        let mut config = empty_config();
3581        let section = make_section();
3582        let remote = vec![ProviderHost::new(
3583            "1".to_string(),
3584            "old-name".to_string(),
3585            "1.2.3.4".to_string(),
3586            vec!["staging".to_string()],
3587        )];
3588        sync_provider(
3589            &mut config,
3590            &MockProvider,
3591            &remote,
3592            &section,
3593            false,
3594            false,
3595            false,
3596        );
3597        config.set_host_tags(
3598            "do-old-name",
3599            &["staging".to_string(), "custom".to_string()],
3600        );
3601
3602        // Rename + provider tags update
3603        let remote2 = vec![ProviderHost::new(
3604            "1".to_string(),
3605            "new-name".to_string(),
3606            "1.2.3.4".to_string(),
3607            vec!["production".to_string()],
3608        )];
3609        let result = sync_provider(
3610            &mut config,
3611            &MockProvider,
3612            &remote2,
3613            &section,
3614            false,
3615            false,
3616            false,
3617        );
3618        assert_eq!(result.updated, 1);
3619        assert_eq!(result.renames.len(), 1);
3620
3621        let entries = config.host_entries();
3622        let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3623        // Provider tags exactly mirror remote
3624        assert_eq!(entry.provider_tags, vec!["production"]);
3625        // User tags preserved (migration only removes tags matching remote "production")
3626        assert!(entry.tags.contains(&"custom".to_string()));
3627        assert!(entry.tags.contains(&"staging".to_string()));
3628    }
3629
3630    // =========================================================================
3631    // Empty IP in first sync never added
3632    // =========================================================================
3633
3634    #[test]
3635    fn test_sync_empty_ip_with_tags_not_added() {
3636        let mut config = empty_config();
3637        let section = make_section();
3638        let remote = vec![ProviderHost::new(
3639            "1".to_string(),
3640            "stopped".to_string(),
3641            String::new(),
3642            vec!["prod".to_string()],
3643        )];
3644        let result = sync_provider(
3645            &mut config,
3646            &MockProvider,
3647            &remote,
3648            &section,
3649            false,
3650            false,
3651            false,
3652        );
3653        assert_eq!(result.added, 0);
3654        assert!(config.host_entries().is_empty());
3655    }
3656
3657    // =========================================================================
3658    // Existing host not in entries_map (orphaned provider marker)
3659    // =========================================================================
3660
3661    #[test]
3662    fn test_sync_orphaned_provider_marker_counts_unchanged() {
3663        // If a provider marker exists but the host block is somehow broken/missing
3664        // from host_entries(), the code path at line 217 counts it as unchanged.
3665        // This is hard to trigger naturally, but we can verify the behavior with
3666        // a host that has a provider marker but also exists in entries_map.
3667        let content = "\
3668Host do-web
3669  HostName 1.2.3.4
3670  # purple:provider digitalocean:123
3671";
3672        let mut config = SshConfigFile {
3673            elements: SshConfigFile::parse_content(content),
3674            path: PathBuf::from("/tmp/test_config"),
3675            crlf: false,
3676            bom: false,
3677        };
3678        let section = make_section();
3679        let remote = vec![ProviderHost::new(
3680            "123".to_string(),
3681            "web".to_string(),
3682            "1.2.3.4".to_string(),
3683            Vec::new(),
3684        )];
3685        let result = sync_provider(
3686            &mut config,
3687            &MockProvider,
3688            &remote,
3689            &section,
3690            false,
3691            false,
3692            false,
3693        );
3694        assert_eq!(result.unchanged, 1);
3695    }
3696
3697    // =========================================================================
3698    // Separator between hosts (no double blank lines)
3699    // =========================================================================
3700
3701    #[test]
3702    fn test_sync_no_double_blank_between_hosts() {
3703        let mut config = empty_config();
3704        let section = make_section();
3705        let remote = vec![
3706            ProviderHost::new(
3707                "1".to_string(),
3708                "web".to_string(),
3709                "1.2.3.4".to_string(),
3710                Vec::new(),
3711            ),
3712            ProviderHost::new(
3713                "2".to_string(),
3714                "db".to_string(),
3715                "5.6.7.8".to_string(),
3716                Vec::new(),
3717            ),
3718        ];
3719        sync_provider(
3720            &mut config,
3721            &MockProvider,
3722            &remote,
3723            &section,
3724            false,
3725            false,
3726            false,
3727        );
3728
3729        // Verify no consecutive blank GlobalLines
3730        let mut prev_blank = false;
3731        for elem in &config.elements {
3732            if let ConfigElement::GlobalLine(line) = elem {
3733                let is_blank = line.trim().is_empty();
3734                assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
3735                prev_blank = is_blank;
3736            } else {
3737                prev_blank = false;
3738            }
3739        }
3740    }
3741
3742    // =========================================================================
3743    // Remove without remove_deleted flag does nothing
3744    // =========================================================================
3745
3746    #[test]
3747    fn test_sync_without_remove_flag_keeps_deleted() {
3748        let mut config = empty_config();
3749        let section = make_section();
3750        let remote = vec![ProviderHost::new(
3751            "1".to_string(),
3752            "web".to_string(),
3753            "1.2.3.4".to_string(),
3754            Vec::new(),
3755        )];
3756        sync_provider(
3757            &mut config,
3758            &MockProvider,
3759            &remote,
3760            &section,
3761            false,
3762            false,
3763            false,
3764        );
3765
3766        // Sync without remove_deleted - host 1 gone from remote
3767        let result = sync_provider(
3768            &mut config,
3769            &MockProvider,
3770            &[],
3771            &section,
3772            false,
3773            false,
3774            false,
3775        );
3776        assert_eq!(result.removed, 0);
3777        assert_eq!(config.host_entries().len(), 1); // Still there
3778    }
3779
3780    // =========================================================================
3781    // Dry-run rename doesn't track renames
3782    // =========================================================================
3783
3784    #[test]
3785    fn test_sync_dry_run_rename_no_renames_tracked() {
3786        let mut config = empty_config();
3787        let section = make_section();
3788        let remote = vec![ProviderHost::new(
3789            "1".to_string(),
3790            "old".to_string(),
3791            "1.2.3.4".to_string(),
3792            Vec::new(),
3793        )];
3794        sync_provider(
3795            &mut config,
3796            &MockProvider,
3797            &remote,
3798            &section,
3799            false,
3800            false,
3801            false,
3802        );
3803
3804        let new_section = ProviderSection {
3805            alias_prefix: "ocean".to_string(),
3806            ..section
3807        };
3808        let result = sync_provider(
3809            &mut config,
3810            &MockProvider,
3811            &remote,
3812            &new_section,
3813            false,
3814            false,
3815            true,
3816        );
3817        assert_eq!(result.updated, 1);
3818        // Dry-run: renames vec stays empty since no actual mutation
3819        assert!(result.renames.is_empty());
3820    }
3821
3822    // =========================================================================
3823    // sanitize_name additional edge cases
3824    // =========================================================================
3825
3826    #[test]
3827    fn test_sanitize_name_whitespace_only() {
3828        assert_eq!(sanitize_name("   "), "server");
3829    }
3830
3831    #[test]
3832    fn test_sanitize_name_single_char() {
3833        assert_eq!(sanitize_name("a"), "a");
3834        assert_eq!(sanitize_name("Z"), "z");
3835        assert_eq!(sanitize_name("5"), "5");
3836    }
3837
3838    #[test]
3839    fn test_sanitize_name_single_special_char() {
3840        assert_eq!(sanitize_name("!"), "server");
3841        assert_eq!(sanitize_name("-"), "server");
3842        assert_eq!(sanitize_name("."), "server");
3843    }
3844
3845    #[test]
3846    fn test_sanitize_name_emoji() {
3847        assert_eq!(sanitize_name("server🚀"), "server");
3848        assert_eq!(sanitize_name("🔥hot🔥"), "hot");
3849    }
3850
3851    #[test]
3852    fn test_sanitize_name_long_mixed_separators() {
3853        assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
3854    }
3855
3856    #[test]
3857    fn test_sanitize_name_dots_and_underscores() {
3858        assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
3859    }
3860
3861    // =========================================================================
3862    // find_hosts_by_provider with includes
3863    // =========================================================================
3864
3865    #[test]
3866    fn test_find_hosts_by_provider_in_includes() {
3867        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3868
3869        let include_content =
3870            "Host do-included\n  HostName 1.2.3.4\n  # purple:provider digitalocean:inc1\n";
3871        let included_elements = SshConfigFile::parse_content(include_content);
3872
3873        let config = SshConfigFile {
3874            elements: vec![ConfigElement::Include(IncludeDirective {
3875                raw_line: "Include conf.d/*".to_string(),
3876                pattern: "conf.d/*".to_string(),
3877                resolved_files: vec![IncludedFile {
3878                    path: PathBuf::from("/tmp/included.conf"),
3879                    elements: included_elements,
3880                }],
3881            })],
3882            path: PathBuf::from("/tmp/test_config"),
3883            crlf: false,
3884            bom: false,
3885        };
3886
3887        let hosts = config.find_hosts_by_provider("digitalocean");
3888        assert_eq!(hosts.len(), 1);
3889        assert_eq!(hosts[0].0, "do-included");
3890        assert_eq!(hosts[0].1, "inc1");
3891    }
3892
3893    #[test]
3894    fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
3895        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3896
3897        // Top-level host
3898        let top_content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:1\n";
3899        let top_elements = SshConfigFile::parse_content(top_content);
3900
3901        // Included host
3902        let inc_content = "Host do-db\n  HostName 5.6.7.8\n  # purple:provider digitalocean:2\n";
3903        let inc_elements = SshConfigFile::parse_content(inc_content);
3904
3905        let mut elements = top_elements;
3906        elements.push(ConfigElement::Include(IncludeDirective {
3907            raw_line: "Include conf.d/*".to_string(),
3908            pattern: "conf.d/*".to_string(),
3909            resolved_files: vec![IncludedFile {
3910                path: PathBuf::from("/tmp/included.conf"),
3911                elements: inc_elements,
3912            }],
3913        }));
3914
3915        let config = SshConfigFile {
3916            elements,
3917            path: PathBuf::from("/tmp/test_config"),
3918            crlf: false,
3919            bom: false,
3920        };
3921
3922        let hosts = config.find_hosts_by_provider("digitalocean");
3923        assert_eq!(hosts.len(), 2);
3924    }
3925
3926    #[test]
3927    fn test_find_hosts_by_provider_empty_includes() {
3928        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3929
3930        let config = SshConfigFile {
3931            elements: vec![ConfigElement::Include(IncludeDirective {
3932                raw_line: "Include conf.d/*".to_string(),
3933                pattern: "conf.d/*".to_string(),
3934                resolved_files: vec![IncludedFile {
3935                    path: PathBuf::from("/tmp/empty.conf"),
3936                    elements: vec![],
3937                }],
3938            })],
3939            path: PathBuf::from("/tmp/test_config"),
3940            crlf: false,
3941            bom: false,
3942        };
3943
3944        let hosts = config.find_hosts_by_provider("digitalocean");
3945        assert!(hosts.is_empty());
3946    }
3947
3948    #[test]
3949    fn test_find_hosts_by_provider_wrong_provider_name() {
3950        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:1\n";
3951        let config = SshConfigFile {
3952            elements: SshConfigFile::parse_content(content),
3953            path: PathBuf::from("/tmp/test_config"),
3954            crlf: false,
3955            bom: false,
3956        };
3957
3958        let hosts = config.find_hosts_by_provider("vultr");
3959        assert!(hosts.is_empty());
3960    }
3961
3962    // =========================================================================
3963    // deduplicate_alias_excluding
3964    // =========================================================================
3965
3966    #[test]
3967    fn test_deduplicate_alias_excluding_self() {
3968        // When renaming do-web to do-web (same alias), exclude prevents collision
3969        let content = "Host do-web\n  HostName 1.2.3.4\n";
3970        let config = SshConfigFile {
3971            elements: SshConfigFile::parse_content(content),
3972            path: PathBuf::from("/tmp/test_config"),
3973            crlf: false,
3974            bom: false,
3975        };
3976
3977        let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
3978        assert_eq!(alias, "do-web"); // Self-excluded, no collision
3979    }
3980
3981    #[test]
3982    fn test_deduplicate_alias_excluding_other() {
3983        // do-web exists, exclude is "do-db" (not the colliding one)
3984        let content = "Host do-web\n  HostName 1.2.3.4\n";
3985        let config = SshConfigFile {
3986            elements: SshConfigFile::parse_content(content),
3987            path: PathBuf::from("/tmp/test_config"),
3988            crlf: false,
3989            bom: false,
3990        };
3991
3992        let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
3993        assert_eq!(alias, "do-web-2"); // do-web is taken, do-db doesn't help
3994    }
3995
3996    #[test]
3997    fn test_deduplicate_alias_excluding_chain() {
3998        // do-web and do-web-2 exist, exclude is "do-web"
3999        let content = "Host do-web\n  HostName 1.1.1.1\n\nHost do-web-2\n  HostName 2.2.2.2\n";
4000        let config = SshConfigFile {
4001            elements: SshConfigFile::parse_content(content),
4002            path: PathBuf::from("/tmp/test_config"),
4003            crlf: false,
4004            bom: false,
4005        };
4006
4007        let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
4008        // do-web is excluded, so it's "available" → returns do-web
4009        assert_eq!(alias, "do-web");
4010    }
4011
4012    #[test]
4013    fn test_deduplicate_alias_excluding_none() {
4014        let content = "Host do-web\n  HostName 1.2.3.4\n";
4015        let config = SshConfigFile {
4016            elements: SshConfigFile::parse_content(content),
4017            path: PathBuf::from("/tmp/test_config"),
4018            crlf: false,
4019            bom: false,
4020        };
4021
4022        // None exclude means normal deduplication
4023        let alias = config.deduplicate_alias_excluding("do-web", None);
4024        assert_eq!(alias, "do-web-2");
4025    }
4026
4027    // =========================================================================
4028    // set_host_tags with empty tags
4029    // =========================================================================
4030
4031    #[test]
4032    fn test_set_host_tags_empty_clears_tags() {
4033        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:tags prod,staging\n";
4034        let mut config = SshConfigFile {
4035            elements: SshConfigFile::parse_content(content),
4036            path: PathBuf::from("/tmp/test_config"),
4037            crlf: false,
4038            bom: false,
4039        };
4040
4041        config.set_host_tags("do-web", &[]);
4042        let entries = config.host_entries();
4043        assert!(entries[0].tags.is_empty());
4044    }
4045
4046    #[test]
4047    fn test_set_host_provider_updates_existing() {
4048        let content = "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:old-id\n";
4049        let mut config = SshConfigFile {
4050            elements: SshConfigFile::parse_content(content),
4051            path: PathBuf::from("/tmp/test_config"),
4052            crlf: false,
4053            bom: false,
4054        };
4055
4056        config.set_host_provider("do-web", "digitalocean", "new-id");
4057        let hosts = config.find_hosts_by_provider("digitalocean");
4058        assert_eq!(hosts.len(), 1);
4059        assert_eq!(hosts[0].1, "new-id");
4060    }
4061
4062    // =========================================================================
4063    // Sync with provider hosts in includes (read-only recognized)
4064    // =========================================================================
4065
4066    #[test]
4067    fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
4068        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4069
4070        let include_content =
4071            "Host do-web\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n";
4072        let included_elements = SshConfigFile::parse_content(include_content);
4073
4074        let mut config = SshConfigFile {
4075            elements: vec![ConfigElement::Include(IncludeDirective {
4076                raw_line: "Include conf.d/*".to_string(),
4077                pattern: "conf.d/*".to_string(),
4078                resolved_files: vec![IncludedFile {
4079                    path: PathBuf::from("/tmp/included.conf"),
4080                    elements: included_elements,
4081                }],
4082            })],
4083            path: PathBuf::from("/tmp/test_config"),
4084            crlf: false,
4085            bom: false,
4086        };
4087
4088        let section = make_section();
4089        let remote = vec![ProviderHost::new(
4090            "123".to_string(),
4091            "web".to_string(),
4092            "1.2.3.4".to_string(),
4093            Vec::new(),
4094        )];
4095
4096        let result = sync_provider(
4097            &mut config,
4098            &MockProvider,
4099            &remote,
4100            &section,
4101            false,
4102            false,
4103            false,
4104        );
4105        assert_eq!(result.unchanged, 1);
4106        assert_eq!(result.added, 0);
4107        // The host should NOT be duplicated in main config
4108        let top_hosts = config
4109            .elements
4110            .iter()
4111            .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
4112            .count();
4113        assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
4114    }
4115
4116    // =========================================================================
4117    // Dedup resolves back to the same alias -> counted as unchanged
4118    // =========================================================================
4119
4120    #[test]
4121    fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
4122        let mut config = empty_config();
4123        let section = make_section();
4124
4125        // Add a host with name "web" -> alias "do-web"
4126        let remote = vec![ProviderHost::new(
4127            "1".to_string(),
4128            "web".to_string(),
4129            "1.2.3.4".to_string(),
4130            Vec::new(),
4131        )];
4132        sync_provider(
4133            &mut config,
4134            &MockProvider,
4135            &remote,
4136            &section,
4137            false,
4138            false,
4139            false,
4140        );
4141        assert_eq!(config.host_entries()[0].alias, "do-web");
4142
4143        // Manually add another host "do-new-web" that would collide after rename
4144        let other = vec![ProviderHost::new(
4145            "2".to_string(),
4146            "new-web".to_string(),
4147            "5.5.5.5".to_string(),
4148            Vec::new(),
4149        )];
4150        sync_provider(
4151            &mut config,
4152            &MockProvider,
4153            &other,
4154            &section,
4155            false,
4156            false,
4157            false,
4158        );
4159
4160        // Now rename the remote host "1" to "new-web", but alias "do-new-web" is taken by host "2".
4161        // dedup will produce "do-new-web-2". This is not the same as "do-web" so it IS a rename.
4162        // But let's create a scenario where dedup resolves back:
4163        // Change prefix so expected alias = "do-web" (same as existing)
4164        // This tests the else branch where alias_changed is initially true (prefix changed)
4165        // but dedup resolves to the same alias.
4166        // Actually, let's test it differently: rename where nothing else changes
4167        let remote_same = vec![
4168            ProviderHost::new(
4169                "1".to_string(),
4170                "web".to_string(),
4171                "1.2.3.4".to_string(),
4172                Vec::new(),
4173            ),
4174            ProviderHost::new(
4175                "2".to_string(),
4176                "new-web".to_string(),
4177                "5.5.5.5".to_string(),
4178                Vec::new(),
4179            ),
4180        ];
4181        let result = sync_provider(
4182            &mut config,
4183            &MockProvider,
4184            &remote_same,
4185            &section,
4186            false,
4187            false,
4188            false,
4189        );
4190        // Host "1" was marked stale by the second sync (only "2" in remote),
4191        // so this sync clears the stale mark -> counts as updated.
4192        assert_eq!(result.unchanged, 1);
4193        assert_eq!(result.updated, 1);
4194        assert!(result.renames.is_empty());
4195    }
4196
4197    // =========================================================================
4198    // Orphan server_id: existing_map has alias not found in entries_map
4199    // =========================================================================
4200
4201    #[test]
4202    fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
4203        // When two hosts have the same server name, the second gets a -2 suffix.
4204        // Test that deduplicate_alias handles this correctly.
4205        let mut config = empty_config();
4206        let section = make_section();
4207
4208        let remote = vec![
4209            ProviderHost::new(
4210                "1".to_string(),
4211                "web".to_string(),
4212                "1.1.1.1".to_string(),
4213                Vec::new(),
4214            ),
4215            ProviderHost::new(
4216                "2".to_string(),
4217                "web".to_string(),
4218                "2.2.2.2".to_string(),
4219                Vec::new(),
4220            ),
4221        ];
4222        let result = sync_provider(
4223            &mut config,
4224            &MockProvider,
4225            &remote,
4226            &section,
4227            false,
4228            false,
4229            false,
4230        );
4231        assert_eq!(result.added, 2);
4232
4233        let entries = config.host_entries();
4234        assert_eq!(entries[0].alias, "do-web");
4235        assert_eq!(entries[1].alias, "do-web-2");
4236
4237        // Re-sync: both should be unchanged
4238        let result = sync_provider(
4239            &mut config,
4240            &MockProvider,
4241            &remote,
4242            &section,
4243            false,
4244            false,
4245            false,
4246        );
4247        assert_eq!(result.unchanged, 2);
4248    }
4249
4250    // =========================================================================
4251    // Dry-run remove with included hosts: included hosts NOT counted in remove
4252    // =========================================================================
4253
4254    #[test]
4255    fn test_sync_dry_run_remove_excludes_included_hosts() {
4256        use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4257
4258        let include_content =
4259            "Host do-included\n  HostName 1.1.1.1\n  # purple:provider digitalocean:inc1\n";
4260        let included_elements = SshConfigFile::parse_content(include_content);
4261
4262        // Top-level host
4263        let mut config = SshConfigFile {
4264            elements: vec![ConfigElement::Include(IncludeDirective {
4265                raw_line: "Include conf.d/*".to_string(),
4266                pattern: "conf.d/*".to_string(),
4267                resolved_files: vec![IncludedFile {
4268                    path: PathBuf::from("/tmp/included.conf"),
4269                    elements: included_elements,
4270                }],
4271            })],
4272            path: PathBuf::from("/tmp/test_config"),
4273            crlf: false,
4274            bom: false,
4275        };
4276
4277        // Add a non-included host
4278        let section = make_section();
4279        let remote = vec![ProviderHost::new(
4280            "top1".to_string(),
4281            "toplevel".to_string(),
4282            "2.2.2.2".to_string(),
4283            Vec::new(),
4284        )];
4285        sync_provider(
4286            &mut config,
4287            &MockProvider,
4288            &remote,
4289            &section,
4290            false,
4291            false,
4292            false,
4293        );
4294
4295        // Dry-run with empty remote (both hosts would be "deleted")
4296        // Only the top-level host should be counted, NOT the included one
4297        let result = sync_provider(&mut config, &MockProvider, &[], &section, true, false, true);
4298        assert_eq!(
4299            result.removed, 1,
4300            "Only top-level host counted in dry-run remove"
4301        );
4302    }
4303
4304    // =========================================================================
4305    // Group header: config already has trailing blank (no extra added)
4306    // =========================================================================
4307
4308    #[test]
4309    fn test_sync_group_header_with_existing_trailing_blank() {
4310        let mut config = empty_config();
4311        // Add a pre-existing global line followed by a blank
4312        config
4313            .elements
4314            .push(ConfigElement::GlobalLine("# some comment".to_string()));
4315        config
4316            .elements
4317            .push(ConfigElement::GlobalLine(String::new()));
4318
4319        let section = make_section();
4320        let remote = vec![ProviderHost::new(
4321            "1".to_string(),
4322            "web".to_string(),
4323            "1.2.3.4".to_string(),
4324            Vec::new(),
4325        )];
4326        let result = sync_provider(
4327            &mut config,
4328            &MockProvider,
4329            &remote,
4330            &section,
4331            false,
4332            false,
4333            false,
4334        );
4335        assert_eq!(result.added, 1);
4336
4337        // Count blank lines: there should be exactly one blank line before the group header
4338        // (the pre-existing one), NOT two
4339        let blank_count = config
4340            .elements
4341            .iter()
4342            .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
4343            .count();
4344        assert_eq!(
4345            blank_count, 1,
4346            "No extra blank line when one already exists"
4347        );
4348    }
4349
4350    // =========================================================================
4351    // Adding second host to existing provider: no group header added
4352    // =========================================================================
4353
4354    #[test]
4355    fn test_sync_no_group_header_for_second_host() {
4356        let mut config = empty_config();
4357        let section = make_section();
4358
4359        // First sync: one host, group header added
4360        let remote = vec![ProviderHost::new(
4361            "1".to_string(),
4362            "web".to_string(),
4363            "1.2.3.4".to_string(),
4364            Vec::new(),
4365        )];
4366        sync_provider(
4367            &mut config,
4368            &MockProvider,
4369            &remote,
4370            &section,
4371            false,
4372            false,
4373            false,
4374        );
4375
4376        let header_count_before = config
4377            .elements
4378            .iter()
4379            .filter(
4380                |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4381            )
4382            .count();
4383        assert_eq!(header_count_before, 1);
4384
4385        // Second sync: add another host
4386        let remote2 = vec![
4387            ProviderHost::new(
4388                "1".to_string(),
4389                "web".to_string(),
4390                "1.2.3.4".to_string(),
4391                Vec::new(),
4392            ),
4393            ProviderHost::new(
4394                "2".to_string(),
4395                "db".to_string(),
4396                "5.5.5.5".to_string(),
4397                Vec::new(),
4398            ),
4399        ];
4400        sync_provider(
4401            &mut config,
4402            &MockProvider,
4403            &remote2,
4404            &section,
4405            false,
4406            false,
4407            false,
4408        );
4409
4410        // Still only one group header
4411        let header_count_after = config
4412            .elements
4413            .iter()
4414            .filter(
4415                |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4416            )
4417            .count();
4418        assert_eq!(header_count_after, 1, "No duplicate group header");
4419    }
4420
4421    // =========================================================================
4422    // Duplicate server_id in remote is skipped
4423    // =========================================================================
4424
4425    #[test]
4426    fn test_sync_duplicate_server_id_in_remote_skipped() {
4427        let mut config = empty_config();
4428        let section = make_section();
4429
4430        // Remote with duplicate server_id
4431        let remote = vec![
4432            ProviderHost::new(
4433                "dup".to_string(),
4434                "first".to_string(),
4435                "1.1.1.1".to_string(),
4436                Vec::new(),
4437            ),
4438            ProviderHost::new(
4439                "dup".to_string(),
4440                "second".to_string(),
4441                "2.2.2.2".to_string(),
4442                Vec::new(),
4443            ),
4444        ];
4445        let result = sync_provider(
4446            &mut config,
4447            &MockProvider,
4448            &remote,
4449            &section,
4450            false,
4451            false,
4452            false,
4453        );
4454        assert_eq!(result.added, 1, "Only the first instance is added");
4455        assert_eq!(config.host_entries()[0].alias, "do-first");
4456    }
4457
4458    // =========================================================================
4459    // Empty IP existing host counted as unchanged (no removal)
4460    // =========================================================================
4461
4462    #[test]
4463    fn test_sync_empty_ip_existing_host_counted_unchanged() {
4464        let mut config = empty_config();
4465        let section = make_section();
4466
4467        // Add host
4468        let remote = vec![ProviderHost::new(
4469            "1".to_string(),
4470            "web".to_string(),
4471            "1.2.3.4".to_string(),
4472            Vec::new(),
4473        )];
4474        sync_provider(
4475            &mut config,
4476            &MockProvider,
4477            &remote,
4478            &section,
4479            false,
4480            false,
4481            false,
4482        );
4483
4484        // Re-sync with empty IP (VM stopped)
4485        let remote2 = vec![ProviderHost::new(
4486            "1".to_string(),
4487            "web".to_string(),
4488            String::new(),
4489            Vec::new(),
4490        )];
4491        let result = sync_provider(
4492            &mut config,
4493            &MockProvider,
4494            &remote2,
4495            &section,
4496            false,
4497            false,
4498            true,
4499        );
4500        assert_eq!(result.unchanged, 1);
4501        assert_eq!(result.removed, 0, "Host with empty IP not removed");
4502        assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
4503    }
4504
4505    // =========================================================================
4506    // Reset tags exact comparison (case-insensitive)
4507    // =========================================================================
4508
4509    #[test]
4510    fn test_sync_provider_tags_case_insensitive_no_update() {
4511        let mut config = empty_config();
4512        let section = make_section();
4513
4514        let remote = vec![ProviderHost::new(
4515            "1".to_string(),
4516            "web".to_string(),
4517            "1.2.3.4".to_string(),
4518            vec!["Production".to_string()],
4519        )];
4520        sync_provider(
4521            &mut config,
4522            &MockProvider,
4523            &remote,
4524            &section,
4525            false,
4526            false,
4527            false,
4528        );
4529
4530        // Same tag but different case -> unchanged
4531        let remote2 = vec![ProviderHost::new(
4532            "1".to_string(),
4533            "web".to_string(),
4534            "1.2.3.4".to_string(),
4535            vec!["production".to_string()],
4536        )];
4537        let result = sync_provider(
4538            &mut config,
4539            &MockProvider,
4540            &remote2,
4541            &section,
4542            false,
4543            false,
4544            false,
4545        );
4546        assert_eq!(
4547            result.unchanged, 1,
4548            "Case-insensitive tag match = unchanged"
4549        );
4550    }
4551
4552    // =========================================================================
4553    // Remove deletes group header when all hosts removed
4554    // =========================================================================
4555
4556    #[test]
4557    fn test_sync_remove_cleans_up_group_header() {
4558        let mut config = empty_config();
4559        let section = make_section();
4560
4561        let remote = vec![ProviderHost::new(
4562            "1".to_string(),
4563            "web".to_string(),
4564            "1.2.3.4".to_string(),
4565            Vec::new(),
4566        )];
4567        sync_provider(
4568            &mut config,
4569            &MockProvider,
4570            &remote,
4571            &section,
4572            false,
4573            false,
4574            false,
4575        );
4576
4577        // Verify group header exists
4578        let has_header = config
4579            .elements
4580            .iter()
4581            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4582        assert!(has_header, "Group header present after add");
4583
4584        // Remove all hosts (empty remote + remove_deleted=true, dry_run=false)
4585        let result = sync_provider(
4586            &mut config,
4587            &MockProvider,
4588            &[],
4589            &section,
4590            true,
4591            false,
4592            false,
4593        );
4594        assert_eq!(result.removed, 1);
4595
4596        // Group header should be cleaned up
4597        let has_header_after = config
4598            .elements
4599            .iter()
4600            .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4601        assert!(
4602            !has_header_after,
4603            "Group header removed when all hosts gone"
4604        );
4605    }
4606
4607    // =========================================================================
4608    // Metadata sync tests
4609    // =========================================================================
4610
4611    #[test]
4612    fn test_sync_adds_host_with_metadata() {
4613        let mut config = empty_config();
4614        let section = make_section();
4615        let remote = vec![ProviderHost {
4616            server_id: "1".to_string(),
4617            name: "web".to_string(),
4618            ip: "1.2.3.4".to_string(),
4619            tags: Vec::new(),
4620            metadata: vec![
4621                ("region".to_string(), "nyc3".to_string()),
4622                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4623            ],
4624        }];
4625        let result = sync_provider(
4626            &mut config,
4627            &MockProvider,
4628            &remote,
4629            &section,
4630            false,
4631            false,
4632            false,
4633        );
4634        assert_eq!(result.added, 1);
4635        let entries = config.host_entries();
4636        assert_eq!(entries[0].provider_meta.len(), 2);
4637        assert_eq!(
4638            entries[0].provider_meta[0],
4639            ("region".to_string(), "nyc3".to_string())
4640        );
4641        assert_eq!(
4642            entries[0].provider_meta[1],
4643            ("plan".to_string(), "s-1vcpu-1gb".to_string())
4644        );
4645    }
4646
4647    #[test]
4648    fn test_sync_updates_changed_metadata() {
4649        let mut config = empty_config();
4650        let section = make_section();
4651        let remote = vec![ProviderHost {
4652            server_id: "1".to_string(),
4653            name: "web".to_string(),
4654            ip: "1.2.3.4".to_string(),
4655            tags: Vec::new(),
4656            metadata: vec![("region".to_string(), "nyc3".to_string())],
4657        }];
4658        sync_provider(
4659            &mut config,
4660            &MockProvider,
4661            &remote,
4662            &section,
4663            false,
4664            false,
4665            false,
4666        );
4667
4668        // Update metadata (region changed, plan added)
4669        let remote2 = vec![ProviderHost {
4670            server_id: "1".to_string(),
4671            name: "web".to_string(),
4672            ip: "1.2.3.4".to_string(),
4673            tags: Vec::new(),
4674            metadata: vec![
4675                ("region".to_string(), "sfo3".to_string()),
4676                ("plan".to_string(), "s-2vcpu-2gb".to_string()),
4677            ],
4678        }];
4679        let result = sync_provider(
4680            &mut config,
4681            &MockProvider,
4682            &remote2,
4683            &section,
4684            false,
4685            false,
4686            false,
4687        );
4688        assert_eq!(result.updated, 1);
4689        let entries = config.host_entries();
4690        assert_eq!(entries[0].provider_meta.len(), 2);
4691        assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4692        assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
4693    }
4694
4695    #[test]
4696    fn test_sync_metadata_unchanged_no_update() {
4697        let mut config = empty_config();
4698        let section = make_section();
4699        let remote = vec![ProviderHost {
4700            server_id: "1".to_string(),
4701            name: "web".to_string(),
4702            ip: "1.2.3.4".to_string(),
4703            tags: Vec::new(),
4704            metadata: vec![("region".to_string(), "nyc3".to_string())],
4705        }];
4706        sync_provider(
4707            &mut config,
4708            &MockProvider,
4709            &remote,
4710            &section,
4711            false,
4712            false,
4713            false,
4714        );
4715
4716        // Same metadata again
4717        let result = sync_provider(
4718            &mut config,
4719            &MockProvider,
4720            &remote,
4721            &section,
4722            false,
4723            false,
4724            false,
4725        );
4726        assert_eq!(result.unchanged, 1);
4727        assert_eq!(result.updated, 0);
4728    }
4729
4730    #[test]
4731    fn test_sync_metadata_order_insensitive() {
4732        let mut config = empty_config();
4733        let section = make_section();
4734        let remote = vec![ProviderHost {
4735            server_id: "1".to_string(),
4736            name: "web".to_string(),
4737            ip: "1.2.3.4".to_string(),
4738            tags: Vec::new(),
4739            metadata: vec![
4740                ("region".to_string(), "nyc3".to_string()),
4741                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4742            ],
4743        }];
4744        sync_provider(
4745            &mut config,
4746            &MockProvider,
4747            &remote,
4748            &section,
4749            false,
4750            false,
4751            false,
4752        );
4753
4754        // Same metadata, different order
4755        let remote2 = vec![ProviderHost {
4756            server_id: "1".to_string(),
4757            name: "web".to_string(),
4758            ip: "1.2.3.4".to_string(),
4759            tags: Vec::new(),
4760            metadata: vec![
4761                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4762                ("region".to_string(), "nyc3".to_string()),
4763            ],
4764        }];
4765        let result = sync_provider(
4766            &mut config,
4767            &MockProvider,
4768            &remote2,
4769            &section,
4770            false,
4771            false,
4772            false,
4773        );
4774        assert_eq!(result.unchanged, 1);
4775        assert_eq!(result.updated, 0);
4776    }
4777
4778    #[test]
4779    fn test_sync_metadata_with_rename() {
4780        let mut config = empty_config();
4781        let section = make_section();
4782        let remote = vec![ProviderHost {
4783            server_id: "1".to_string(),
4784            name: "old-name".to_string(),
4785            ip: "1.2.3.4".to_string(),
4786            tags: Vec::new(),
4787            metadata: vec![("region".to_string(), "nyc3".to_string())],
4788        }];
4789        sync_provider(
4790            &mut config,
4791            &MockProvider,
4792            &remote,
4793            &section,
4794            false,
4795            false,
4796            false,
4797        );
4798        assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4799
4800        // Rename + metadata change
4801        let remote2 = vec![ProviderHost {
4802            server_id: "1".to_string(),
4803            name: "new-name".to_string(),
4804            ip: "1.2.3.4".to_string(),
4805            tags: Vec::new(),
4806            metadata: vec![("region".to_string(), "sfo3".to_string())],
4807        }];
4808        let result = sync_provider(
4809            &mut config,
4810            &MockProvider,
4811            &remote2,
4812            &section,
4813            false,
4814            false,
4815            false,
4816        );
4817        assert_eq!(result.updated, 1);
4818        assert!(!result.renames.is_empty());
4819        let entries = config.host_entries();
4820        assert_eq!(entries[0].alias, "do-new-name");
4821        assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4822    }
4823
4824    #[test]
4825    fn test_sync_metadata_dry_run_no_mutation() {
4826        let mut config = empty_config();
4827        let section = make_section();
4828        let remote = vec![ProviderHost {
4829            server_id: "1".to_string(),
4830            name: "web".to_string(),
4831            ip: "1.2.3.4".to_string(),
4832            tags: Vec::new(),
4833            metadata: vec![("region".to_string(), "nyc3".to_string())],
4834        }];
4835        sync_provider(
4836            &mut config,
4837            &MockProvider,
4838            &remote,
4839            &section,
4840            false,
4841            false,
4842            false,
4843        );
4844
4845        // Dry-run with metadata change
4846        let remote2 = vec![ProviderHost {
4847            server_id: "1".to_string(),
4848            name: "web".to_string(),
4849            ip: "1.2.3.4".to_string(),
4850            tags: Vec::new(),
4851            metadata: vec![("region".to_string(), "sfo3".to_string())],
4852        }];
4853        let result = sync_provider(
4854            &mut config,
4855            &MockProvider,
4856            &remote2,
4857            &section,
4858            false,
4859            false,
4860            true,
4861        );
4862        assert_eq!(result.updated, 1);
4863        // Config should still have old metadata
4864        assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4865    }
4866
4867    #[test]
4868    fn test_sync_metadata_only_change_triggers_update() {
4869        let mut config = empty_config();
4870        let section = make_section();
4871        let remote = vec![ProviderHost {
4872            server_id: "1".to_string(),
4873            name: "web".to_string(),
4874            ip: "1.2.3.4".to_string(),
4875            tags: vec!["prod".to_string()],
4876            metadata: vec![("region".to_string(), "nyc3".to_string())],
4877        }];
4878        sync_provider(
4879            &mut config,
4880            &MockProvider,
4881            &remote,
4882            &section,
4883            false,
4884            false,
4885            false,
4886        );
4887
4888        // Only metadata changes (IP, tags, alias all the same)
4889        let remote2 = vec![ProviderHost {
4890            server_id: "1".to_string(),
4891            name: "web".to_string(),
4892            ip: "1.2.3.4".to_string(),
4893            tags: vec!["prod".to_string()],
4894            metadata: vec![
4895                ("region".to_string(), "nyc3".to_string()),
4896                ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4897            ],
4898        }];
4899        let result = sync_provider(
4900            &mut config,
4901            &MockProvider,
4902            &remote2,
4903            &section,
4904            false,
4905            false,
4906            false,
4907        );
4908        assert_eq!(result.updated, 1);
4909        assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
4910    }
4911
4912    // =========================================================================
4913    // Migration and provider_tags edge cases
4914    // =========================================================================
4915
4916    #[test]
4917    fn test_sync_upgrade_migration() {
4918        // Host with old-format tags (no provider_tags line). Provider tags
4919        // mixed into user tags. First sync should create provider_tags and
4920        // clean the user tags.
4921        let content = "\
4922Host do-web-1
4923  HostName 1.2.3.4
4924  User root
4925  # purple:tags prod,us-east,my-custom
4926  # purple:provider digitalocean:123
4927";
4928        let mut config = SshConfigFile {
4929            elements: SshConfigFile::parse_content(content),
4930            path: PathBuf::from("/tmp/test_config"),
4931            crlf: false,
4932            bom: false,
4933        };
4934        let section = make_section();
4935
4936        // Provider returns tags that overlap with user tags
4937        let remote = vec![ProviderHost::new(
4938            "123".to_string(),
4939            "web-1".to_string(),
4940            "1.2.3.4".to_string(),
4941            vec!["prod".to_string(), "us-east".to_string()],
4942        )];
4943
4944        let result = sync_provider(
4945            &mut config,
4946            &MockProvider,
4947            &remote,
4948            &section,
4949            false,
4950            false,
4951            false,
4952        );
4953        // Should detect tags_changed (no provider_tags existed before)
4954        assert_eq!(result.updated, 1);
4955
4956        let entry = &config.host_entries()[0];
4957        // provider_tags should now contain the provider tags
4958        let mut ptags = entry.provider_tags.clone();
4959        ptags.sort();
4960        assert_eq!(ptags, vec!["prod", "us-east"]);
4961
4962        // User tags should have provider tags removed, leaving only "my-custom"
4963        assert_eq!(entry.tags, vec!["my-custom"]);
4964    }
4965
4966    #[test]
4967    fn test_sync_duplicate_user_provider_tag() {
4968        // User manually adds tag "prod" that already exists in provider_tags.
4969        // Next sync with same provider tags (unchanged) should clean the duplicate.
4970        // NOTE: This tests DESIRED behavior. If the current code doesn't clean
4971        // duplicates when tags_changed=false, the test may fail until the fix lands.
4972        let mut config = empty_config();
4973        let section = make_section();
4974
4975        // First sync: add host with provider tag "prod"
4976        let remote = vec![ProviderHost::new(
4977            "123".to_string(),
4978            "web-1".to_string(),
4979            "1.2.3.4".to_string(),
4980            vec!["prod".to_string()],
4981        )];
4982        sync_provider(
4983            &mut config,
4984            &MockProvider,
4985            &remote,
4986            &section,
4987            false,
4988            false,
4989            false,
4990        );
4991        assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
4992
4993        // User manually adds "prod" to user tags (simulating TUI tag edit)
4994        config.set_host_tags("do-web-1", &["prod".to_string(), "custom".to_string()]);
4995        assert_eq!(config.host_entries()[0].tags, vec!["prod", "custom"]);
4996
4997        // Second sync: same provider tags (unchanged)
4998        sync_provider(
4999            &mut config,
5000            &MockProvider,
5001            &remote,
5002            &section,
5003            false,
5004            false,
5005            false,
5006        );
5007
5008        // Desired: duplicate "prod" removed from user tags
5009        let entry = &config.host_entries()[0];
5010        assert!(
5011            !entry.tags.contains(&"prod".to_string()),
5012            "User tag 'prod' should be cleaned since it duplicates a provider tag"
5013        );
5014        assert!(
5015            entry.tags.contains(&"custom".to_string()),
5016            "User tag 'custom' should be preserved"
5017        );
5018        // provider_tags unchanged
5019        assert_eq!(entry.provider_tags, vec!["prod"]);
5020    }
5021
5022    #[test]
5023    fn test_sync_set_provider_tags_empty_writes_sentinel() {
5024        // Calling set_provider_tags(&[]) should write an empty sentinel comment
5025        let content = "\
5026Host do-web-1
5027  HostName 1.2.3.4
5028  # purple:provider_tags prod
5029  # purple:provider digitalocean:123
5030";
5031        let mut config = SshConfigFile {
5032            elements: SshConfigFile::parse_content(content),
5033            path: PathBuf::from("/tmp/test_config"),
5034            crlf: false,
5035            bom: false,
5036        };
5037
5038        // Clear provider tags via the model
5039        config.set_host_provider_tags("do-web-1", &[]);
5040
5041        let serialized = config.serialize();
5042        assert!(
5043            serialized.contains("# purple:provider_tags"),
5044            "empty sentinel should exist. Got:\n{}",
5045            serialized
5046        );
5047        assert!(
5048            !serialized.contains("# purple:provider_tags "),
5049            "sentinel should have no trailing content. Got:\n{}",
5050            serialized
5051        );
5052        // Host block should still exist
5053        assert!(serialized.contains("Host do-web-1"));
5054        assert!(serialized.contains("purple:provider digitalocean:123"));
5055    }
5056
5057    #[test]
5058    fn test_sync_set_provider_does_not_clobber_provider_tags() {
5059        // Updating the provider marker should not remove provider_tags
5060        let content = "\
5061Host do-web-1
5062  HostName 1.2.3.4
5063  # purple:provider digitalocean:123
5064  # purple:provider_tags prod
5065";
5066        let mut config = SshConfigFile {
5067            elements: SshConfigFile::parse_content(content),
5068            path: PathBuf::from("/tmp/test_config"),
5069            crlf: false,
5070            bom: false,
5071        };
5072
5073        // Update provider marker (e.g. server_id changed)
5074        config.set_host_provider("do-web-1", "digitalocean", "456");
5075
5076        let serialized = config.serialize();
5077        assert!(
5078            serialized.contains("# purple:provider_tags prod"),
5079            "provider_tags should survive set_provider. Got:\n{}",
5080            serialized
5081        );
5082        assert!(
5083            serialized.contains("# purple:provider digitalocean:456"),
5084            "provider marker should be updated. Got:\n{}",
5085            serialized
5086        );
5087    }
5088
5089    #[test]
5090    fn test_sync_provider_tags_roundtrip() {
5091        // Parse -> serialize -> reparse should preserve provider_tags
5092        let content = "\
5093Host do-web-1
5094  HostName 1.2.3.4
5095  User root
5096  # purple:provider_tags prod,us-east
5097  # purple:provider digitalocean:123
5098";
5099        let config = SshConfigFile {
5100            elements: SshConfigFile::parse_content(content),
5101            path: PathBuf::from("/tmp/test_config"),
5102            crlf: false,
5103            bom: false,
5104        };
5105
5106        // Verify initial parse
5107        let entries = config.host_entries();
5108        assert_eq!(entries.len(), 1);
5109        let mut ptags = entries[0].provider_tags.clone();
5110        ptags.sort();
5111        assert_eq!(ptags, vec!["prod", "us-east"]);
5112
5113        // Serialize and reparse
5114        let serialized = config.serialize();
5115        let config2 = SshConfigFile {
5116            elements: SshConfigFile::parse_content(&serialized),
5117            path: PathBuf::from("/tmp/test_config"),
5118            crlf: false,
5119            bom: false,
5120        };
5121
5122        let entries2 = config2.host_entries();
5123        assert_eq!(entries2.len(), 1);
5124        let mut ptags2 = entries2[0].provider_tags.clone();
5125        ptags2.sort();
5126        assert_eq!(ptags2, vec!["prod", "us-east"]);
5127    }
5128
5129    #[test]
5130    fn test_sync_first_migration_empty_remote_writes_sentinel() {
5131        // Old-format host: has # purple:tags but no # purple:provider_tags
5132        let mut config = SshConfigFile {
5133            elements: SshConfigFile::parse_content(
5134                "Host do-web-1\n  HostName 1.2.3.4\n  # purple:provider digitalocean:123\n  # purple:tags prod\n",
5135            ),
5136            path: PathBuf::from("/tmp/test_config"),
5137            crlf: false,
5138            bom: false,
5139        };
5140        let section = make_section();
5141
5142        // Verify: no provider_tags comment yet
5143        let entries = config.host_entries();
5144        assert!(!entries[0].has_provider_tags);
5145        assert_eq!(entries[0].tags, vec!["prod"]);
5146
5147        // First sync: provider returns empty tags
5148        let remote = vec![ProviderHost::new(
5149            "123".to_string(),
5150            "web-1".to_string(),
5151            "1.2.3.4".to_string(),
5152            Vec::new(),
5153        )];
5154        let result = sync_provider(
5155            &mut config,
5156            &MockProvider,
5157            &remote,
5158            &section,
5159            false,
5160            false,
5161            false,
5162        );
5163        assert_eq!(result.updated, 1);
5164
5165        // Verify: provider_tags sentinel written (has_provider_tags=true, but empty)
5166        let entries = config.host_entries();
5167        assert!(entries[0].has_provider_tags);
5168        assert!(entries[0].provider_tags.is_empty());
5169        // User tag "prod" preserved (no overlap with empty remote)
5170        assert_eq!(entries[0].tags, vec!["prod"]);
5171
5172        // Second sync: same empty tags. Now first_migration=false (has_provider_tags=true).
5173        // Nothing changed, so host should be unchanged.
5174        let result2 = sync_provider(
5175            &mut config,
5176            &MockProvider,
5177            &remote,
5178            &section,
5179            false,
5180            false,
5181            false,
5182        );
5183        assert_eq!(result2.unchanged, 1);
5184    }
5185
5186    // =========================================================================
5187    // Stale marking tests
5188    // =========================================================================
5189
5190    #[test]
5191    fn test_sync_marks_stale_when_host_disappears() {
5192        let mut config = empty_config();
5193        let section = make_section();
5194        let remote = vec![ProviderHost::new(
5195            "1".to_string(),
5196            "web".to_string(),
5197            "1.2.3.4".to_string(),
5198            Vec::new(),
5199        )];
5200        sync_provider(
5201            &mut config,
5202            &MockProvider,
5203            &remote,
5204            &section,
5205            false,
5206            false,
5207            false,
5208        );
5209        assert_eq!(config.host_entries().len(), 1);
5210
5211        // Host disappears
5212        let result = sync_provider(
5213            &mut config,
5214            &MockProvider,
5215            &[],
5216            &section,
5217            false,
5218            false,
5219            false,
5220        );
5221        assert_eq!(result.stale, 1);
5222        assert_eq!(result.removed, 0);
5223        let entries = config.host_entries();
5224        assert_eq!(entries.len(), 1);
5225        assert!(entries[0].stale.is_some());
5226    }
5227
5228    #[test]
5229    fn test_sync_clears_stale_when_host_returns() {
5230        let mut config = empty_config();
5231        let section = make_section();
5232        let remote = vec![ProviderHost::new(
5233            "1".to_string(),
5234            "web".to_string(),
5235            "1.2.3.4".to_string(),
5236            Vec::new(),
5237        )];
5238        sync_provider(
5239            &mut config,
5240            &MockProvider,
5241            &remote,
5242            &section,
5243            false,
5244            false,
5245            false,
5246        );
5247
5248        // Host disappears -> marked stale
5249        sync_provider(
5250            &mut config,
5251            &MockProvider,
5252            &[],
5253            &section,
5254            false,
5255            false,
5256            false,
5257        );
5258        assert!(config.host_entries()[0].stale.is_some());
5259
5260        // Host returns -> stale cleared
5261        let result = sync_provider(
5262            &mut config,
5263            &MockProvider,
5264            &remote,
5265            &section,
5266            false,
5267            false,
5268            false,
5269        );
5270        assert_eq!(result.updated, 1);
5271        assert!(config.host_entries()[0].stale.is_none());
5272    }
5273
5274    #[test]
5275    fn test_sync_stale_timestamp_preserved_not_refreshed() {
5276        let mut config = empty_config();
5277        let section = make_section();
5278        let remote = vec![ProviderHost::new(
5279            "1".to_string(),
5280            "web".to_string(),
5281            "1.2.3.4".to_string(),
5282            Vec::new(),
5283        )];
5284        sync_provider(
5285            &mut config,
5286            &MockProvider,
5287            &remote,
5288            &section,
5289            false,
5290            false,
5291            false,
5292        );
5293
5294        // Mark stale
5295        sync_provider(
5296            &mut config,
5297            &MockProvider,
5298            &[],
5299            &section,
5300            false,
5301            false,
5302            false,
5303        );
5304        let ts1 = config.host_entries()[0].stale.unwrap();
5305
5306        // Another sync with host still missing - timestamp should not change
5307        sync_provider(
5308            &mut config,
5309            &MockProvider,
5310            &[],
5311            &section,
5312            false,
5313            false,
5314            false,
5315        );
5316        let ts2 = config.host_entries()[0].stale.unwrap();
5317        assert_eq!(ts1, ts2);
5318    }
5319
5320    #[test]
5321    fn test_sync_stale_host_returns_with_new_ip() {
5322        let mut config = empty_config();
5323        let section = make_section();
5324        let remote = vec![ProviderHost::new(
5325            "1".to_string(),
5326            "web".to_string(),
5327            "1.2.3.4".to_string(),
5328            Vec::new(),
5329        )];
5330        sync_provider(
5331            &mut config,
5332            &MockProvider,
5333            &remote,
5334            &section,
5335            false,
5336            false,
5337            false,
5338        );
5339
5340        // Host disappears
5341        sync_provider(
5342            &mut config,
5343            &MockProvider,
5344            &[],
5345            &section,
5346            false,
5347            false,
5348            false,
5349        );
5350
5351        // Host returns with new IP
5352        let remote_new = vec![ProviderHost::new(
5353            "1".to_string(),
5354            "web".to_string(),
5355            "9.9.9.9".to_string(),
5356            Vec::new(),
5357        )];
5358        let result = sync_provider(
5359            &mut config,
5360            &MockProvider,
5361            &remote_new,
5362            &section,
5363            false,
5364            false,
5365            false,
5366        );
5367        assert_eq!(result.updated, 1);
5368        let entries = config.host_entries();
5369        assert!(entries[0].stale.is_none());
5370        assert_eq!(entries[0].hostname, "9.9.9.9");
5371    }
5372
5373    #[test]
5374    fn test_sync_remove_deleted_still_hard_deletes() {
5375        let mut config = empty_config();
5376        let section = make_section();
5377        let remote = vec![ProviderHost::new(
5378            "1".to_string(),
5379            "web".to_string(),
5380            "1.2.3.4".to_string(),
5381            Vec::new(),
5382        )];
5383        sync_provider(
5384            &mut config,
5385            &MockProvider,
5386            &remote,
5387            &section,
5388            false,
5389            false,
5390            false,
5391        );
5392
5393        // With remove_deleted=true, host is hard-deleted, not stale
5394        let result = sync_provider(
5395            &mut config,
5396            &MockProvider,
5397            &[],
5398            &section,
5399            true,
5400            false,
5401            false,
5402        );
5403        assert_eq!(result.removed, 1);
5404        assert_eq!(result.stale, 0);
5405        assert!(config.host_entries().is_empty());
5406    }
5407
5408    #[test]
5409    fn test_sync_partial_failure_no_stale_marking() {
5410        let mut config = empty_config();
5411        let section = make_section();
5412        let remote = vec![ProviderHost::new(
5413            "1".to_string(),
5414            "web".to_string(),
5415            "1.2.3.4".to_string(),
5416            Vec::new(),
5417        )];
5418        sync_provider(
5419            &mut config,
5420            &MockProvider,
5421            &remote,
5422            &section,
5423            false,
5424            false,
5425            false,
5426        );
5427
5428        // Partial failure: suppress_stale=true
5429        let result = sync_provider(
5430            &mut config,
5431            &MockProvider,
5432            &[],
5433            &section,
5434            false,
5435            true,
5436            false,
5437        );
5438        assert_eq!(result.stale, 0);
5439        assert!(config.host_entries()[0].stale.is_none());
5440    }
5441
5442    #[test]
5443    fn test_sync_dry_run_reports_stale_count() {
5444        let mut config = empty_config();
5445        let section = make_section();
5446        let remote = vec![ProviderHost::new(
5447            "1".to_string(),
5448            "web".to_string(),
5449            "1.2.3.4".to_string(),
5450            Vec::new(),
5451        )];
5452        sync_provider(
5453            &mut config,
5454            &MockProvider,
5455            &remote,
5456            &section,
5457            false,
5458            false,
5459            false,
5460        );
5461
5462        // Dry run: stale count reported but no mutation
5463        let result = sync_provider(
5464            &mut config,
5465            &MockProvider,
5466            &[],
5467            &section,
5468            false,
5469            false,
5470            true,
5471        );
5472        assert_eq!(result.stale, 1);
5473        assert!(config.host_entries()[0].stale.is_none()); // Not actually marked
5474    }
5475
5476    #[test]
5477    fn test_sync_top_level_host_marked_stale() {
5478        // A top-level provider host that disappears should be marked stale
5479        let config_str = "\
5480Host do-web
5481  HostName 1.2.3.4
5482  # purple:provider digitalocean:1
5483";
5484        let mut config = SshConfigFile {
5485            elements: SshConfigFile::parse_content(config_str),
5486            path: PathBuf::from("/tmp/test_config"),
5487            crlf: false,
5488            bom: false,
5489        };
5490        let section = make_section();
5491        let result = sync_provider(
5492            &mut config,
5493            &MockProvider,
5494            &[],
5495            &section,
5496            false,
5497            false,
5498            false,
5499        );
5500        assert_eq!(result.stale, 1);
5501    }
5502
5503    #[test]
5504    fn test_sync_multiple_hosts_disappear() {
5505        let mut config = empty_config();
5506        let section = make_section();
5507        let remote = vec![
5508            ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5509            ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5510            ProviderHost::new("3".into(), "app".into(), "3.3.3.3".into(), Vec::new()),
5511        ];
5512        sync_provider(
5513            &mut config,
5514            &MockProvider,
5515            &remote,
5516            &section,
5517            false,
5518            false,
5519            false,
5520        );
5521        assert_eq!(config.host_entries().len(), 3);
5522
5523        // Only host "2" remains
5524        let remaining = vec![ProviderHost::new(
5525            "2".into(),
5526            "db".into(),
5527            "2.2.2.2".into(),
5528            Vec::new(),
5529        )];
5530        let result = sync_provider(
5531            &mut config,
5532            &MockProvider,
5533            &remaining,
5534            &section,
5535            false,
5536            false,
5537            false,
5538        );
5539        assert_eq!(result.stale, 2);
5540        assert_eq!(result.unchanged, 1);
5541        let entries = config.host_entries();
5542        assert!(
5543            entries
5544                .iter()
5545                .find(|e| e.alias == "do-web")
5546                .unwrap()
5547                .stale
5548                .is_some()
5549        );
5550        assert!(
5551            entries
5552                .iter()
5553                .find(|e| e.alias == "do-db")
5554                .unwrap()
5555                .stale
5556                .is_none()
5557        );
5558        assert!(
5559            entries
5560                .iter()
5561                .find(|e| e.alias == "do-app")
5562                .unwrap()
5563                .stale
5564                .is_some()
5565        );
5566    }
5567
5568    #[test]
5569    fn test_sync_already_stale_then_remove_deleted() {
5570        let mut config = empty_config();
5571        let section = make_section();
5572        let remote = vec![ProviderHost::new(
5573            "1".into(),
5574            "web".into(),
5575            "1.1.1.1".into(),
5576            Vec::new(),
5577        )];
5578        sync_provider(
5579            &mut config,
5580            &MockProvider,
5581            &remote,
5582            &section,
5583            false,
5584            false,
5585            false,
5586        );
5587
5588        // Mark stale
5589        sync_provider(
5590            &mut config,
5591            &MockProvider,
5592            &[],
5593            &section,
5594            false,
5595            false,
5596            false,
5597        );
5598        assert!(config.host_entries()[0].stale.is_some());
5599
5600        // Hard delete with remove_deleted=true
5601        let result = sync_provider(
5602            &mut config,
5603            &MockProvider,
5604            &[],
5605            &section,
5606            true,
5607            false,
5608            false,
5609        );
5610        assert_eq!(result.removed, 1);
5611        assert!(config.host_entries().is_empty());
5612    }
5613
5614    #[test]
5615    fn test_sync_stale_cross_provider_isolation() {
5616        let mut config = empty_config();
5617        let do_section = make_section();
5618        let vultr_section = ProviderSection {
5619            alias_prefix: "vultr".to_string(),
5620            ..make_section()
5621        };
5622
5623        // Add DO host
5624        let do_remote = vec![ProviderHost::new(
5625            "1".into(),
5626            "web".into(),
5627            "1.1.1.1".into(),
5628            Vec::new(),
5629        )];
5630        sync_provider(
5631            &mut config,
5632            &MockProvider,
5633            &do_remote,
5634            &do_section,
5635            false,
5636            false,
5637            false,
5638        );
5639
5640        // Add Vultr host
5641        let vultr_remote = vec![ProviderHost::new(
5642            "1".into(),
5643            "db".into(),
5644            "2.2.2.2".into(),
5645            Vec::new(),
5646        )];
5647        sync_provider(
5648            &mut config,
5649            &MockProvider2,
5650            &vultr_remote,
5651            &vultr_section,
5652            false,
5653            false,
5654            false,
5655        );
5656
5657        // DO host disappears - Vultr host should NOT be affected
5658        let result = sync_provider(
5659            &mut config,
5660            &MockProvider,
5661            &[],
5662            &do_section,
5663            false,
5664            false,
5665            false,
5666        );
5667        assert_eq!(result.stale, 1);
5668        let entries = config.host_entries();
5669        assert!(
5670            entries
5671                .iter()
5672                .find(|e| e.alias == "do-web")
5673                .unwrap()
5674                .stale
5675                .is_some()
5676        );
5677        assert!(
5678            entries
5679                .iter()
5680                .find(|e| e.alias == "vultr-db")
5681                .unwrap()
5682                .stale
5683                .is_none()
5684        );
5685    }
5686
5687    #[test]
5688    fn test_sync_stale_host_returns_with_tag_changes() {
5689        let mut config = empty_config();
5690        let section = make_section();
5691        let remote = vec![ProviderHost::new(
5692            "1".into(),
5693            "web".into(),
5694            "1.1.1.1".into(),
5695            vec!["prod".into()],
5696        )];
5697        sync_provider(
5698            &mut config,
5699            &MockProvider,
5700            &remote,
5701            &section,
5702            false,
5703            false,
5704            false,
5705        );
5706
5707        // Mark stale
5708        sync_provider(
5709            &mut config,
5710            &MockProvider,
5711            &[],
5712            &section,
5713            false,
5714            false,
5715            false,
5716        );
5717        assert!(config.host_entries()[0].stale.is_some());
5718
5719        // Return with different tags
5720        let remote_new = vec![ProviderHost::new(
5721            "1".into(),
5722            "web".into(),
5723            "1.1.1.1".into(),
5724            vec!["staging".into()],
5725        )];
5726        let result = sync_provider(
5727            &mut config,
5728            &MockProvider,
5729            &remote_new,
5730            &section,
5731            false,
5732            false,
5733            false,
5734        );
5735        assert_eq!(result.updated, 1);
5736        let entries = config.host_entries();
5737        assert!(entries[0].stale.is_none());
5738        assert!(entries[0].provider_tags.contains(&"staging".to_string()));
5739    }
5740
5741    #[test]
5742    fn test_sync_stale_result_count_includes_already_stale() {
5743        let mut config = empty_config();
5744        let section = make_section();
5745        let remote = vec![ProviderHost::new(
5746            "1".into(),
5747            "web".into(),
5748            "1.2.3.4".into(),
5749            Vec::new(),
5750        )];
5751        sync_provider(
5752            &mut config,
5753            &MockProvider,
5754            &remote,
5755            &section,
5756            false,
5757            false,
5758            false,
5759        );
5760
5761        // First disappearance
5762        let r1 = sync_provider(
5763            &mut config,
5764            &MockProvider,
5765            &[],
5766            &section,
5767            false,
5768            false,
5769            false,
5770        );
5771        assert_eq!(r1.stale, 1);
5772
5773        // Second disappearance - still counted
5774        let r2 = sync_provider(
5775            &mut config,
5776            &MockProvider,
5777            &[],
5778            &section,
5779            false,
5780            false,
5781            false,
5782        );
5783        assert_eq!(r2.stale, 1);
5784    }
5785
5786    // =========================================================================
5787    // SSH config integrity: stale must never corrupt the config
5788    // =========================================================================
5789
5790    #[test]
5791    fn test_sync_stale_config_byte_identical_after_clear() {
5792        let mut config = empty_config();
5793        let section = make_section();
5794        let remote = vec![
5795            ProviderHost::new(
5796                "1".into(),
5797                "web".into(),
5798                "1.1.1.1".into(),
5799                vec!["prod".into()],
5800            ),
5801            ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5802        ];
5803        // Add hosts
5804        sync_provider(
5805            &mut config,
5806            &MockProvider,
5807            &remote,
5808            &section,
5809            false,
5810            false,
5811            false,
5812        );
5813        let config_after_add = config.serialize();
5814
5815        // Mark stale (all hosts disappear)
5816        sync_provider(
5817            &mut config,
5818            &MockProvider,
5819            &[],
5820            &section,
5821            false,
5822            false,
5823            false,
5824        );
5825        let config_after_stale = config.serialize();
5826        assert_ne!(config_after_stale, config_after_add);
5827
5828        // Hosts return (clear stale)
5829        sync_provider(
5830            &mut config,
5831            &MockProvider,
5832            &remote,
5833            &section,
5834            false,
5835            false,
5836            false,
5837        );
5838        let config_after_return = config.serialize();
5839        assert_eq!(
5840            config_after_return, config_after_add,
5841            "config must be byte-identical after stale->return cycle"
5842        );
5843    }
5844
5845    #[test]
5846    fn test_sync_stale_preserves_neighboring_hosts() {
5847        let config_str = "\
5848Host manual
5849  HostName 10.0.0.1
5850  User admin
5851
5852";
5853        let mut config = SshConfigFile {
5854            elements: SshConfigFile::parse_content(config_str),
5855            path: PathBuf::from("/tmp/test_config"),
5856            crlf: false,
5857            bom: false,
5858        };
5859        let section = make_section();
5860        let remote = vec![ProviderHost::new(
5861            "1".into(),
5862            "web".into(),
5863            "1.1.1.1".into(),
5864            Vec::new(),
5865        )];
5866        sync_provider(
5867            &mut config,
5868            &MockProvider,
5869            &remote,
5870            &section,
5871            false,
5872            false,
5873            false,
5874        );
5875
5876        // Manual host must survive stale marking
5877        sync_provider(
5878            &mut config,
5879            &MockProvider,
5880            &[],
5881            &section,
5882            false,
5883            false,
5884            false,
5885        );
5886        let output = config.serialize();
5887        assert!(
5888            output.contains("Host manual"),
5889            "manual host lost after stale marking"
5890        );
5891        assert!(
5892            output.contains("HostName 10.0.0.1"),
5893            "manual host directives lost after stale marking"
5894        );
5895        assert!(
5896            output.contains("User admin"),
5897            "manual host user lost after stale marking"
5898        );
5899    }
5900
5901    #[test]
5902    fn test_sync_stale_then_purge_leaves_clean_config() {
5903        let config_str = "\
5904Host manual
5905  HostName 10.0.0.1
5906  User admin
5907
5908";
5909        let mut config = SshConfigFile {
5910            elements: SshConfigFile::parse_content(config_str),
5911            path: PathBuf::from("/tmp/test_config"),
5912            crlf: false,
5913            bom: false,
5914        };
5915        let section = make_section();
5916        let remote = vec![
5917            ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5918            ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5919        ];
5920        sync_provider(
5921            &mut config,
5922            &MockProvider,
5923            &remote,
5924            &section,
5925            false,
5926            false,
5927            false,
5928        );
5929
5930        // Mark all provider hosts stale
5931        sync_provider(
5932            &mut config,
5933            &MockProvider,
5934            &[],
5935            &section,
5936            false,
5937            false,
5938            false,
5939        );
5940
5941        // Simulate purge: delete all stale hosts
5942        let stale = config.stale_hosts();
5943        for (alias, _) in &stale {
5944            config.delete_host(alias);
5945        }
5946
5947        let output = config.serialize();
5948        // Manual host must be intact
5949        assert!(output.contains("Host manual"));
5950        assert!(output.contains("HostName 10.0.0.1"));
5951        // No stale comments remaining
5952        assert!(!output.contains("purple:stale"));
5953        // No orphan provider group headers
5954        assert!(!output.contains("purple:group"));
5955        // No excessive blank lines (3+ consecutive)
5956        assert!(
5957            !output.contains("\n\n\n"),
5958            "excessive blank lines after purge:\n{}",
5959            output
5960        );
5961    }
5962
5963    #[test]
5964    fn test_sync_stale_empty_ip_return_preserves_hostname() {
5965        let mut config = empty_config();
5966        let section = make_section();
5967        let remote = vec![ProviderHost::new(
5968            "1".into(),
5969            "web".into(),
5970            "1.1.1.1".into(),
5971            Vec::new(),
5972        )];
5973        sync_provider(
5974            &mut config,
5975            &MockProvider,
5976            &remote,
5977            &section,
5978            false,
5979            false,
5980            false,
5981        );
5982
5983        // Host disappears -> stale
5984        sync_provider(
5985            &mut config,
5986            &MockProvider,
5987            &[],
5988            &section,
5989            false,
5990            false,
5991            false,
5992        );
5993        assert!(config.host_entries()[0].stale.is_some());
5994
5995        // Host returns with empty IP (stopped VM)
5996        let remote_empty_ip = vec![ProviderHost::new(
5997            "1".into(),
5998            "web".into(),
5999            "".into(),
6000            Vec::new(),
6001        )];
6002        let result = sync_provider(
6003            &mut config,
6004            &MockProvider,
6005            &remote_empty_ip,
6006            &section,
6007            false,
6008            false,
6009            false,
6010        );
6011        assert_eq!(result.updated, 1);
6012        // Stale cleared
6013        assert!(config.host_entries()[0].stale.is_none());
6014        // Hostname must NOT be wiped
6015        assert_eq!(config.host_entries()[0].hostname, "1.1.1.1");
6016    }
6017
6018    #[test]
6019    fn test_sync_insert_adds_blank_line_before_next_group() {
6020        // Simulate: DO has 1 host, Hetzner group follows. Adding a 2nd DO host
6021        // must produce a blank line between the new host and the Hetzner header.
6022        let config_str = "\
6023# purple:group DigitalOcean
6024
6025Host do-web
6026  HostName 1.1.1.1
6027  User root
6028  # purple:provider digitalocean:111
6029
6030# purple:group Hetzner
6031
6032Host hz-build
6033  HostName 2.2.2.2
6034  User ci
6035  # purple:provider hetzner:222
6036";
6037        let mut config = SshConfigFile {
6038            elements: SshConfigFile::parse_content(config_str),
6039            path: PathBuf::from("/tmp/test_config"),
6040            crlf: false,
6041            bom: false,
6042        };
6043        let section = make_section();
6044        let remote = vec![
6045            ProviderHost::new("111".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
6046            ProviderHost::new("333".into(), "db".into(), "3.3.3.3".into(), Vec::new()),
6047        ];
6048        sync_provider(
6049            &mut config,
6050            &MockProvider,
6051            &remote,
6052            &section,
6053            false,
6054            false,
6055            false,
6056        );
6057        let output = config.serialize();
6058        // There must be a blank line between the last DO host and the Hetzner group
6059        assert!(
6060            output.contains("\n\n# purple:group Hetzner"),
6061            "missing blank line before next group header:\n{}",
6062            output
6063        );
6064        // No triple blank lines
6065        assert!(
6066            !output.contains("\n\n\n"),
6067            "triple blank lines found:\n{}",
6068            output
6069        );
6070    }
6071
6072    #[test]
6073    fn test_sync_insert_blank_line_real_world_scenario() {
6074        // Exact reproduction of user-reported bug: DO host without trailing
6075        // blank directly followed by # purple:group Proxmox VE. Adding a new
6076        // DO host via sync must not smash it against the Proxmox header.
6077        let config_str = "\
6078# purple:group DigitalOcean
6079
6080Host do-signalproxy
6081  HostName 128.199.41.235
6082  User root
6083  IdentityFile ~/.ssh/id_ed25519
6084  # purple:provider digitalocean:517532225
6085  # purple:meta region=ams3,size=s-1vcpu-512mb-10gb,status=active
6086  Port 60022
6087  # purple:provider_tags
6088  # purple:tags signal
6089# purple:group Proxmox VE
6090
6091Host pve-testvm
6092  HostName 192.168.1.100
6093  User root
6094  # purple:provider proxmox:100
6095";
6096        let mut config = SshConfigFile {
6097            elements: SshConfigFile::parse_content(config_str),
6098            path: PathBuf::from("/tmp/test_config"),
6099            crlf: false,
6100            bom: false,
6101        };
6102        let section = make_section();
6103        // Sync DO with the existing host + a new one
6104        let remote = vec![
6105            ProviderHost::new(
6106                "517532225".into(),
6107                "signalproxy-nl".into(),
6108                "128.199.41.235".into(),
6109                Vec::new(),
6110            ),
6111            ProviderHost::new(
6112                "560734563".into(),
6113                "ubuntu-nyc1".into(),
6114                "167.172.128.123".into(),
6115                Vec::new(),
6116            ),
6117        ];
6118        sync_provider(
6119            &mut config,
6120            &MockProvider,
6121            &remote,
6122            &section,
6123            false,
6124            false,
6125            false,
6126        );
6127        let output = config.serialize();
6128
6129        // The new DO host must have a blank line before the Proxmox group header
6130        assert!(
6131            output.contains("\n\n# purple:group Proxmox VE"),
6132            "missing blank line before Proxmox group:\n{}",
6133            output
6134        );
6135        // Both DO hosts must be present
6136        assert!(
6137            output.contains("Host do-signalproxy") || output.contains("Host do-signalproxy-nl")
6138        );
6139        assert!(output.contains("Host do-ubuntu-nyc1"));
6140        // Proxmox host must still be present
6141        assert!(output.contains("Host pve-testvm"));
6142        // No triple blank lines
6143        assert!(
6144            !output.contains("\n\n\n"),
6145            "triple blank lines:\n{}",
6146            output
6147        );
6148    }
6149}