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 result.updated += 1;
111 } else {
112 let new_alias = if alias_changed {
115 config.deduplicate_alias_excluding(
116 &expected_alias,
117 Some(existing_alias),
118 )
119 } else {
120 existing_alias.clone()
121 };
122 let alias_changed = new_alias != *existing_alias;
124
125 if alias_changed || ip_changed || tags_changed {
126 if alias_changed || ip_changed {
127 let updated = HostEntry {
128 alias: new_alias.clone(),
129 hostname: remote.ip.clone(),
130 ..entry.clone()
131 };
132 config.update_host(existing_alias, &updated);
133 }
134 let tags_alias =
136 if alias_changed { &new_alias } else { existing_alias };
137 if tags_changed {
138 config.set_host_tags(tags_alias, &remote.tags);
139 }
140 if alias_changed {
142 config.set_host_provider(
143 &new_alias,
144 provider.name(),
145 &remote.server_id,
146 );
147 }
148 result.updated += 1;
149 } else {
150 result.unchanged += 1;
151 }
152 }
153 } else {
154 result.unchanged += 1;
155 }
156 } else {
157 result.unchanged += 1;
158 }
159 } else {
160 let sanitized = sanitize_name(&remote.name);
162 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
163 let alias = if dry_run {
164 base_alias
165 } else {
166 config.deduplicate_alias(&base_alias)
167 };
168
169 if !dry_run {
170 let wrote_header = needs_header;
172 if needs_header {
173 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
174 config
175 .elements
176 .push(ConfigElement::GlobalLine(String::new()));
177 }
178 config
179 .elements
180 .push(ConfigElement::GlobalLine(format!(
181 "# purple:group {}",
182 super::provider_display_name(provider.name())
183 )));
184 needs_header = false;
185 }
186
187 let entry = HostEntry {
188 alias: alias.clone(),
189 hostname: remote.ip.clone(),
190 user: section.user.clone(),
191 port: 22,
192 identity_file: section.identity_file.clone(),
193 proxy_jump: String::new(),
194 source_file: None,
195 tags: remote.tags.clone(),
196 provider: Some(provider.name().to_string()),
197 };
198
199 if !wrote_header
202 && !config.elements.is_empty()
203 && !config.last_element_has_trailing_blank()
204 {
205 config
206 .elements
207 .push(ConfigElement::GlobalLine(String::new()));
208 }
209
210 let block = SshConfigFile::entry_to_block(&entry);
211 config.elements.push(ConfigElement::HostBlock(block));
212 config.set_host_provider(&alias, provider.name(), &remote.server_id);
213 if !remote.tags.is_empty() {
214 config.set_host_tags(&alias, &remote.tags);
215 }
216 }
217
218 result.added += 1;
219 }
220 }
221
222 if remove_deleted && !dry_run {
224 let to_remove: Vec<String> = existing_map
225 .iter()
226 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
227 .filter(|(_, alias)| {
228 entries_map
229 .get(alias.as_str())
230 .is_none_or(|e| e.source_file.is_none())
231 })
232 .map(|(_, alias)| alias.clone())
233 .collect();
234 for alias in &to_remove {
235 config.delete_host(alias);
236 }
237 result.removed = to_remove.len();
238
239 if config.find_hosts_by_provider(provider.name()).is_empty() {
241 let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
242 config
243 .elements
244 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
245 }
246 } else if remove_deleted {
247 result.removed = existing_map
248 .iter()
249 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
250 .filter(|(_, alias)| {
251 entries_map
252 .get(alias.as_str())
253 .is_none_or(|e| e.source_file.is_none())
254 })
255 .count();
256 }
257
258 result
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::path::PathBuf;
265
266 fn empty_config() -> SshConfigFile {
267 SshConfigFile {
268 elements: Vec::new(),
269 path: PathBuf::from("/tmp/test_config"),
270 crlf: false,
271 }
272 }
273
274 fn make_section() -> ProviderSection {
275 ProviderSection {
276 provider: "digitalocean".to_string(),
277 token: "test".to_string(),
278 alias_prefix: "do".to_string(),
279 user: "root".to_string(),
280 identity_file: String::new(),
281 }
282 }
283
284 struct MockProvider;
285 impl Provider for MockProvider {
286 fn name(&self) -> &str {
287 "digitalocean"
288 }
289 fn short_label(&self) -> &str {
290 "do"
291 }
292 fn fetch_hosts(
293 &self,
294 _token: &str,
295 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
296 Ok(Vec::new())
297 }
298 }
299
300 #[test]
301 fn test_build_alias() {
302 assert_eq!(build_alias("do", "web-1"), "do-web-1");
303 assert_eq!(build_alias("", "web-1"), "web-1");
304 assert_eq!(build_alias("ocean", "db"), "ocean-db");
305 }
306
307 #[test]
308 fn test_sanitize_name() {
309 assert_eq!(sanitize_name("web-1"), "web-1");
310 assert_eq!(sanitize_name("My Server"), "my-server");
311 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
312 assert_eq!(sanitize_name("--weird--"), "weird");
313 assert_eq!(sanitize_name("UPPER"), "upper");
314 assert_eq!(sanitize_name("a--b"), "a-b");
315 assert_eq!(sanitize_name(""), "server");
316 assert_eq!(sanitize_name("..."), "server");
317 }
318
319 #[test]
320 fn test_sync_adds_new_hosts() {
321 let mut config = empty_config();
322 let section = make_section();
323 let remote = vec![
324 ProviderHost {
325 server_id: "123".to_string(),
326 name: "web-1".to_string(),
327 ip: "1.2.3.4".to_string(),
328 tags: Vec::new(),
329 },
330 ProviderHost {
331 server_id: "456".to_string(),
332 name: "db-1".to_string(),
333 ip: "5.6.7.8".to_string(),
334 tags: Vec::new(),
335 },
336 ];
337
338 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
339 assert_eq!(result.added, 2);
340 assert_eq!(result.updated, 0);
341 assert_eq!(result.unchanged, 0);
342
343 let entries = config.host_entries();
344 assert_eq!(entries.len(), 2);
345 assert_eq!(entries[0].alias, "do-web-1");
346 assert_eq!(entries[0].hostname, "1.2.3.4");
347 assert_eq!(entries[1].alias, "do-db-1");
348 }
349
350 #[test]
351 fn test_sync_updates_changed_ip() {
352 let mut config = empty_config();
353 let section = make_section();
354
355 let remote = vec![ProviderHost {
357 server_id: "123".to_string(),
358 name: "web-1".to_string(),
359 ip: "1.2.3.4".to_string(),
360 tags: Vec::new(),
361 }];
362 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
363
364 let remote = vec![ProviderHost {
366 server_id: "123".to_string(),
367 name: "web-1".to_string(),
368 ip: "9.8.7.6".to_string(),
369 tags: Vec::new(),
370 }];
371 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
372 assert_eq!(result.updated, 1);
373 assert_eq!(result.added, 0);
374
375 let entries = config.host_entries();
376 assert_eq!(entries[0].hostname, "9.8.7.6");
377 }
378
379 #[test]
380 fn test_sync_unchanged() {
381 let mut config = empty_config();
382 let section = make_section();
383
384 let remote = vec![ProviderHost {
385 server_id: "123".to_string(),
386 name: "web-1".to_string(),
387 ip: "1.2.3.4".to_string(),
388 tags: Vec::new(),
389 }];
390 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
391
392 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
394 assert_eq!(result.unchanged, 1);
395 assert_eq!(result.added, 0);
396 assert_eq!(result.updated, 0);
397 }
398
399 #[test]
400 fn test_sync_removes_deleted() {
401 let mut config = empty_config();
402 let section = make_section();
403
404 let remote = vec![ProviderHost {
405 server_id: "123".to_string(),
406 name: "web-1".to_string(),
407 ip: "1.2.3.4".to_string(),
408 tags: Vec::new(),
409 }];
410 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
411 assert_eq!(config.host_entries().len(), 1);
412
413 let result =
415 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
416 assert_eq!(result.removed, 1);
417 assert_eq!(config.host_entries().len(), 0);
418 }
419
420 #[test]
421 fn test_sync_dry_run_no_mutations() {
422 let mut config = empty_config();
423 let section = make_section();
424
425 let remote = vec![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
432 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
433 assert_eq!(result.added, 1);
434 assert_eq!(config.host_entries().len(), 0); }
436
437 #[test]
438 fn test_sync_dedup_server_id_in_response() {
439 let mut config = empty_config();
440 let section = make_section();
441 let remote = vec![
442 ProviderHost {
443 server_id: "123".to_string(),
444 name: "web-1".to_string(),
445 ip: "1.2.3.4".to_string(),
446 tags: Vec::new(),
447 },
448 ProviderHost {
449 server_id: "123".to_string(),
450 name: "web-1-dup".to_string(),
451 ip: "5.6.7.8".to_string(),
452 tags: Vec::new(),
453 },
454 ];
455
456 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
457 assert_eq!(result.added, 1);
458 assert_eq!(config.host_entries().len(), 1);
459 assert_eq!(config.host_entries()[0].alias, "do-web-1");
460 }
461
462 #[test]
463 fn test_sync_duplicate_local_server_id_keeps_first() {
464 let content = "\
466Host do-web-1
467 HostName 1.2.3.4
468 # purple:provider digitalocean:123
469
470Host do-web-1-copy
471 HostName 1.2.3.4
472 # purple:provider digitalocean:123
473";
474 let mut config = SshConfigFile {
475 elements: SshConfigFile::parse_content(content),
476 path: PathBuf::from("/tmp/test_config"),
477 crlf: false,
478 };
479 let section = make_section();
480
481 let remote = vec![ProviderHost {
483 server_id: "123".to_string(),
484 name: "web-1".to_string(),
485 ip: "5.6.7.8".to_string(),
486 tags: Vec::new(),
487 }];
488
489 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
490 assert_eq!(result.updated, 1);
492 assert_eq!(result.added, 0);
493 let entries = config.host_entries();
494 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
495 assert_eq!(first.hostname, "5.6.7.8");
496 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
498 assert_eq!(copy.hostname, "1.2.3.4");
499 }
500
501 #[test]
502 fn test_sync_no_duplicate_header_on_repeated_sync() {
503 let mut config = empty_config();
504 let section = make_section();
505
506 let remote = vec![ProviderHost {
508 server_id: "123".to_string(),
509 name: "web-1".to_string(),
510 ip: "1.2.3.4".to_string(),
511 tags: Vec::new(),
512 }];
513 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
514
515 let remote = vec![
517 ProviderHost {
518 server_id: "123".to_string(),
519 name: "web-1".to_string(),
520 ip: "1.2.3.4".to_string(),
521 tags: Vec::new(),
522 },
523 ProviderHost {
524 server_id: "456".to_string(),
525 name: "db-1".to_string(),
526 ip: "5.6.7.8".to_string(),
527 tags: Vec::new(),
528 },
529 ];
530 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
531
532 let header_count = config
534 .elements
535 .iter()
536 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
537 .count();
538 assert_eq!(header_count, 1);
539 assert_eq!(config.host_entries().len(), 2);
540 }
541
542 #[test]
543 fn test_sync_removes_orphan_header() {
544 let mut config = empty_config();
545 let section = make_section();
546
547 let remote = vec![ProviderHost {
549 server_id: "123".to_string(),
550 name: "web-1".to_string(),
551 ip: "1.2.3.4".to_string(),
552 tags: Vec::new(),
553 }];
554 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
555
556 let has_header = config
558 .elements
559 .iter()
560 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
561 assert!(has_header);
562
563 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
565 assert_eq!(result.removed, 1);
566
567 let has_header = config
569 .elements
570 .iter()
571 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
572 assert!(!has_header);
573 }
574
575 #[test]
576 fn test_sync_writes_provider_tags() {
577 let mut config = empty_config();
578 let section = make_section();
579 let remote = vec![ProviderHost {
580 server_id: "123".to_string(),
581 name: "web-1".to_string(),
582 ip: "1.2.3.4".to_string(),
583 tags: vec!["production".to_string(), "us-east".to_string()],
584 }];
585
586 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
587
588 let entries = config.host_entries();
589 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
590 }
591
592 #[test]
593 fn test_sync_updates_changed_tags() {
594 let mut config = empty_config();
595 let section = make_section();
596
597 let remote = vec![ProviderHost {
599 server_id: "123".to_string(),
600 name: "web-1".to_string(),
601 ip: "1.2.3.4".to_string(),
602 tags: vec!["staging".to_string()],
603 }];
604 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
605 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
606
607 let remote = vec![ProviderHost {
609 server_id: "123".to_string(),
610 name: "web-1".to_string(),
611 ip: "1.2.3.4".to_string(),
612 tags: vec!["production".to_string(), "us-east".to_string()],
613 }];
614 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
615 assert_eq!(result.updated, 1);
616 assert_eq!(
617 config.host_entries()[0].tags,
618 vec!["production", "us-east"]
619 );
620 }
621
622 #[test]
623 fn test_sync_combined_add_update_remove() {
624 let mut config = empty_config();
625 let section = make_section();
626
627 let remote = vec![
629 ProviderHost {
630 server_id: "1".to_string(),
631 name: "web".to_string(),
632 ip: "1.1.1.1".to_string(),
633 tags: Vec::new(),
634 },
635 ProviderHost {
636 server_id: "2".to_string(),
637 name: "db".to_string(),
638 ip: "2.2.2.2".to_string(),
639 tags: Vec::new(),
640 },
641 ];
642 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
643 assert_eq!(config.host_entries().len(), 2);
644
645 let remote = vec![
647 ProviderHost {
648 server_id: "1".to_string(),
649 name: "web".to_string(),
650 ip: "9.9.9.9".to_string(),
651 tags: Vec::new(),
652 },
653 ProviderHost {
654 server_id: "3".to_string(),
655 name: "cache".to_string(),
656 ip: "3.3.3.3".to_string(),
657 tags: Vec::new(),
658 },
659 ];
660 let result =
661 sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
662 assert_eq!(result.updated, 1);
663 assert_eq!(result.added, 1);
664 assert_eq!(result.removed, 1);
665
666 let entries = config.host_entries();
667 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
669 assert_eq!(entries[0].hostname, "9.9.9.9");
670 assert_eq!(entries[1].alias, "do-cache");
671 }
672
673 #[test]
674 fn test_sync_tag_order_insensitive() {
675 let mut config = empty_config();
676 let section = make_section();
677
678 let remote = vec![ProviderHost {
680 server_id: "123".to_string(),
681 name: "web-1".to_string(),
682 ip: "1.2.3.4".to_string(),
683 tags: vec!["beta".to_string(), "alpha".to_string()],
684 }];
685 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
686
687 let remote = vec![ProviderHost {
689 server_id: "123".to_string(),
690 name: "web-1".to_string(),
691 ip: "1.2.3.4".to_string(),
692 tags: vec!["alpha".to_string(), "beta".to_string()],
693 }];
694 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
695 assert_eq!(result.unchanged, 1);
696 assert_eq!(result.updated, 0);
697 }
698
699 fn config_with_include_provider_host() -> SshConfigFile {
700 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
701
702 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
704 let included_elements = SshConfigFile::parse_content(content);
705
706 SshConfigFile {
707 elements: vec![ConfigElement::Include(IncludeDirective {
708 raw_line: "Include conf.d/*".to_string(),
709 pattern: "conf.d/*".to_string(),
710 resolved_files: vec![IncludedFile {
711 path: PathBuf::from("/tmp/included.conf"),
712 elements: included_elements,
713 }],
714 })],
715 path: PathBuf::from("/tmp/test_config"),
716 crlf: false,
717 }
718 }
719
720 #[test]
721 fn test_sync_include_host_skips_update() {
722 let mut config = config_with_include_provider_host();
723 let section = make_section();
724
725 let remote = vec![ProviderHost {
727 server_id: "inc1".to_string(),
728 name: "included".to_string(),
729 ip: "9.9.9.9".to_string(),
730 tags: Vec::new(),
731 }];
732 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
733 assert_eq!(result.unchanged, 1);
734 assert_eq!(result.updated, 0);
735 assert_eq!(result.added, 0);
736
737 let entries = config.host_entries();
739 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
740 assert_eq!(included.hostname, "1.2.3.4");
741 }
742
743 #[test]
744 fn test_sync_include_host_skips_remove() {
745 let mut config = config_with_include_provider_host();
746 let section = make_section();
747
748 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
750 assert_eq!(result.removed, 0);
751 assert_eq!(config.host_entries().len(), 1);
752 }
753
754 #[test]
755 fn test_sync_dry_run_remove_count() {
756 let mut config = empty_config();
757 let section = make_section();
758
759 let remote = vec![
761 ProviderHost {
762 server_id: "1".to_string(),
763 name: "web".to_string(),
764 ip: "1.1.1.1".to_string(),
765 tags: Vec::new(),
766 },
767 ProviderHost {
768 server_id: "2".to_string(),
769 name: "db".to_string(),
770 ip: "2.2.2.2".to_string(),
771 tags: Vec::new(),
772 },
773 ];
774 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
775 assert_eq!(config.host_entries().len(), 2);
776
777 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
779 assert_eq!(result.removed, 2);
780 assert_eq!(config.host_entries().len(), 2); }
782
783 #[test]
784 fn test_sync_tags_cleared() {
785 let mut config = empty_config();
786 let section = make_section();
787
788 let remote = vec![ProviderHost {
790 server_id: "123".to_string(),
791 name: "web-1".to_string(),
792 ip: "1.2.3.4".to_string(),
793 tags: vec!["production".to_string()],
794 }];
795 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
796 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
797
798 let remote = vec![ProviderHost {
800 server_id: "123".to_string(),
801 name: "web-1".to_string(),
802 ip: "1.2.3.4".to_string(),
803 tags: Vec::new(),
804 }];
805 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
806 assert_eq!(result.updated, 1);
807 assert!(config.host_entries()[0].tags.is_empty());
808 }
809
810 #[test]
811 fn test_sync_deduplicates_alias() {
812 let content = "Host do-web-1\n HostName 10.0.0.1\n";
813 let mut config = SshConfigFile {
814 elements: SshConfigFile::parse_content(content),
815 path: PathBuf::from("/tmp/test_config"),
816 crlf: false,
817 };
818 let section = make_section();
819
820 let remote = vec![ProviderHost {
821 server_id: "999".to_string(),
822 name: "web-1".to_string(),
823 ip: "1.2.3.4".to_string(),
824 tags: Vec::new(),
825 }];
826
827 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
828
829 let entries = config.host_entries();
830 assert_eq!(entries.len(), 2);
832 assert_eq!(entries[0].alias, "do-web-1");
833 assert_eq!(entries[1].alias, "do-web-1-2");
834 }
835
836 #[test]
837 fn test_sync_renames_on_prefix_change() {
838 let mut config = empty_config();
839 let section = make_section(); let remote = vec![ProviderHost {
843 server_id: "123".to_string(),
844 name: "web-1".to_string(),
845 ip: "1.2.3.4".to_string(),
846 tags: Vec::new(),
847 }];
848 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
849 assert_eq!(config.host_entries()[0].alias, "do-web-1");
850
851 let new_section = ProviderSection {
853 alias_prefix: "ocean".to_string(),
854 ..section
855 };
856 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
857 assert_eq!(result.updated, 1);
858 assert_eq!(result.unchanged, 0);
859
860 let entries = config.host_entries();
861 assert_eq!(entries.len(), 1);
862 assert_eq!(entries[0].alias, "ocean-web-1");
863 assert_eq!(entries[0].hostname, "1.2.3.4");
864 }
865
866 #[test]
867 fn test_sync_rename_and_ip_change() {
868 let mut config = empty_config();
869 let section = make_section();
870
871 let remote = vec![ProviderHost {
872 server_id: "123".to_string(),
873 name: "web-1".to_string(),
874 ip: "1.2.3.4".to_string(),
875 tags: Vec::new(),
876 }];
877 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
878
879 let new_section = ProviderSection {
881 alias_prefix: "ocean".to_string(),
882 ..section
883 };
884 let remote = vec![ProviderHost {
885 server_id: "123".to_string(),
886 name: "web-1".to_string(),
887 ip: "9.9.9.9".to_string(),
888 tags: Vec::new(),
889 }];
890 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
891 assert_eq!(result.updated, 1);
892
893 let entries = config.host_entries();
894 assert_eq!(entries[0].alias, "ocean-web-1");
895 assert_eq!(entries[0].hostname, "9.9.9.9");
896 }
897
898 #[test]
899 fn test_sync_rename_dry_run_no_mutation() {
900 let mut config = empty_config();
901 let section = make_section();
902
903 let remote = vec![ProviderHost {
904 server_id: "123".to_string(),
905 name: "web-1".to_string(),
906 ip: "1.2.3.4".to_string(),
907 tags: Vec::new(),
908 }];
909 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
910
911 let new_section = ProviderSection {
912 alias_prefix: "ocean".to_string(),
913 ..section
914 };
915 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
916 assert_eq!(result.updated, 1);
917
918 assert_eq!(config.host_entries()[0].alias, "do-web-1");
920 }
921
922 #[test]
923 fn test_sync_no_rename_when_prefix_unchanged() {
924 let mut config = empty_config();
925 let section = make_section();
926
927 let remote = vec![ProviderHost {
928 server_id: "123".to_string(),
929 name: "web-1".to_string(),
930 ip: "1.2.3.4".to_string(),
931 tags: Vec::new(),
932 }];
933 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
934
935 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
937 assert_eq!(result.unchanged, 1);
938 assert_eq!(result.updated, 0);
939 assert_eq!(config.host_entries()[0].alias, "do-web-1");
940 }
941
942 #[test]
943 fn test_sync_manual_comment_survives_cleanup() {
944 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
947 let mut config = SshConfigFile {
948 elements: SshConfigFile::parse_content(content),
949 path: PathBuf::from("/tmp/test_config"),
950 crlf: false,
951 };
952 let section = make_section();
953
954 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
956
957 let has_manual = config
959 .elements
960 .iter()
961 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
962 assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
963 }
964
965 #[test]
966 fn test_sync_rename_skips_included_host() {
967 let mut config = config_with_include_provider_host();
968
969 let new_section = ProviderSection {
970 provider: "digitalocean".to_string(),
971 token: "test".to_string(),
972 alias_prefix: "ocean".to_string(), user: "root".to_string(),
974 identity_file: String::new(),
975 };
976
977 let remote = vec![ProviderHost {
979 server_id: "inc1".to_string(),
980 name: "included".to_string(),
981 ip: "1.2.3.4".to_string(),
982 tags: Vec::new(),
983 }];
984 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
985 assert_eq!(result.unchanged, 1);
986 assert_eq!(result.updated, 0);
987
988 assert_eq!(config.host_entries()[0].alias, "do-included");
990 }
991
992 #[test]
993 fn test_sync_rename_stable_with_manual_collision() {
994 let mut config = empty_config();
995 let section = make_section(); let remote = vec![ProviderHost {
999 server_id: "123".to_string(),
1000 name: "web-1".to_string(),
1001 ip: "1.2.3.4".to_string(),
1002 tags: Vec::new(),
1003 }];
1004 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1005 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1006
1007 let manual = HostEntry {
1009 alias: "ocean-web-1".to_string(),
1010 hostname: "5.5.5.5".to_string(),
1011 ..Default::default()
1012 };
1013 config.add_host(&manual);
1014
1015 let new_section = ProviderSection {
1017 alias_prefix: "ocean".to_string(),
1018 ..section.clone()
1019 };
1020 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1021 assert_eq!(result.updated, 1);
1022
1023 let entries = config.host_entries();
1024 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1025 assert_eq!(provider_host.alias, "ocean-web-1-2");
1026
1027 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1029 assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1030
1031 let entries = config.host_entries();
1032 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1033 assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
1034 }
1035}