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 pub renames: Vec<(String, String)>,
17}
18
19fn 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
39fn 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
50pub 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 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 let entries_map: HashMap<String, HostEntry> = config
73 .host_entries()
74 .into_iter()
75 .map(|e| (e.alias.clone(), e))
76 .collect();
77
78 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
80
81 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; }
88
89 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
90 if let Some(entry) = entries_map.get(existing_alias) {
92 if entry.source_file.is_some() {
94 result.unchanged += 1;
95 continue;
96 }
97
98 let sanitized = sanitize_name(&remote.name);
100 let expected_alias = build_alias(§ion.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 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 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 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 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 let sanitized = sanitize_name(&remote.name);
165 let base_alias = build_alias(§ion.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 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 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 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 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, §ion, 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 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, §ion, false, false);
364
365 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, §ion, 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, §ion, false, false);
392
393 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, 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, §ion, false, false);
412 assert_eq!(config.host_entries().len(), 1);
413
414 let result =
416 sync_provider(&mut config, &MockProvider, &[], §ion, 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, §ion, false, true);
434 assert_eq!(result.added, 1);
435 assert_eq!(config.host_entries().len(), 0); }
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, §ion, 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 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 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, §ion, false, false);
491 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 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 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, §ion, false, false);
515
516 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, §ion, false, false);
532
533 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 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, §ion, false, false);
556
557 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 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
566 assert_eq!(result.removed, 1);
567
568 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, §ion, 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 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, §ion, false, false);
606 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
607
608 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, §ion, 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 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, §ion, false, false);
644 assert_eq!(config.host_entries().len(), 2);
645
646 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, §ion, 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); 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 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, §ion, false, false);
687
688 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, §ion, 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 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 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, §ion, false, false);
734 assert_eq!(result.unchanged, 1);
735 assert_eq!(result.updated, 0);
736 assert_eq!(result.added, 0);
737
738 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 let result = sync_provider(&mut config, &MockProvider, &[], §ion, 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 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, §ion, false, false);
776 assert_eq!(config.host_entries().len(), 2);
777
778 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
780 assert_eq!(result.removed, 2);
781 assert_eq!(config.host_entries().len(), 2); }
783
784 #[test]
785 fn test_sync_tags_cleared() {
786 let mut config = empty_config();
787 let section = make_section();
788
789 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, §ion, false, false);
797 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
798
799 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, §ion, 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, §ion, false, false);
829
830 let entries = config.host_entries();
831 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(); 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, §ion, false, false);
850 assert_eq!(config.host_entries()[0].alias, "do-web-1");
851
852 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, §ion, false, false);
879
880 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, §ion, 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 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, §ion, false, false);
935
936 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, 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 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 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
957
958 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(), user: "root".to_string(),
975 identity_file: String::new(),
976 };
977
978 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 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(); 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, §ion, false, false);
1006 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1007
1008 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 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 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}