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