Skip to main content

purple_ssh/providers/
sync.rs

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