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 sync_provider_with_options(
60 config,
61 provider,
62 remote_hosts,
63 section,
64 remove_deleted,
65 dry_run,
66 false,
67 )
68}
69
70pub fn sync_provider_with_options(
74 config: &mut SshConfigFile,
75 provider: &dyn Provider,
76 remote_hosts: &[ProviderHost],
77 section: &ProviderSection,
78 remove_deleted: bool,
79 dry_run: bool,
80 reset_tags: bool,
81) -> SyncResult {
82 let mut result = SyncResult::default();
83
84 let existing = config.find_hosts_by_provider(provider.name());
87 let mut existing_map: HashMap<String, String> = HashMap::new();
88 for (alias, server_id) in &existing {
89 existing_map
90 .entry(server_id.clone())
91 .or_insert_with(|| alias.clone());
92 }
93
94 let entries_map: HashMap<String, HostEntry> = config
96 .host_entries()
97 .into_iter()
98 .map(|e| (e.alias.clone(), e))
99 .collect();
100
101 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
103
104 let mut needs_header = !dry_run && existing_map.is_empty();
106
107 for remote in remote_hosts {
108 if !remote_ids.insert(remote.server_id.clone()) {
109 continue; }
111
112 if remote.ip.is_empty() {
116 if existing_map.contains_key(&remote.server_id) {
117 result.unchanged += 1;
118 }
119 continue;
120 }
121
122 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
123 if let Some(entry) = entries_map.get(existing_alias) {
125 if entry.source_file.is_some() {
127 result.unchanged += 1;
128 continue;
129 }
130
131 let sanitized = sanitize_name(&remote.name);
133 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
134 let alias_changed = *existing_alias != expected_alias;
135
136 let ip_changed = entry.hostname != remote.ip;
137 let trimmed_remote: Vec<String> =
138 remote.tags.iter().map(|t| t.trim().to_string()).collect();
139 let tags_changed = if reset_tags {
140 let mut sorted_local: Vec<String> =
142 entry.tags.iter().map(|t| t.to_lowercase()).collect();
143 sorted_local.sort();
144 let mut sorted_remote: Vec<String> =
145 trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
146 sorted_remote.sort();
147 sorted_local != sorted_remote
148 } else {
149 trimmed_remote.iter().any(|rt| {
151 !entry
152 .tags
153 .iter()
154 .any(|lt| lt.eq_ignore_ascii_case(rt))
155 })
156 };
157 if alias_changed || ip_changed || tags_changed {
158 if dry_run {
159 result.updated += 1;
160 } else {
161 let new_alias = if alias_changed {
164 config.deduplicate_alias_excluding(
165 &expected_alias,
166 Some(existing_alias),
167 )
168 } else {
169 existing_alias.clone()
170 };
171 let alias_changed = new_alias != *existing_alias;
173
174 if alias_changed || ip_changed || tags_changed {
175 if alias_changed || ip_changed {
176 let updated = HostEntry {
177 alias: new_alias.clone(),
178 hostname: remote.ip.clone(),
179 ..entry.clone()
180 };
181 config.update_host(existing_alias, &updated);
182 }
183 let tags_alias =
185 if alias_changed { &new_alias } else { existing_alias };
186 if tags_changed {
187 if reset_tags {
188 config.set_host_tags(tags_alias, &trimmed_remote);
189 } else {
190 let mut merged = entry.tags.clone();
192 for rt in &trimmed_remote {
193 if !merged.iter().any(|t| t.eq_ignore_ascii_case(rt)) {
194 merged.push(rt.clone());
195 }
196 }
197 config.set_host_tags(tags_alias, &merged);
198 }
199 }
200 if alias_changed {
202 config.set_host_provider(
203 &new_alias,
204 provider.name(),
205 &remote.server_id,
206 );
207 result.renames.push((existing_alias.clone(), new_alias.clone()));
208 }
209 result.updated += 1;
210 } else {
211 result.unchanged += 1;
212 }
213 }
214 } else {
215 result.unchanged += 1;
216 }
217 } else {
218 result.unchanged += 1;
219 }
220 } else {
221 let sanitized = sanitize_name(&remote.name);
223 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
224 let alias = if dry_run {
225 base_alias
226 } else {
227 config.deduplicate_alias(&base_alias)
228 };
229
230 if !dry_run {
231 let wrote_header = needs_header;
233 if needs_header {
234 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
235 config
236 .elements
237 .push(ConfigElement::GlobalLine(String::new()));
238 }
239 config
240 .elements
241 .push(ConfigElement::GlobalLine(format!(
242 "# purple:group {}",
243 super::provider_display_name(provider.name())
244 )));
245 needs_header = false;
246 }
247
248 let entry = HostEntry {
249 alias: alias.clone(),
250 hostname: remote.ip.clone(),
251 user: section.user.clone(),
252 identity_file: section.identity_file.clone(),
253 tags: remote.tags.clone(),
254 provider: Some(provider.name().to_string()),
255 ..Default::default()
256 };
257
258 if !wrote_header
261 && !config.elements.is_empty()
262 && !config.last_element_has_trailing_blank()
263 {
264 config
265 .elements
266 .push(ConfigElement::GlobalLine(String::new()));
267 }
268
269 let block = SshConfigFile::entry_to_block(&entry);
270 config.elements.push(ConfigElement::HostBlock(block));
271 config.set_host_provider(&alias, provider.name(), &remote.server_id);
272 if !remote.tags.is_empty() {
273 config.set_host_tags(&alias, &remote.tags);
274 }
275 }
276
277 result.added += 1;
278 }
279 }
280
281 if remove_deleted && !dry_run {
283 let to_remove: Vec<String> = existing_map
284 .iter()
285 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
286 .filter(|(_, alias)| {
287 entries_map
288 .get(alias.as_str())
289 .is_none_or(|e| e.source_file.is_none())
290 })
291 .map(|(_, alias)| alias.clone())
292 .collect();
293 for alias in &to_remove {
294 config.delete_host(alias);
295 }
296 result.removed = to_remove.len();
297
298 if config.find_hosts_by_provider(provider.name()).is_empty() {
300 let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
301 config
302 .elements
303 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
304 }
305 } else if remove_deleted {
306 result.removed = existing_map
307 .iter()
308 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
309 .filter(|(_, alias)| {
310 entries_map
311 .get(alias.as_str())
312 .is_none_or(|e| e.source_file.is_none())
313 })
314 .count();
315 }
316
317 result
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::path::PathBuf;
324
325 fn empty_config() -> SshConfigFile {
326 SshConfigFile {
327 elements: Vec::new(),
328 path: PathBuf::from("/tmp/test_config"),
329 crlf: false,
330 }
331 }
332
333 fn make_section() -> ProviderSection {
334 ProviderSection {
335 provider: "digitalocean".to_string(),
336 token: "test".to_string(),
337 alias_prefix: "do".to_string(),
338 user: "root".to_string(),
339 identity_file: String::new(),
340 url: String::new(),
341 verify_tls: true,
342 auto_sync: true,
343 }
344 }
345
346 struct MockProvider;
347 impl Provider for MockProvider {
348 fn name(&self) -> &str {
349 "digitalocean"
350 }
351 fn short_label(&self) -> &str {
352 "do"
353 }
354 fn fetch_hosts_cancellable(
355 &self,
356 _token: &str,
357 _cancel: &std::sync::atomic::AtomicBool,
358 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
359 Ok(Vec::new())
360 }
361 }
362
363 #[test]
364 fn test_build_alias() {
365 assert_eq!(build_alias("do", "web-1"), "do-web-1");
366 assert_eq!(build_alias("", "web-1"), "web-1");
367 assert_eq!(build_alias("ocean", "db"), "ocean-db");
368 }
369
370 #[test]
371 fn test_sanitize_name() {
372 assert_eq!(sanitize_name("web-1"), "web-1");
373 assert_eq!(sanitize_name("My Server"), "my-server");
374 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
375 assert_eq!(sanitize_name("--weird--"), "weird");
376 assert_eq!(sanitize_name("UPPER"), "upper");
377 assert_eq!(sanitize_name("a--b"), "a-b");
378 assert_eq!(sanitize_name(""), "server");
379 assert_eq!(sanitize_name("..."), "server");
380 }
381
382 #[test]
383 fn test_sync_adds_new_hosts() {
384 let mut config = empty_config();
385 let section = make_section();
386 let remote = vec![
387 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 ProviderHost {
394 server_id: "456".to_string(),
395 name: "db-1".to_string(),
396 ip: "5.6.7.8".to_string(),
397 tags: Vec::new(),
398 },
399 ];
400
401 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
402 assert_eq!(result.added, 2);
403 assert_eq!(result.updated, 0);
404 assert_eq!(result.unchanged, 0);
405
406 let entries = config.host_entries();
407 assert_eq!(entries.len(), 2);
408 assert_eq!(entries[0].alias, "do-web-1");
409 assert_eq!(entries[0].hostname, "1.2.3.4");
410 assert_eq!(entries[1].alias, "do-db-1");
411 }
412
413 #[test]
414 fn test_sync_updates_changed_ip() {
415 let mut config = empty_config();
416 let section = make_section();
417
418 let remote = vec![ProviderHost {
420 server_id: "123".to_string(),
421 name: "web-1".to_string(),
422 ip: "1.2.3.4".to_string(),
423 tags: Vec::new(),
424 }];
425 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
426
427 let remote = vec![ProviderHost {
429 server_id: "123".to_string(),
430 name: "web-1".to_string(),
431 ip: "9.8.7.6".to_string(),
432 tags: Vec::new(),
433 }];
434 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
435 assert_eq!(result.updated, 1);
436 assert_eq!(result.added, 0);
437
438 let entries = config.host_entries();
439 assert_eq!(entries[0].hostname, "9.8.7.6");
440 }
441
442 #[test]
443 fn test_sync_unchanged() {
444 let mut config = empty_config();
445 let section = make_section();
446
447 let remote = vec![ProviderHost {
448 server_id: "123".to_string(),
449 name: "web-1".to_string(),
450 ip: "1.2.3.4".to_string(),
451 tags: Vec::new(),
452 }];
453 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
454
455 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
457 assert_eq!(result.unchanged, 1);
458 assert_eq!(result.added, 0);
459 assert_eq!(result.updated, 0);
460 }
461
462 #[test]
463 fn test_sync_removes_deleted() {
464 let mut config = empty_config();
465 let section = make_section();
466
467 let remote = vec![ProviderHost {
468 server_id: "123".to_string(),
469 name: "web-1".to_string(),
470 ip: "1.2.3.4".to_string(),
471 tags: Vec::new(),
472 }];
473 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
474 assert_eq!(config.host_entries().len(), 1);
475
476 let result =
478 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
479 assert_eq!(result.removed, 1);
480 assert_eq!(config.host_entries().len(), 0);
481 }
482
483 #[test]
484 fn test_sync_dry_run_no_mutations() {
485 let mut config = empty_config();
486 let section = make_section();
487
488 let remote = vec![ProviderHost {
489 server_id: "123".to_string(),
490 name: "web-1".to_string(),
491 ip: "1.2.3.4".to_string(),
492 tags: Vec::new(),
493 }];
494
495 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
496 assert_eq!(result.added, 1);
497 assert_eq!(config.host_entries().len(), 0); }
499
500 #[test]
501 fn test_sync_dedup_server_id_in_response() {
502 let mut config = empty_config();
503 let section = make_section();
504 let remote = vec![
505 ProviderHost {
506 server_id: "123".to_string(),
507 name: "web-1".to_string(),
508 ip: "1.2.3.4".to_string(),
509 tags: Vec::new(),
510 },
511 ProviderHost {
512 server_id: "123".to_string(),
513 name: "web-1-dup".to_string(),
514 ip: "5.6.7.8".to_string(),
515 tags: Vec::new(),
516 },
517 ];
518
519 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
520 assert_eq!(result.added, 1);
521 assert_eq!(config.host_entries().len(), 1);
522 assert_eq!(config.host_entries()[0].alias, "do-web-1");
523 }
524
525 #[test]
526 fn test_sync_duplicate_local_server_id_keeps_first() {
527 let content = "\
529Host do-web-1
530 HostName 1.2.3.4
531 # purple:provider digitalocean:123
532
533Host do-web-1-copy
534 HostName 1.2.3.4
535 # purple:provider digitalocean:123
536";
537 let mut config = SshConfigFile {
538 elements: SshConfigFile::parse_content(content),
539 path: PathBuf::from("/tmp/test_config"),
540 crlf: false,
541 };
542 let section = make_section();
543
544 let remote = vec![ProviderHost {
546 server_id: "123".to_string(),
547 name: "web-1".to_string(),
548 ip: "5.6.7.8".to_string(),
549 tags: Vec::new(),
550 }];
551
552 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
553 assert_eq!(result.updated, 1);
555 assert_eq!(result.added, 0);
556 let entries = config.host_entries();
557 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
558 assert_eq!(first.hostname, "5.6.7.8");
559 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
561 assert_eq!(copy.hostname, "1.2.3.4");
562 }
563
564 #[test]
565 fn test_sync_no_duplicate_header_on_repeated_sync() {
566 let mut config = empty_config();
567 let section = make_section();
568
569 let remote = vec![ProviderHost {
571 server_id: "123".to_string(),
572 name: "web-1".to_string(),
573 ip: "1.2.3.4".to_string(),
574 tags: Vec::new(),
575 }];
576 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
577
578 let remote = vec![
580 ProviderHost {
581 server_id: "123".to_string(),
582 name: "web-1".to_string(),
583 ip: "1.2.3.4".to_string(),
584 tags: Vec::new(),
585 },
586 ProviderHost {
587 server_id: "456".to_string(),
588 name: "db-1".to_string(),
589 ip: "5.6.7.8".to_string(),
590 tags: Vec::new(),
591 },
592 ];
593 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
594
595 let header_count = config
597 .elements
598 .iter()
599 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
600 .count();
601 assert_eq!(header_count, 1);
602 assert_eq!(config.host_entries().len(), 2);
603 }
604
605 #[test]
606 fn test_sync_removes_orphan_header() {
607 let mut config = empty_config();
608 let section = make_section();
609
610 let remote = vec![ProviderHost {
612 server_id: "123".to_string(),
613 name: "web-1".to_string(),
614 ip: "1.2.3.4".to_string(),
615 tags: Vec::new(),
616 }];
617 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
618
619 let has_header = config
621 .elements
622 .iter()
623 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
624 assert!(has_header);
625
626 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
628 assert_eq!(result.removed, 1);
629
630 let has_header = config
632 .elements
633 .iter()
634 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
635 assert!(!has_header);
636 }
637
638 #[test]
639 fn test_sync_writes_provider_tags() {
640 let mut config = empty_config();
641 let section = make_section();
642 let remote = vec![ProviderHost {
643 server_id: "123".to_string(),
644 name: "web-1".to_string(),
645 ip: "1.2.3.4".to_string(),
646 tags: vec!["production".to_string(), "us-east".to_string()],
647 }];
648
649 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
650
651 let entries = config.host_entries();
652 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
653 }
654
655 #[test]
656 fn test_sync_updates_changed_tags() {
657 let mut config = empty_config();
658 let section = make_section();
659
660 let remote = vec![ProviderHost {
662 server_id: "123".to_string(),
663 name: "web-1".to_string(),
664 ip: "1.2.3.4".to_string(),
665 tags: vec!["staging".to_string()],
666 }];
667 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
668 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
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!["production".to_string(), "us-east".to_string()],
676 }];
677 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
678 assert_eq!(result.updated, 1);
679 assert_eq!(
680 config.host_entries()[0].tags,
681 vec!["staging", "production", "us-east"]
682 );
683 }
684
685 #[test]
686 fn test_sync_combined_add_update_remove() {
687 let mut config = empty_config();
688 let section = make_section();
689
690 let remote = vec![
692 ProviderHost {
693 server_id: "1".to_string(),
694 name: "web".to_string(),
695 ip: "1.1.1.1".to_string(),
696 tags: Vec::new(),
697 },
698 ProviderHost {
699 server_id: "2".to_string(),
700 name: "db".to_string(),
701 ip: "2.2.2.2".to_string(),
702 tags: Vec::new(),
703 },
704 ];
705 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
706 assert_eq!(config.host_entries().len(), 2);
707
708 let remote = vec![
710 ProviderHost {
711 server_id: "1".to_string(),
712 name: "web".to_string(),
713 ip: "9.9.9.9".to_string(),
714 tags: Vec::new(),
715 },
716 ProviderHost {
717 server_id: "3".to_string(),
718 name: "cache".to_string(),
719 ip: "3.3.3.3".to_string(),
720 tags: Vec::new(),
721 },
722 ];
723 let result =
724 sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
725 assert_eq!(result.updated, 1);
726 assert_eq!(result.added, 1);
727 assert_eq!(result.removed, 1);
728
729 let entries = config.host_entries();
730 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
732 assert_eq!(entries[0].hostname, "9.9.9.9");
733 assert_eq!(entries[1].alias, "do-cache");
734 }
735
736 #[test]
737 fn test_sync_tag_order_insensitive() {
738 let mut config = empty_config();
739 let section = make_section();
740
741 let remote = vec![ProviderHost {
743 server_id: "123".to_string(),
744 name: "web-1".to_string(),
745 ip: "1.2.3.4".to_string(),
746 tags: vec!["beta".to_string(), "alpha".to_string()],
747 }];
748 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
749
750 let remote = vec![ProviderHost {
752 server_id: "123".to_string(),
753 name: "web-1".to_string(),
754 ip: "1.2.3.4".to_string(),
755 tags: vec!["alpha".to_string(), "beta".to_string()],
756 }];
757 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
758 assert_eq!(result.unchanged, 1);
759 assert_eq!(result.updated, 0);
760 }
761
762 fn config_with_include_provider_host() -> SshConfigFile {
763 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
764
765 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
767 let included_elements = SshConfigFile::parse_content(content);
768
769 SshConfigFile {
770 elements: vec![ConfigElement::Include(IncludeDirective {
771 raw_line: "Include conf.d/*".to_string(),
772 pattern: "conf.d/*".to_string(),
773 resolved_files: vec![IncludedFile {
774 path: PathBuf::from("/tmp/included.conf"),
775 elements: included_elements,
776 }],
777 })],
778 path: PathBuf::from("/tmp/test_config"),
779 crlf: false,
780 }
781 }
782
783 #[test]
784 fn test_sync_include_host_skips_update() {
785 let mut config = config_with_include_provider_host();
786 let section = make_section();
787
788 let remote = vec![ProviderHost {
790 server_id: "inc1".to_string(),
791 name: "included".to_string(),
792 ip: "9.9.9.9".to_string(),
793 tags: Vec::new(),
794 }];
795 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
796 assert_eq!(result.unchanged, 1);
797 assert_eq!(result.updated, 0);
798 assert_eq!(result.added, 0);
799
800 let entries = config.host_entries();
802 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
803 assert_eq!(included.hostname, "1.2.3.4");
804 }
805
806 #[test]
807 fn test_sync_include_host_skips_remove() {
808 let mut config = config_with_include_provider_host();
809 let section = make_section();
810
811 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
813 assert_eq!(result.removed, 0);
814 assert_eq!(config.host_entries().len(), 1);
815 }
816
817 #[test]
818 fn test_sync_dry_run_remove_count() {
819 let mut config = empty_config();
820 let section = make_section();
821
822 let remote = vec![
824 ProviderHost {
825 server_id: "1".to_string(),
826 name: "web".to_string(),
827 ip: "1.1.1.1".to_string(),
828 tags: Vec::new(),
829 },
830 ProviderHost {
831 server_id: "2".to_string(),
832 name: "db".to_string(),
833 ip: "2.2.2.2".to_string(),
834 tags: Vec::new(),
835 },
836 ];
837 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
838 assert_eq!(config.host_entries().len(), 2);
839
840 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
842 assert_eq!(result.removed, 2);
843 assert_eq!(config.host_entries().len(), 2); }
845
846 #[test]
847 fn test_sync_tags_cleared_remotely_preserved_locally() {
848 let mut config = empty_config();
849 let section = make_section();
850
851 let remote = vec![ProviderHost {
853 server_id: "123".to_string(),
854 name: "web-1".to_string(),
855 ip: "1.2.3.4".to_string(),
856 tags: vec!["production".to_string()],
857 }];
858 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
859 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
860
861 let remote = vec![ProviderHost {
863 server_id: "123".to_string(),
864 name: "web-1".to_string(),
865 ip: "1.2.3.4".to_string(),
866 tags: Vec::new(),
867 }];
868 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
869 assert_eq!(result.unchanged, 1);
870 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
871 }
872
873 #[test]
874 fn test_sync_deduplicates_alias() {
875 let content = "Host do-web-1\n HostName 10.0.0.1\n";
876 let mut config = SshConfigFile {
877 elements: SshConfigFile::parse_content(content),
878 path: PathBuf::from("/tmp/test_config"),
879 crlf: false,
880 };
881 let section = make_section();
882
883 let remote = vec![ProviderHost {
884 server_id: "999".to_string(),
885 name: "web-1".to_string(),
886 ip: "1.2.3.4".to_string(),
887 tags: Vec::new(),
888 }];
889
890 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
891
892 let entries = config.host_entries();
893 assert_eq!(entries.len(), 2);
895 assert_eq!(entries[0].alias, "do-web-1");
896 assert_eq!(entries[1].alias, "do-web-1-2");
897 }
898
899 #[test]
900 fn test_sync_renames_on_prefix_change() {
901 let mut config = empty_config();
902 let section = make_section(); let remote = vec![ProviderHost {
906 server_id: "123".to_string(),
907 name: "web-1".to_string(),
908 ip: "1.2.3.4".to_string(),
909 tags: Vec::new(),
910 }];
911 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
912 assert_eq!(config.host_entries()[0].alias, "do-web-1");
913
914 let new_section = ProviderSection {
916 alias_prefix: "ocean".to_string(),
917 ..section
918 };
919 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
920 assert_eq!(result.updated, 1);
921 assert_eq!(result.unchanged, 0);
922
923 let entries = config.host_entries();
924 assert_eq!(entries.len(), 1);
925 assert_eq!(entries[0].alias, "ocean-web-1");
926 assert_eq!(entries[0].hostname, "1.2.3.4");
927 }
928
929 #[test]
930 fn test_sync_rename_and_ip_change() {
931 let mut config = empty_config();
932 let section = make_section();
933
934 let remote = vec![ProviderHost {
935 server_id: "123".to_string(),
936 name: "web-1".to_string(),
937 ip: "1.2.3.4".to_string(),
938 tags: Vec::new(),
939 }];
940 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
941
942 let new_section = ProviderSection {
944 alias_prefix: "ocean".to_string(),
945 ..section
946 };
947 let remote = vec![ProviderHost {
948 server_id: "123".to_string(),
949 name: "web-1".to_string(),
950 ip: "9.9.9.9".to_string(),
951 tags: Vec::new(),
952 }];
953 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
954 assert_eq!(result.updated, 1);
955
956 let entries = config.host_entries();
957 assert_eq!(entries[0].alias, "ocean-web-1");
958 assert_eq!(entries[0].hostname, "9.9.9.9");
959 }
960
961 #[test]
962 fn test_sync_rename_dry_run_no_mutation() {
963 let mut config = empty_config();
964 let section = make_section();
965
966 let remote = vec![ProviderHost {
967 server_id: "123".to_string(),
968 name: "web-1".to_string(),
969 ip: "1.2.3.4".to_string(),
970 tags: Vec::new(),
971 }];
972 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
973
974 let new_section = ProviderSection {
975 alias_prefix: "ocean".to_string(),
976 ..section
977 };
978 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
979 assert_eq!(result.updated, 1);
980
981 assert_eq!(config.host_entries()[0].alias, "do-web-1");
983 }
984
985 #[test]
986 fn test_sync_no_rename_when_prefix_unchanged() {
987 let mut config = empty_config();
988 let section = make_section();
989
990 let remote = vec![ProviderHost {
991 server_id: "123".to_string(),
992 name: "web-1".to_string(),
993 ip: "1.2.3.4".to_string(),
994 tags: Vec::new(),
995 }];
996 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
997
998 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1000 assert_eq!(result.unchanged, 1);
1001 assert_eq!(result.updated, 0);
1002 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1003 }
1004
1005 #[test]
1006 fn test_sync_manual_comment_survives_cleanup() {
1007 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
1010 let mut config = SshConfigFile {
1011 elements: SshConfigFile::parse_content(content),
1012 path: PathBuf::from("/tmp/test_config"),
1013 crlf: false,
1014 };
1015 let section = make_section();
1016
1017 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1019
1020 let has_manual = config
1022 .elements
1023 .iter()
1024 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
1025 assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
1026 }
1027
1028 #[test]
1029 fn test_sync_rename_skips_included_host() {
1030 let mut config = config_with_include_provider_host();
1031
1032 let new_section = ProviderSection {
1033 provider: "digitalocean".to_string(),
1034 token: "test".to_string(),
1035 alias_prefix: "ocean".to_string(), user: "root".to_string(),
1037 identity_file: String::new(),
1038 url: String::new(),
1039 verify_tls: true,
1040 auto_sync: true,
1041 };
1042
1043 let remote = vec![ProviderHost {
1045 server_id: "inc1".to_string(),
1046 name: "included".to_string(),
1047 ip: "1.2.3.4".to_string(),
1048 tags: Vec::new(),
1049 }];
1050 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1051 assert_eq!(result.unchanged, 1);
1052 assert_eq!(result.updated, 0);
1053
1054 assert_eq!(config.host_entries()[0].alias, "do-included");
1056 }
1057
1058 #[test]
1059 fn test_sync_rename_stable_with_manual_collision() {
1060 let mut config = empty_config();
1061 let section = make_section(); let remote = vec![ProviderHost {
1065 server_id: "123".to_string(),
1066 name: "web-1".to_string(),
1067 ip: "1.2.3.4".to_string(),
1068 tags: Vec::new(),
1069 }];
1070 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1071 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1072
1073 let manual = HostEntry {
1075 alias: "ocean-web-1".to_string(),
1076 hostname: "5.5.5.5".to_string(),
1077 ..Default::default()
1078 };
1079 config.add_host(&manual);
1080
1081 let new_section = ProviderSection {
1083 alias_prefix: "ocean".to_string(),
1084 ..section.clone()
1085 };
1086 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1087 assert_eq!(result.updated, 1);
1088
1089 let entries = config.host_entries();
1090 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1091 assert_eq!(provider_host.alias, "ocean-web-1-2");
1092
1093 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
1095 assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1096
1097 let entries = config.host_entries();
1098 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1099 assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
1100 }
1101
1102 #[test]
1103 fn test_sync_preserves_user_tags() {
1104 let mut config = empty_config();
1105 let section = make_section();
1106
1107 let remote = vec![ProviderHost {
1109 server_id: "123".to_string(),
1110 name: "web-1".to_string(),
1111 ip: "1.2.3.4".to_string(),
1112 tags: vec!["nyc1".to_string()],
1113 }];
1114 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1115 assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1116
1117 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1119 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1120
1121 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1123 assert_eq!(result.unchanged, 1);
1124 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1125 }
1126
1127 #[test]
1128 fn test_sync_merges_new_provider_tag_with_user_tags() {
1129 let mut config = empty_config();
1130 let section = make_section();
1131
1132 let remote = vec![ProviderHost {
1134 server_id: "123".to_string(),
1135 name: "web-1".to_string(),
1136 ip: "1.2.3.4".to_string(),
1137 tags: vec!["nyc1".to_string()],
1138 }];
1139 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1140
1141 config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
1143
1144 let remote = vec![ProviderHost {
1146 server_id: "123".to_string(),
1147 name: "web-1".to_string(),
1148 ip: "1.2.3.4".to_string(),
1149 tags: vec!["nyc1".to_string(), "v2".to_string()],
1150 }];
1151 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1152 assert_eq!(result.updated, 1);
1153 let tags = &config.host_entries()[0].tags;
1154 assert!(tags.contains(&"nyc1".to_string()));
1155 assert!(tags.contains(&"critical".to_string()));
1156 assert!(tags.contains(&"v2".to_string()));
1157 }
1158
1159 #[test]
1160 fn test_sync_reset_tags_replaces_local_tags() {
1161 let mut config = empty_config();
1162 let section = make_section();
1163
1164 let remote = vec![ProviderHost {
1166 server_id: "123".to_string(),
1167 name: "web-1".to_string(),
1168 ip: "1.2.3.4".to_string(),
1169 tags: vec!["nyc1".to_string()],
1170 }];
1171 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1172
1173 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1175 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1176
1177 let result = sync_provider_with_options(
1179 &mut config, &MockProvider, &remote, §ion, false, false, true,
1180 );
1181 assert_eq!(result.updated, 1);
1182 assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1183 }
1184
1185 #[test]
1186 fn test_sync_reset_tags_clears_stale_tags() {
1187 let mut config = empty_config();
1188 let section = make_section();
1189
1190 let remote = vec![ProviderHost {
1192 server_id: "123".to_string(),
1193 name: "web-1".to_string(),
1194 ip: "1.2.3.4".to_string(),
1195 tags: vec!["staging".to_string()],
1196 }];
1197 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1198
1199 let remote = vec![ProviderHost {
1201 server_id: "123".to_string(),
1202 name: "web-1".to_string(),
1203 ip: "1.2.3.4".to_string(),
1204 tags: Vec::new(),
1205 }];
1206 let result = sync_provider_with_options(
1207 &mut config, &MockProvider, &remote, §ion, false, false, true,
1208 );
1209 assert_eq!(result.updated, 1);
1210 assert!(config.host_entries()[0].tags.is_empty());
1211 }
1212
1213 #[test]
1214 fn test_sync_reset_tags_unchanged_when_matching() {
1215 let mut config = empty_config();
1216 let section = make_section();
1217
1218 let remote = vec![ProviderHost {
1220 server_id: "123".to_string(),
1221 name: "web-1".to_string(),
1222 ip: "1.2.3.4".to_string(),
1223 tags: vec!["prod".to_string(), "nyc1".to_string()],
1224 }];
1225 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1226
1227 let remote = vec![ProviderHost {
1229 server_id: "123".to_string(),
1230 name: "web-1".to_string(),
1231 ip: "1.2.3.4".to_string(),
1232 tags: vec!["nyc1".to_string(), "prod".to_string()],
1233 }];
1234 let result = sync_provider_with_options(
1235 &mut config, &MockProvider, &remote, §ion, false, false, true,
1236 );
1237 assert_eq!(result.unchanged, 1);
1238 }
1239
1240 #[test]
1241 fn test_sync_merge_case_insensitive() {
1242 let mut config = empty_config();
1243 let section = make_section();
1244
1245 let remote = vec![ProviderHost {
1247 server_id: "123".to_string(),
1248 name: "web-1".to_string(),
1249 ip: "1.2.3.4".to_string(),
1250 tags: vec!["prod".to_string()],
1251 }];
1252 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1253 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1254
1255 let remote = vec![ProviderHost {
1257 server_id: "123".to_string(),
1258 name: "web-1".to_string(),
1259 ip: "1.2.3.4".to_string(),
1260 tags: vec!["Prod".to_string()],
1261 }];
1262 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1263 assert_eq!(result.unchanged, 1);
1264 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1265 }
1266
1267 #[test]
1268 fn test_sync_reset_tags_case_insensitive_unchanged() {
1269 let mut config = empty_config();
1270 let section = make_section();
1271
1272 let remote = vec![ProviderHost {
1274 server_id: "123".to_string(),
1275 name: "web-1".to_string(),
1276 ip: "1.2.3.4".to_string(),
1277 tags: vec!["prod".to_string()],
1278 }];
1279 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1280
1281 let remote = vec![ProviderHost {
1283 server_id: "123".to_string(),
1284 name: "web-1".to_string(),
1285 ip: "1.2.3.4".to_string(),
1286 tags: vec!["Prod".to_string()],
1287 }];
1288 let result = sync_provider_with_options(
1289 &mut config, &MockProvider, &remote, §ion, false, false, true,
1290 );
1291 assert_eq!(result.unchanged, 1);
1292 }
1293
1294 #[test]
1297 fn test_sync_empty_ip_not_added() {
1298 let mut config = empty_config();
1299 let section = make_section();
1300 let remote = vec![ProviderHost {
1301 server_id: "100".to_string(),
1302 name: "stopped-vm".to_string(),
1303 ip: String::new(),
1304 tags: Vec::new(),
1305 }];
1306 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1307 assert_eq!(result.added, 0);
1308 assert_eq!(config.host_entries().len(), 0);
1309 }
1310
1311 #[test]
1312 fn test_sync_empty_ip_existing_host_unchanged() {
1313 let mut config = empty_config();
1314 let section = make_section();
1315
1316 let remote = vec![ProviderHost {
1318 server_id: "100".to_string(),
1319 name: "web".to_string(),
1320 ip: "1.2.3.4".to_string(),
1321 tags: Vec::new(),
1322 }];
1323 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1324 assert_eq!(config.host_entries().len(), 1);
1325 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1326
1327 let remote = vec![ProviderHost {
1329 server_id: "100".to_string(),
1330 name: "web".to_string(),
1331 ip: String::new(),
1332 tags: Vec::new(),
1333 }];
1334 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1335 assert_eq!(result.unchanged, 1);
1336 assert_eq!(result.updated, 0);
1337 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1338 }
1339
1340 #[test]
1341 fn test_sync_remove_skips_empty_ip_hosts() {
1342 let mut config = empty_config();
1343 let section = make_section();
1344
1345 let remote = vec![
1347 ProviderHost {
1348 server_id: "100".to_string(),
1349 name: "web".to_string(),
1350 ip: "1.2.3.4".to_string(),
1351 tags: Vec::new(),
1352 },
1353 ProviderHost {
1354 server_id: "200".to_string(),
1355 name: "db".to_string(),
1356 ip: "5.6.7.8".to_string(),
1357 tags: Vec::new(),
1358 },
1359 ];
1360 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1361 assert_eq!(config.host_entries().len(), 2);
1362
1363 let remote = vec![
1366 ProviderHost {
1367 server_id: "100".to_string(),
1368 name: "web".to_string(),
1369 ip: "1.2.3.4".to_string(),
1370 tags: Vec::new(),
1371 },
1372 ProviderHost {
1373 server_id: "200".to_string(),
1374 name: "db".to_string(),
1375 ip: String::new(),
1376 tags: Vec::new(),
1377 },
1378 ];
1379 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1380 assert_eq!(result.removed, 0);
1381 assert_eq!(result.unchanged, 2);
1382 assert_eq!(config.host_entries().len(), 2);
1383 }
1384
1385 #[test]
1386 fn test_sync_remove_deletes_truly_gone_hosts() {
1387 let mut config = empty_config();
1388 let section = make_section();
1389
1390 let remote = vec![
1392 ProviderHost {
1393 server_id: "100".to_string(),
1394 name: "web".to_string(),
1395 ip: "1.2.3.4".to_string(),
1396 tags: Vec::new(),
1397 },
1398 ProviderHost {
1399 server_id: "200".to_string(),
1400 name: "db".to_string(),
1401 ip: "5.6.7.8".to_string(),
1402 tags: Vec::new(),
1403 },
1404 ];
1405 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1406 assert_eq!(config.host_entries().len(), 2);
1407
1408 let remote = vec![ProviderHost {
1410 server_id: "100".to_string(),
1411 name: "web".to_string(),
1412 ip: "1.2.3.4".to_string(),
1413 tags: Vec::new(),
1414 }];
1415 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1416 assert_eq!(result.removed, 1);
1417 assert_eq!(config.host_entries().len(), 1);
1418 assert_eq!(config.host_entries()[0].alias, "do-web");
1419 }
1420
1421 #[test]
1422 fn test_sync_mixed_resolved_empty_and_missing() {
1423 let mut config = empty_config();
1424 let section = make_section();
1425
1426 let remote = vec![
1428 ProviderHost {
1429 server_id: "1".to_string(),
1430 name: "running".to_string(),
1431 ip: "1.1.1.1".to_string(),
1432 tags: Vec::new(),
1433 },
1434 ProviderHost {
1435 server_id: "2".to_string(),
1436 name: "stopped".to_string(),
1437 ip: "2.2.2.2".to_string(),
1438 tags: Vec::new(),
1439 },
1440 ProviderHost {
1441 server_id: "3".to_string(),
1442 name: "deleted".to_string(),
1443 ip: "3.3.3.3".to_string(),
1444 tags: Vec::new(),
1445 },
1446 ];
1447 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1448 assert_eq!(config.host_entries().len(), 3);
1449
1450 let remote = vec![
1455 ProviderHost {
1456 server_id: "1".to_string(),
1457 name: "running".to_string(),
1458 ip: "9.9.9.9".to_string(),
1459 tags: Vec::new(),
1460 },
1461 ProviderHost {
1462 server_id: "2".to_string(),
1463 name: "stopped".to_string(),
1464 ip: String::new(),
1465 tags: Vec::new(),
1466 },
1467 ];
1468 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1469 assert_eq!(result.updated, 1);
1470 assert_eq!(result.unchanged, 1);
1471 assert_eq!(result.removed, 1);
1472
1473 let entries = config.host_entries();
1474 assert_eq!(entries.len(), 2);
1475 let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
1477 assert_eq!(running.hostname, "9.9.9.9");
1478 let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
1480 assert_eq!(stopped.hostname, "2.2.2.2");
1481 }
1482}