1use std::collections::HashMap;
2
3use crate::ssh_config::model::{ConfigElement, HostEntry, SshConfigFile};
4
5use super::config::ProviderSection;
6use super::{Provider, ProviderHost};
7
8#[derive(Debug, Default)]
10pub struct SyncResult {
11 pub added: usize,
12 pub updated: usize,
13 pub removed: usize,
14 pub unchanged: usize,
15}
16
17fn sanitize_name(name: &str) -> String {
21 let mut result = String::new();
22 for c in name.chars() {
23 if c.is_ascii_alphanumeric() {
24 result.push(c.to_ascii_lowercase());
25 } else if !result.ends_with('-') {
26 result.push('-');
27 }
28 }
29 let trimmed = result.trim_matches('-').to_string();
30 if trimmed.is_empty() {
31 "server".to_string()
32 } else {
33 trimmed
34 }
35}
36
37fn build_alias(prefix: &str, sanitized: &str) -> String {
40 if prefix.is_empty() {
41 sanitized.to_string()
42 } else {
43 format!("{}-{}", prefix, sanitized)
44 }
45}
46
47
48pub fn sync_provider(
50 config: &mut SshConfigFile,
51 provider: &dyn Provider,
52 remote_hosts: &[ProviderHost],
53 section: &ProviderSection,
54 remove_deleted: bool,
55 dry_run: bool,
56) -> SyncResult {
57 let mut result = SyncResult::default();
58
59 let existing = config.find_hosts_by_provider(provider.name());
62 let mut existing_map: HashMap<String, String> = HashMap::new();
63 for (alias, server_id) in &existing {
64 existing_map
65 .entry(server_id.clone())
66 .or_insert_with(|| alias.clone());
67 }
68
69 let entries_map: HashMap<String, HostEntry> = config
71 .host_entries()
72 .into_iter()
73 .map(|e| (e.alias.clone(), e))
74 .collect();
75
76 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
78
79 let mut needs_header = !dry_run && existing_map.is_empty();
81
82 for remote in remote_hosts {
83 if !remote_ids.insert(remote.server_id.clone()) {
84 continue; }
86
87 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
88 if let Some(entry) = entries_map.get(existing_alias) {
90 if entry.source_file.is_some() {
92 result.unchanged += 1;
93 continue;
94 }
95
96 let sanitized = sanitize_name(&remote.name);
98 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
99 let alias_changed = *existing_alias != expected_alias;
100
101 let ip_changed = entry.hostname != remote.ip;
102 let mut sorted_local = entry.tags.clone();
103 sorted_local.sort();
104 let mut sorted_remote: Vec<String> =
105 remote.tags.iter().map(|t| t.trim().to_string()).collect();
106 sorted_remote.sort();
107 let tags_changed = sorted_local != sorted_remote;
108 if alias_changed || ip_changed || tags_changed {
109 if !dry_run {
110 let new_alias = if alias_changed {
112 config.deduplicate_alias(&expected_alias)
113 } else {
114 existing_alias.clone()
115 };
116
117 if alias_changed || ip_changed {
118 let updated = HostEntry {
119 alias: new_alias.clone(),
120 hostname: remote.ip.clone(),
121 ..entry.clone()
122 };
123 config.update_host(existing_alias, &updated);
124 }
125 let tags_alias = if alias_changed { &new_alias } else { existing_alias };
127 if tags_changed {
128 config.set_host_tags(tags_alias, &remote.tags);
129 }
130 if alias_changed {
132 config.set_host_provider(&new_alias, provider.name(), &remote.server_id);
133 }
134 }
135 result.updated += 1;
136 } else {
137 result.unchanged += 1;
138 }
139 } else {
140 result.unchanged += 1;
141 }
142 } else {
143 let sanitized = sanitize_name(&remote.name);
145 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
146 let alias = if dry_run {
147 base_alias
148 } else {
149 config.deduplicate_alias(&base_alias)
150 };
151
152 if !dry_run {
153 let wrote_header = needs_header;
155 if needs_header {
156 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
157 config
158 .elements
159 .push(ConfigElement::GlobalLine(String::new()));
160 }
161 config
162 .elements
163 .push(ConfigElement::GlobalLine(format!(
164 "# purple:group {}",
165 super::provider_display_name(provider.name())
166 )));
167 needs_header = false;
168 }
169
170 let entry = HostEntry {
171 alias: alias.clone(),
172 hostname: remote.ip.clone(),
173 user: section.user.clone(),
174 port: 22,
175 identity_file: section.identity_file.clone(),
176 proxy_jump: String::new(),
177 source_file: None,
178 tags: remote.tags.clone(),
179 provider: Some(provider.name().to_string()),
180 };
181
182 if !wrote_header
185 && !config.elements.is_empty()
186 && !config.last_element_has_trailing_blank()
187 {
188 config
189 .elements
190 .push(ConfigElement::GlobalLine(String::new()));
191 }
192
193 let block = SshConfigFile::entry_to_block(&entry);
194 config.elements.push(ConfigElement::HostBlock(block));
195 config.set_host_provider(&alias, provider.name(), &remote.server_id);
196 if !remote.tags.is_empty() {
197 config.set_host_tags(&alias, &remote.tags);
198 }
199 }
200
201 result.added += 1;
202 }
203 }
204
205 if remove_deleted && !dry_run {
207 let to_remove: Vec<String> = existing_map
208 .iter()
209 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
210 .filter(|(_, alias)| {
211 entries_map
212 .get(alias.as_str())
213 .is_none_or(|e| e.source_file.is_none())
214 })
215 .map(|(_, alias)| alias.clone())
216 .collect();
217 for alias in &to_remove {
218 config.delete_host(alias);
219 }
220 result.removed = to_remove.len();
221
222 if config.find_hosts_by_provider(provider.name()).is_empty() {
224 let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
225 config
226 .elements
227 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
228 }
229 } else if remove_deleted {
230 result.removed = existing_map
231 .iter()
232 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
233 .filter(|(_, alias)| {
234 entries_map
235 .get(alias.as_str())
236 .is_none_or(|e| e.source_file.is_none())
237 })
238 .count();
239 }
240
241 result
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use std::path::PathBuf;
248
249 fn empty_config() -> SshConfigFile {
250 SshConfigFile {
251 elements: Vec::new(),
252 path: PathBuf::from("/tmp/test_config"),
253 crlf: false,
254 }
255 }
256
257 fn make_section() -> ProviderSection {
258 ProviderSection {
259 provider: "digitalocean".to_string(),
260 token: "test".to_string(),
261 alias_prefix: "do".to_string(),
262 user: "root".to_string(),
263 identity_file: String::new(),
264 }
265 }
266
267 struct MockProvider;
268 impl Provider for MockProvider {
269 fn name(&self) -> &str {
270 "digitalocean"
271 }
272 fn short_label(&self) -> &str {
273 "do"
274 }
275 fn fetch_hosts(
276 &self,
277 _token: &str,
278 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
279 Ok(Vec::new())
280 }
281 }
282
283 #[test]
284 fn test_build_alias() {
285 assert_eq!(build_alias("do", "web-1"), "do-web-1");
286 assert_eq!(build_alias("", "web-1"), "web-1");
287 assert_eq!(build_alias("ocean", "db"), "ocean-db");
288 }
289
290 #[test]
291 fn test_sanitize_name() {
292 assert_eq!(sanitize_name("web-1"), "web-1");
293 assert_eq!(sanitize_name("My Server"), "my-server");
294 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
295 assert_eq!(sanitize_name("--weird--"), "weird");
296 assert_eq!(sanitize_name("UPPER"), "upper");
297 assert_eq!(sanitize_name("a--b"), "a-b");
298 assert_eq!(sanitize_name(""), "server");
299 assert_eq!(sanitize_name("..."), "server");
300 }
301
302 #[test]
303 fn test_sync_adds_new_hosts() {
304 let mut config = empty_config();
305 let section = make_section();
306 let remote = vec![
307 ProviderHost {
308 server_id: "123".to_string(),
309 name: "web-1".to_string(),
310 ip: "1.2.3.4".to_string(),
311 tags: Vec::new(),
312 },
313 ProviderHost {
314 server_id: "456".to_string(),
315 name: "db-1".to_string(),
316 ip: "5.6.7.8".to_string(),
317 tags: Vec::new(),
318 },
319 ];
320
321 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
322 assert_eq!(result.added, 2);
323 assert_eq!(result.updated, 0);
324 assert_eq!(result.unchanged, 0);
325
326 let entries = config.host_entries();
327 assert_eq!(entries.len(), 2);
328 assert_eq!(entries[0].alias, "do-web-1");
329 assert_eq!(entries[0].hostname, "1.2.3.4");
330 assert_eq!(entries[1].alias, "do-db-1");
331 }
332
333 #[test]
334 fn test_sync_updates_changed_ip() {
335 let mut config = empty_config();
336 let section = make_section();
337
338 let remote = vec![ProviderHost {
340 server_id: "123".to_string(),
341 name: "web-1".to_string(),
342 ip: "1.2.3.4".to_string(),
343 tags: Vec::new(),
344 }];
345 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
346
347 let remote = vec![ProviderHost {
349 server_id: "123".to_string(),
350 name: "web-1".to_string(),
351 ip: "9.8.7.6".to_string(),
352 tags: Vec::new(),
353 }];
354 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
355 assert_eq!(result.updated, 1);
356 assert_eq!(result.added, 0);
357
358 let entries = config.host_entries();
359 assert_eq!(entries[0].hostname, "9.8.7.6");
360 }
361
362 #[test]
363 fn test_sync_unchanged() {
364 let mut config = empty_config();
365 let section = make_section();
366
367 let remote = vec![ProviderHost {
368 server_id: "123".to_string(),
369 name: "web-1".to_string(),
370 ip: "1.2.3.4".to_string(),
371 tags: Vec::new(),
372 }];
373 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
374
375 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
377 assert_eq!(result.unchanged, 1);
378 assert_eq!(result.added, 0);
379 assert_eq!(result.updated, 0);
380 }
381
382 #[test]
383 fn test_sync_removes_deleted() {
384 let mut config = empty_config();
385 let section = make_section();
386
387 let remote = vec![ProviderHost {
388 server_id: "123".to_string(),
389 name: "web-1".to_string(),
390 ip: "1.2.3.4".to_string(),
391 tags: Vec::new(),
392 }];
393 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
394 assert_eq!(config.host_entries().len(), 1);
395
396 let result =
398 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
399 assert_eq!(result.removed, 1);
400 assert_eq!(config.host_entries().len(), 0);
401 }
402
403 #[test]
404 fn test_sync_dry_run_no_mutations() {
405 let mut config = empty_config();
406 let section = make_section();
407
408 let remote = vec![ProviderHost {
409 server_id: "123".to_string(),
410 name: "web-1".to_string(),
411 ip: "1.2.3.4".to_string(),
412 tags: Vec::new(),
413 }];
414
415 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
416 assert_eq!(result.added, 1);
417 assert_eq!(config.host_entries().len(), 0); }
419
420 #[test]
421 fn test_sync_dedup_server_id_in_response() {
422 let mut config = empty_config();
423 let section = make_section();
424 let remote = vec![
425 ProviderHost {
426 server_id: "123".to_string(),
427 name: "web-1".to_string(),
428 ip: "1.2.3.4".to_string(),
429 tags: Vec::new(),
430 },
431 ProviderHost {
432 server_id: "123".to_string(),
433 name: "web-1-dup".to_string(),
434 ip: "5.6.7.8".to_string(),
435 tags: Vec::new(),
436 },
437 ];
438
439 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
440 assert_eq!(result.added, 1);
441 assert_eq!(config.host_entries().len(), 1);
442 assert_eq!(config.host_entries()[0].alias, "do-web-1");
443 }
444
445 #[test]
446 fn test_sync_duplicate_local_server_id_keeps_first() {
447 let content = "\
449Host do-web-1
450 HostName 1.2.3.4
451 # purple:provider digitalocean:123
452
453Host do-web-1-copy
454 HostName 1.2.3.4
455 # purple:provider digitalocean:123
456";
457 let mut config = SshConfigFile {
458 elements: SshConfigFile::parse_content(content),
459 path: PathBuf::from("/tmp/test_config"),
460 crlf: false,
461 };
462 let section = make_section();
463
464 let remote = vec![ProviderHost {
466 server_id: "123".to_string(),
467 name: "web-1".to_string(),
468 ip: "5.6.7.8".to_string(),
469 tags: Vec::new(),
470 }];
471
472 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
473 assert_eq!(result.updated, 1);
475 assert_eq!(result.added, 0);
476 let entries = config.host_entries();
477 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
478 assert_eq!(first.hostname, "5.6.7.8");
479 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
481 assert_eq!(copy.hostname, "1.2.3.4");
482 }
483
484 #[test]
485 fn test_sync_no_duplicate_header_on_repeated_sync() {
486 let mut config = empty_config();
487 let section = make_section();
488
489 let remote = vec![ProviderHost {
491 server_id: "123".to_string(),
492 name: "web-1".to_string(),
493 ip: "1.2.3.4".to_string(),
494 tags: Vec::new(),
495 }];
496 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
497
498 let remote = vec![
500 ProviderHost {
501 server_id: "123".to_string(),
502 name: "web-1".to_string(),
503 ip: "1.2.3.4".to_string(),
504 tags: Vec::new(),
505 },
506 ProviderHost {
507 server_id: "456".to_string(),
508 name: "db-1".to_string(),
509 ip: "5.6.7.8".to_string(),
510 tags: Vec::new(),
511 },
512 ];
513 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
514
515 let header_count = config
517 .elements
518 .iter()
519 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
520 .count();
521 assert_eq!(header_count, 1);
522 assert_eq!(config.host_entries().len(), 2);
523 }
524
525 #[test]
526 fn test_sync_removes_orphan_header() {
527 let mut config = empty_config();
528 let section = make_section();
529
530 let remote = vec![ProviderHost {
532 server_id: "123".to_string(),
533 name: "web-1".to_string(),
534 ip: "1.2.3.4".to_string(),
535 tags: Vec::new(),
536 }];
537 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
538
539 let has_header = config
541 .elements
542 .iter()
543 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
544 assert!(has_header);
545
546 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
548 assert_eq!(result.removed, 1);
549
550 let has_header = config
552 .elements
553 .iter()
554 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
555 assert!(!has_header);
556 }
557
558 #[test]
559 fn test_sync_writes_provider_tags() {
560 let mut config = empty_config();
561 let section = make_section();
562 let remote = vec![ProviderHost {
563 server_id: "123".to_string(),
564 name: "web-1".to_string(),
565 ip: "1.2.3.4".to_string(),
566 tags: vec!["production".to_string(), "us-east".to_string()],
567 }];
568
569 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
570
571 let entries = config.host_entries();
572 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
573 }
574
575 #[test]
576 fn test_sync_updates_changed_tags() {
577 let mut config = empty_config();
578 let section = make_section();
579
580 let remote = vec![ProviderHost {
582 server_id: "123".to_string(),
583 name: "web-1".to_string(),
584 ip: "1.2.3.4".to_string(),
585 tags: vec!["staging".to_string()],
586 }];
587 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
588 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
589
590 let remote = vec![ProviderHost {
592 server_id: "123".to_string(),
593 name: "web-1".to_string(),
594 ip: "1.2.3.4".to_string(),
595 tags: vec!["production".to_string(), "us-east".to_string()],
596 }];
597 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
598 assert_eq!(result.updated, 1);
599 assert_eq!(
600 config.host_entries()[0].tags,
601 vec!["production", "us-east"]
602 );
603 }
604
605 #[test]
606 fn test_sync_combined_add_update_remove() {
607 let mut config = empty_config();
608 let section = make_section();
609
610 let remote = vec![
612 ProviderHost {
613 server_id: "1".to_string(),
614 name: "web".to_string(),
615 ip: "1.1.1.1".to_string(),
616 tags: Vec::new(),
617 },
618 ProviderHost {
619 server_id: "2".to_string(),
620 name: "db".to_string(),
621 ip: "2.2.2.2".to_string(),
622 tags: Vec::new(),
623 },
624 ];
625 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
626 assert_eq!(config.host_entries().len(), 2);
627
628 let remote = vec![
630 ProviderHost {
631 server_id: "1".to_string(),
632 name: "web".to_string(),
633 ip: "9.9.9.9".to_string(),
634 tags: Vec::new(),
635 },
636 ProviderHost {
637 server_id: "3".to_string(),
638 name: "cache".to_string(),
639 ip: "3.3.3.3".to_string(),
640 tags: Vec::new(),
641 },
642 ];
643 let result =
644 sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
645 assert_eq!(result.updated, 1);
646 assert_eq!(result.added, 1);
647 assert_eq!(result.removed, 1);
648
649 let entries = config.host_entries();
650 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
652 assert_eq!(entries[0].hostname, "9.9.9.9");
653 assert_eq!(entries[1].alias, "do-cache");
654 }
655
656 #[test]
657 fn test_sync_tag_order_insensitive() {
658 let mut config = empty_config();
659 let section = make_section();
660
661 let remote = vec![ProviderHost {
663 server_id: "123".to_string(),
664 name: "web-1".to_string(),
665 ip: "1.2.3.4".to_string(),
666 tags: vec!["beta".to_string(), "alpha".to_string()],
667 }];
668 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
669
670 let remote = vec![ProviderHost {
672 server_id: "123".to_string(),
673 name: "web-1".to_string(),
674 ip: "1.2.3.4".to_string(),
675 tags: vec!["alpha".to_string(), "beta".to_string()],
676 }];
677 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
678 assert_eq!(result.unchanged, 1);
679 assert_eq!(result.updated, 0);
680 }
681
682 fn config_with_include_provider_host() -> SshConfigFile {
683 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
684
685 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
687 let included_elements = SshConfigFile::parse_content(content);
688
689 SshConfigFile {
690 elements: vec![ConfigElement::Include(IncludeDirective {
691 raw_line: "Include conf.d/*".to_string(),
692 pattern: "conf.d/*".to_string(),
693 resolved_files: vec![IncludedFile {
694 path: PathBuf::from("/tmp/included.conf"),
695 elements: included_elements,
696 }],
697 })],
698 path: PathBuf::from("/tmp/test_config"),
699 crlf: false,
700 }
701 }
702
703 #[test]
704 fn test_sync_include_host_skips_update() {
705 let mut config = config_with_include_provider_host();
706 let section = make_section();
707
708 let remote = vec![ProviderHost {
710 server_id: "inc1".to_string(),
711 name: "included".to_string(),
712 ip: "9.9.9.9".to_string(),
713 tags: Vec::new(),
714 }];
715 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
716 assert_eq!(result.unchanged, 1);
717 assert_eq!(result.updated, 0);
718 assert_eq!(result.added, 0);
719
720 let entries = config.host_entries();
722 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
723 assert_eq!(included.hostname, "1.2.3.4");
724 }
725
726 #[test]
727 fn test_sync_include_host_skips_remove() {
728 let mut config = config_with_include_provider_host();
729 let section = make_section();
730
731 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
733 assert_eq!(result.removed, 0);
734 assert_eq!(config.host_entries().len(), 1);
735 }
736
737 #[test]
738 fn test_sync_dry_run_remove_count() {
739 let mut config = empty_config();
740 let section = make_section();
741
742 let remote = vec![
744 ProviderHost {
745 server_id: "1".to_string(),
746 name: "web".to_string(),
747 ip: "1.1.1.1".to_string(),
748 tags: Vec::new(),
749 },
750 ProviderHost {
751 server_id: "2".to_string(),
752 name: "db".to_string(),
753 ip: "2.2.2.2".to_string(),
754 tags: Vec::new(),
755 },
756 ];
757 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
758 assert_eq!(config.host_entries().len(), 2);
759
760 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
762 assert_eq!(result.removed, 2);
763 assert_eq!(config.host_entries().len(), 2); }
765
766 #[test]
767 fn test_sync_tags_cleared() {
768 let mut config = empty_config();
769 let section = make_section();
770
771 let remote = vec![ProviderHost {
773 server_id: "123".to_string(),
774 name: "web-1".to_string(),
775 ip: "1.2.3.4".to_string(),
776 tags: vec!["production".to_string()],
777 }];
778 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
779 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
780
781 let remote = vec![ProviderHost {
783 server_id: "123".to_string(),
784 name: "web-1".to_string(),
785 ip: "1.2.3.4".to_string(),
786 tags: Vec::new(),
787 }];
788 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
789 assert_eq!(result.updated, 1);
790 assert!(config.host_entries()[0].tags.is_empty());
791 }
792
793 #[test]
794 fn test_sync_deduplicates_alias() {
795 let content = "Host do-web-1\n HostName 10.0.0.1\n";
796 let mut config = SshConfigFile {
797 elements: SshConfigFile::parse_content(content),
798 path: PathBuf::from("/tmp/test_config"),
799 crlf: false,
800 };
801 let section = make_section();
802
803 let remote = vec![ProviderHost {
804 server_id: "999".to_string(),
805 name: "web-1".to_string(),
806 ip: "1.2.3.4".to_string(),
807 tags: Vec::new(),
808 }];
809
810 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
811
812 let entries = config.host_entries();
813 assert_eq!(entries.len(), 2);
815 assert_eq!(entries[0].alias, "do-web-1");
816 assert_eq!(entries[1].alias, "do-web-1-2");
817 }
818
819 #[test]
820 fn test_sync_renames_on_prefix_change() {
821 let mut config = empty_config();
822 let section = make_section(); let remote = vec![ProviderHost {
826 server_id: "123".to_string(),
827 name: "web-1".to_string(),
828 ip: "1.2.3.4".to_string(),
829 tags: Vec::new(),
830 }];
831 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
832 assert_eq!(config.host_entries()[0].alias, "do-web-1");
833
834 let new_section = ProviderSection {
836 alias_prefix: "ocean".to_string(),
837 ..section
838 };
839 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
840 assert_eq!(result.updated, 1);
841 assert_eq!(result.unchanged, 0);
842
843 let entries = config.host_entries();
844 assert_eq!(entries.len(), 1);
845 assert_eq!(entries[0].alias, "ocean-web-1");
846 assert_eq!(entries[0].hostname, "1.2.3.4");
847 }
848
849 #[test]
850 fn test_sync_rename_and_ip_change() {
851 let mut config = empty_config();
852 let section = make_section();
853
854 let remote = vec![ProviderHost {
855 server_id: "123".to_string(),
856 name: "web-1".to_string(),
857 ip: "1.2.3.4".to_string(),
858 tags: Vec::new(),
859 }];
860 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
861
862 let new_section = ProviderSection {
864 alias_prefix: "ocean".to_string(),
865 ..section
866 };
867 let remote = vec![ProviderHost {
868 server_id: "123".to_string(),
869 name: "web-1".to_string(),
870 ip: "9.9.9.9".to_string(),
871 tags: Vec::new(),
872 }];
873 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
874 assert_eq!(result.updated, 1);
875
876 let entries = config.host_entries();
877 assert_eq!(entries[0].alias, "ocean-web-1");
878 assert_eq!(entries[0].hostname, "9.9.9.9");
879 }
880
881 #[test]
882 fn test_sync_rename_dry_run_no_mutation() {
883 let mut config = empty_config();
884 let section = make_section();
885
886 let remote = vec![ProviderHost {
887 server_id: "123".to_string(),
888 name: "web-1".to_string(),
889 ip: "1.2.3.4".to_string(),
890 tags: Vec::new(),
891 }];
892 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
893
894 let new_section = ProviderSection {
895 alias_prefix: "ocean".to_string(),
896 ..section
897 };
898 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
899 assert_eq!(result.updated, 1);
900
901 assert_eq!(config.host_entries()[0].alias, "do-web-1");
903 }
904
905 #[test]
906 fn test_sync_no_rename_when_prefix_unchanged() {
907 let mut config = empty_config();
908 let section = make_section();
909
910 let remote = vec![ProviderHost {
911 server_id: "123".to_string(),
912 name: "web-1".to_string(),
913 ip: "1.2.3.4".to_string(),
914 tags: Vec::new(),
915 }];
916 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
917
918 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
920 assert_eq!(result.unchanged, 1);
921 assert_eq!(result.updated, 0);
922 assert_eq!(config.host_entries()[0].alias, "do-web-1");
923 }
924
925 #[test]
926 fn test_sync_manual_comment_survives_cleanup() {
927 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
930 let mut config = SshConfigFile {
931 elements: SshConfigFile::parse_content(content),
932 path: PathBuf::from("/tmp/test_config"),
933 crlf: false,
934 };
935 let section = make_section();
936
937 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
939
940 let has_manual = config
942 .elements
943 .iter()
944 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
945 assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
946 }
947
948 #[test]
949 fn test_sync_rename_skips_included_host() {
950 let mut config = config_with_include_provider_host();
951
952 let new_section = ProviderSection {
953 provider: "digitalocean".to_string(),
954 token: "test".to_string(),
955 alias_prefix: "ocean".to_string(), user: "root".to_string(),
957 identity_file: String::new(),
958 };
959
960 let remote = vec![ProviderHost {
962 server_id: "inc1".to_string(),
963 name: "included".to_string(),
964 ip: "1.2.3.4".to_string(),
965 tags: Vec::new(),
966 }];
967 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
968 assert_eq!(result.unchanged, 1);
969 assert_eq!(result.updated, 0);
970
971 assert_eq!(config.host_entries()[0].alias, "do-included");
973 }
974}