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
1483 #[test]
1488 fn test_sanitize_name_unicode() {
1489 assert_eq!(sanitize_name("서버-1"), "1");
1491 }
1492
1493 #[test]
1494 fn test_sanitize_name_numbers_only() {
1495 assert_eq!(sanitize_name("12345"), "12345");
1496 }
1497
1498 #[test]
1499 fn test_sanitize_name_mixed_special_chars() {
1500 assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
1501 }
1502
1503 #[test]
1504 fn test_sanitize_name_tabs_and_newlines() {
1505 assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
1506 }
1507
1508 #[test]
1509 fn test_sanitize_name_consecutive_specials() {
1510 assert_eq!(sanitize_name("a!!!b"), "a-b");
1511 }
1512
1513 #[test]
1514 fn test_sanitize_name_trailing_special() {
1515 assert_eq!(sanitize_name("web-"), "web");
1516 }
1517
1518 #[test]
1519 fn test_sanitize_name_leading_special() {
1520 assert_eq!(sanitize_name("-web"), "web");
1521 }
1522
1523 #[test]
1528 fn test_build_alias_prefix_with_hyphen() {
1529 assert_eq!(build_alias("do-", "web-1"), "do--web-1");
1532 }
1533
1534 #[test]
1535 fn test_build_alias_long_names() {
1536 assert_eq!(build_alias("my-provider", "my-very-long-server-name"), "my-provider-my-very-long-server-name");
1537 }
1538
1539 #[test]
1544 fn test_sync_applies_user_from_section() {
1545 let mut config = empty_config();
1546 let mut section = make_section();
1547 section.user = "admin".to_string();
1548 let remote = vec![ProviderHost {
1549 server_id: "1".to_string(),
1550 name: "web".to_string(),
1551 ip: "1.2.3.4".to_string(),
1552 tags: Vec::new(),
1553 }];
1554 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1555 let entries = config.host_entries();
1556 assert_eq!(entries[0].user, "admin");
1557 }
1558
1559 #[test]
1560 fn test_sync_applies_identity_file_from_section() {
1561 let mut config = empty_config();
1562 let mut section = make_section();
1563 section.identity_file = "~/.ssh/id_rsa".to_string();
1564 let remote = vec![ProviderHost {
1565 server_id: "1".to_string(),
1566 name: "web".to_string(),
1567 ip: "1.2.3.4".to_string(),
1568 tags: Vec::new(),
1569 }];
1570 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1571 let entries = config.host_entries();
1572 assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
1573 }
1574
1575 #[test]
1576 fn test_sync_empty_user_not_set() {
1577 let mut config = empty_config();
1578 let mut section = make_section();
1579 section.user = String::new(); let remote = vec![ProviderHost {
1581 server_id: "1".to_string(),
1582 name: "web".to_string(),
1583 ip: "1.2.3.4".to_string(),
1584 tags: Vec::new(),
1585 }];
1586 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1587 let entries = config.host_entries();
1588 assert!(entries[0].user.is_empty());
1589 }
1590
1591 #[test]
1596 fn test_sync_result_default() {
1597 let result = SyncResult::default();
1598 assert_eq!(result.added, 0);
1599 assert_eq!(result.updated, 0);
1600 assert_eq!(result.removed, 0);
1601 assert_eq!(result.unchanged, 0);
1602 assert!(result.renames.is_empty());
1603 }
1604
1605 #[test]
1610 fn test_sync_server_name_change_updates_alias() {
1611 let mut config = empty_config();
1612 let section = make_section();
1613 let remote = vec![ProviderHost {
1615 server_id: "1".to_string(),
1616 name: "old-name".to_string(),
1617 ip: "1.2.3.4".to_string(),
1618 tags: Vec::new(),
1619 }];
1620 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1621 assert_eq!(config.host_entries()[0].alias, "do-old-name");
1622
1623 let remote_renamed = vec![ProviderHost {
1625 server_id: "1".to_string(),
1626 name: "new-name".to_string(),
1627 ip: "1.2.3.4".to_string(),
1628 tags: Vec::new(),
1629 }];
1630 let result = sync_provider(&mut config, &MockProvider, &remote_renamed, §ion, false, false);
1631 assert!(!result.renames.is_empty() || result.updated > 0);
1633 }
1634
1635 #[test]
1636 fn test_sync_idempotent_same_data() {
1637 let mut config = empty_config();
1638 let section = make_section();
1639 let remote = vec![ProviderHost {
1640 server_id: "1".to_string(),
1641 name: "web".to_string(),
1642 ip: "1.2.3.4".to_string(),
1643 tags: vec!["prod".to_string()],
1644 }];
1645 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1646 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1647 assert_eq!(result.added, 0);
1648 assert_eq!(result.updated, 0);
1649 assert_eq!(result.unchanged, 1);
1650 }
1651
1652 #[test]
1657 fn test_sync_tag_merge_case_insensitive_no_duplicate() {
1658 let mut config = empty_config();
1659 let section = make_section();
1660 let remote = vec![ProviderHost {
1662 server_id: "1".to_string(),
1663 name: "web".to_string(),
1664 ip: "1.2.3.4".to_string(),
1665 tags: vec!["Prod".to_string()],
1666 }];
1667 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1668
1669 let remote2 = vec![ProviderHost {
1671 server_id: "1".to_string(),
1672 name: "web".to_string(),
1673 ip: "1.2.3.4".to_string(),
1674 tags: vec!["prod".to_string()],
1675 }];
1676 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1677 assert_eq!(result.unchanged, 1);
1678 assert_eq!(result.updated, 0);
1679 }
1680
1681 #[test]
1682 fn test_sync_tag_merge_adds_new_remote_tag() {
1683 let mut config = empty_config();
1684 let section = make_section();
1685 let remote = vec![ProviderHost {
1686 server_id: "1".to_string(),
1687 name: "web".to_string(),
1688 ip: "1.2.3.4".to_string(),
1689 tags: vec!["prod".to_string()],
1690 }];
1691 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1692
1693 let remote2 = vec![ProviderHost {
1695 server_id: "1".to_string(),
1696 name: "web".to_string(),
1697 ip: "1.2.3.4".to_string(),
1698 tags: vec!["prod".to_string(), "us-east".to_string()],
1699 }];
1700 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1701 assert_eq!(result.updated, 1);
1702
1703 let entries = config.host_entries();
1705 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1706 assert!(entry.tags.iter().any(|t| t == "prod"));
1707 assert!(entry.tags.iter().any(|t| t == "us-east"));
1708 }
1709
1710 #[test]
1711 fn test_sync_tag_merge_preserves_local_tags() {
1712 let mut config = empty_config();
1713 let section = make_section();
1714 let remote = vec![ProviderHost {
1715 server_id: "1".to_string(),
1716 name: "web".to_string(),
1717 ip: "1.2.3.4".to_string(),
1718 tags: vec!["prod".to_string()],
1719 }];
1720 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1721
1722 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1724
1725 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1727 assert_eq!(result.unchanged, 1);
1728 let entries = config.host_entries();
1729 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1730 assert!(entry.tags.iter().any(|t| t == "my-custom"));
1731 }
1732
1733 #[test]
1734 fn test_sync_reset_tags_replaces_local() {
1735 let mut config = empty_config();
1736 let section = make_section();
1737 let remote = vec![ProviderHost {
1738 server_id: "1".to_string(),
1739 name: "web".to_string(),
1740 ip: "1.2.3.4".to_string(),
1741 tags: vec!["prod".to_string()],
1742 }];
1743 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1744
1745 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1747
1748 let remote2 = vec![ProviderHost {
1750 server_id: "1".to_string(),
1751 name: "web".to_string(),
1752 ip: "1.2.3.4".to_string(),
1753 tags: vec!["prod".to_string(), "new-tag".to_string()],
1754 }];
1755 let result = sync_provider_with_options(&mut config, &MockProvider, &remote2, §ion, false, false, true);
1756 assert_eq!(result.updated, 1);
1757
1758 let entries = config.host_entries();
1759 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1760 assert!(entry.tags.iter().any(|t| t == "new-tag"));
1761 assert!(!entry.tags.iter().any(|t| t == "my-custom"));
1763 }
1764
1765 #[test]
1770 fn test_sync_rename_and_ip_change_simultaneously() {
1771 let mut config = empty_config();
1772 let section = make_section();
1773 let remote = vec![ProviderHost {
1774 server_id: "1".to_string(),
1775 name: "old-name".to_string(),
1776 ip: "1.2.3.4".to_string(),
1777 tags: Vec::new(),
1778 }];
1779 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1780
1781 let remote2 = vec![ProviderHost {
1783 server_id: "1".to_string(),
1784 name: "new-name".to_string(),
1785 ip: "9.8.7.6".to_string(),
1786 tags: Vec::new(),
1787 }];
1788 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1789 assert_eq!(result.updated, 1);
1790 assert_eq!(result.renames.len(), 1);
1791 assert_eq!(result.renames[0].0, "do-old-name");
1792 assert_eq!(result.renames[0].1, "do-new-name");
1793
1794 let entries = config.host_entries();
1795 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1796 assert_eq!(entry.hostname, "9.8.7.6");
1797 }
1798
1799 #[test]
1804 fn test_sync_duplicate_server_id_deduped() {
1805 let mut config = empty_config();
1806 let section = make_section();
1807 let remote = vec![
1808 ProviderHost {
1809 server_id: "1".to_string(),
1810 name: "web".to_string(),
1811 ip: "1.2.3.4".to_string(),
1812 tags: Vec::new(),
1813 },
1814 ProviderHost {
1815 server_id: "1".to_string(), name: "web-copy".to_string(),
1817 ip: "5.6.7.8".to_string(),
1818 tags: Vec::new(),
1819 },
1820 ];
1821 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1822 assert_eq!(result.added, 1); assert_eq!(config.host_entries().len(), 1);
1824 }
1825
1826 #[test]
1831 fn test_sync_remove_all_when_remote_empty() {
1832 let mut config = empty_config();
1833 let section = make_section();
1834 let remote = vec![
1835 ProviderHost {
1836 server_id: "1".to_string(),
1837 name: "web".to_string(),
1838 ip: "1.2.3.4".to_string(),
1839 tags: Vec::new(),
1840 },
1841 ProviderHost {
1842 server_id: "2".to_string(),
1843 name: "db".to_string(),
1844 ip: "5.6.7.8".to_string(),
1845 tags: Vec::new(),
1846 },
1847 ];
1848 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1849 assert_eq!(config.host_entries().len(), 2);
1850
1851 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1853 assert_eq!(result.removed, 2);
1854 assert_eq!(config.host_entries().len(), 0);
1855 }
1856
1857 #[test]
1862 fn test_sync_adds_group_header_on_first_host() {
1863 let mut config = empty_config();
1864 let section = make_section();
1865 let remote = vec![ProviderHost {
1866 server_id: "1".to_string(),
1867 name: "web".to_string(),
1868 ip: "1.2.3.4".to_string(),
1869 tags: Vec::new(),
1870 }];
1871 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1872
1873 let has_header = config.elements.iter().any(|e| {
1875 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1876 });
1877 assert!(has_header);
1878 }
1879
1880 #[test]
1881 fn test_sync_removes_header_when_all_hosts_deleted() {
1882 let mut config = empty_config();
1883 let section = make_section();
1884 let remote = vec![ProviderHost {
1885 server_id: "1".to_string(),
1886 name: "web".to_string(),
1887 ip: "1.2.3.4".to_string(),
1888 tags: Vec::new(),
1889 }];
1890 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1891
1892 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1894 assert_eq!(result.removed, 1);
1895
1896 let has_header = config.elements.iter().any(|e| {
1898 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1899 });
1900 assert!(!has_header);
1901 }
1902
1903 #[test]
1908 fn test_sync_identity_file_set_on_new_host() {
1909 let mut config = empty_config();
1910 let mut section = make_section();
1911 section.identity_file = "~/.ssh/do_key".to_string();
1912 let remote = vec![ProviderHost {
1913 server_id: "1".to_string(),
1914 name: "web".to_string(),
1915 ip: "1.2.3.4".to_string(),
1916 tags: Vec::new(),
1917 }];
1918 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1919 let entries = config.host_entries();
1920 assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
1921 }
1922
1923 #[test]
1928 fn test_sync_alias_collision_dedup() {
1929 let mut config = empty_config();
1930 let section = make_section();
1931 let remote = vec![
1933 ProviderHost {
1934 server_id: "1".to_string(),
1935 name: "web".to_string(),
1936 ip: "1.2.3.4".to_string(),
1937 tags: Vec::new(),
1938 },
1939 ProviderHost {
1940 server_id: "2".to_string(),
1941 name: "web".to_string(), ip: "5.6.7.8".to_string(),
1943 tags: Vec::new(),
1944 },
1945 ];
1946 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1947 assert_eq!(result.added, 2);
1948
1949 let entries = config.host_entries();
1950 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
1951 assert!(aliases.contains(&"do-web"));
1952 assert!(aliases.contains(&"do-web-2")); }
1954
1955 #[test]
1960 fn test_sync_empty_alias_prefix() {
1961 let mut config = empty_config();
1962 let mut section = make_section();
1963 section.alias_prefix = String::new();
1964 let remote = vec![ProviderHost {
1965 server_id: "1".to_string(),
1966 name: "web-1".to_string(),
1967 ip: "1.2.3.4".to_string(),
1968 tags: Vec::new(),
1969 }];
1970 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1971 let entries = config.host_entries();
1972 assert_eq!(entries[0].alias, "web-1"); }
1974
1975 #[test]
1980 fn test_sync_dry_run_add_count() {
1981 let mut config = empty_config();
1982 let section = make_section();
1983 let remote = vec![
1984 ProviderHost {
1985 server_id: "1".to_string(),
1986 name: "web".to_string(),
1987 ip: "1.2.3.4".to_string(),
1988 tags: Vec::new(),
1989 },
1990 ProviderHost {
1991 server_id: "2".to_string(),
1992 name: "db".to_string(),
1993 ip: "5.6.7.8".to_string(),
1994 tags: Vec::new(),
1995 },
1996 ];
1997 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
1998 assert_eq!(result.added, 2);
1999 assert_eq!(config.host_entries().len(), 0);
2001 }
2002
2003 #[test]
2004 fn test_sync_dry_run_remove_count_preserves_config() {
2005 let mut config = empty_config();
2006 let section = make_section();
2007 let remote = vec![ProviderHost {
2008 server_id: "1".to_string(),
2009 name: "web".to_string(),
2010 ip: "1.2.3.4".to_string(),
2011 tags: Vec::new(),
2012 }];
2013 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2014 assert_eq!(config.host_entries().len(), 1);
2015
2016 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
2018 assert_eq!(result.removed, 1);
2019 assert_eq!(config.host_entries().len(), 1);
2021 }
2022
2023 #[test]
2028 fn test_sync_result_counts_add_up() {
2029 let mut config = empty_config();
2030 let section = make_section();
2031 let remote = vec![
2033 ProviderHost { server_id: "1".to_string(), name: "a".to_string(), ip: "1.1.1.1".to_string(), tags: Vec::new() },
2034 ProviderHost { server_id: "2".to_string(), name: "b".to_string(), ip: "2.2.2.2".to_string(), tags: Vec::new() },
2035 ProviderHost { server_id: "3".to_string(), name: "c".to_string(), ip: "3.3.3.3".to_string(), tags: Vec::new() },
2036 ];
2037 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2038
2039 let remote2 = vec![
2041 ProviderHost { server_id: "1".to_string(), name: "a".to_string(), ip: "1.1.1.1".to_string(), tags: Vec::new() }, ProviderHost { server_id: "2".to_string(), name: "b".to_string(), ip: "9.9.9.9".to_string(), tags: Vec::new() }, ];
2045 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, true, false);
2046 assert_eq!(result.unchanged, 1);
2047 assert_eq!(result.updated, 1);
2048 assert_eq!(result.removed, 1);
2049 assert_eq!(result.added, 0);
2050 }
2051
2052 #[test]
2057 fn test_sync_multiple_renames() {
2058 let mut config = empty_config();
2059 let section = make_section();
2060 let remote = vec![
2061 ProviderHost { server_id: "1".to_string(), name: "old-a".to_string(), ip: "1.1.1.1".to_string(), tags: Vec::new() },
2062 ProviderHost { server_id: "2".to_string(), name: "old-b".to_string(), ip: "2.2.2.2".to_string(), tags: Vec::new() },
2063 ];
2064 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2065
2066 let remote2 = vec![
2067 ProviderHost { server_id: "1".to_string(), name: "new-a".to_string(), ip: "1.1.1.1".to_string(), tags: Vec::new() },
2068 ProviderHost { server_id: "2".to_string(), name: "new-b".to_string(), ip: "2.2.2.2".to_string(), tags: Vec::new() },
2069 ];
2070 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2071 assert_eq!(result.renames.len(), 2);
2072 assert_eq!(result.updated, 2);
2073 }
2074
2075 #[test]
2080 fn test_sync_tag_whitespace_trimmed_on_store() {
2081 let mut config = empty_config();
2082 let section = make_section();
2083 let remote = vec![ProviderHost {
2085 server_id: "1".to_string(),
2086 name: "web".to_string(),
2087 ip: "1.2.3.4".to_string(),
2088 tags: vec![" production ".to_string(), " us-east ".to_string()],
2089 }];
2090 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2091 let entries = config.host_entries();
2092 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
2094 }
2095
2096 #[test]
2097 fn test_sync_tag_trimmed_remote_triggers_merge() {
2098 let mut config = empty_config();
2099 let section = make_section();
2100 let remote = vec![ProviderHost {
2102 server_id: "1".to_string(),
2103 name: "web".to_string(),
2104 ip: "1.2.3.4".to_string(),
2105 tags: vec!["production".to_string()],
2106 }];
2107 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2108
2109 let remote2 = vec![ProviderHost {
2111 server_id: "1".to_string(),
2112 name: "web".to_string(),
2113 ip: "1.2.3.4".to_string(),
2114 tags: vec![" production ".to_string()], }];
2116 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2117 assert_eq!(result.unchanged, 1);
2119 }
2120
2121 struct MockProvider2;
2126 impl Provider for MockProvider2 {
2127 fn name(&self) -> &str {
2128 "vultr"
2129 }
2130 fn short_label(&self) -> &str {
2131 "vultr"
2132 }
2133 fn fetch_hosts_cancellable(
2134 &self,
2135 _token: &str,
2136 _cancel: &std::sync::atomic::AtomicBool,
2137 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
2138 Ok(Vec::new())
2139 }
2140 }
2141
2142 #[test]
2143 fn test_sync_two_providers_independent() {
2144 let mut config = empty_config();
2145
2146 let do_section = make_section(); let vultr_section = ProviderSection {
2148 provider: "vultr".to_string(),
2149 token: "test".to_string(),
2150 alias_prefix: "vultr".to_string(),
2151 user: String::new(),
2152 identity_file: String::new(),
2153 url: String::new(),
2154 verify_tls: true,
2155 auto_sync: true,
2156 };
2157
2158 let do_remote = vec![ProviderHost {
2160 server_id: "1".to_string(),
2161 name: "web".to_string(),
2162 ip: "1.2.3.4".to_string(),
2163 tags: Vec::new(),
2164 }];
2165 sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
2166
2167 let vultr_remote = vec![ProviderHost {
2169 server_id: "abc".to_string(),
2170 name: "web".to_string(),
2171 ip: "5.6.7.8".to_string(),
2172 tags: Vec::new(),
2173 }];
2174 sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
2175
2176 let entries = config.host_entries();
2177 assert_eq!(entries.len(), 2);
2178 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
2179 assert!(aliases.contains(&"do-web"));
2180 assert!(aliases.contains(&"vultr-web"));
2181 }
2182
2183 #[test]
2184 fn test_sync_remove_only_affects_own_provider() {
2185 let mut config = empty_config();
2186 let do_section = make_section();
2187 let vultr_section = ProviderSection {
2188 provider: "vultr".to_string(),
2189 token: "test".to_string(),
2190 alias_prefix: "vultr".to_string(),
2191 user: String::new(),
2192 identity_file: String::new(),
2193 url: String::new(),
2194 verify_tls: true,
2195 auto_sync: true,
2196 };
2197
2198 let do_remote = vec![ProviderHost {
2200 server_id: "1".to_string(),
2201 name: "web".to_string(),
2202 ip: "1.2.3.4".to_string(),
2203 tags: Vec::new(),
2204 }];
2205 sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
2206
2207 let vultr_remote = vec![ProviderHost {
2208 server_id: "abc".to_string(),
2209 name: "db".to_string(),
2210 ip: "5.6.7.8".to_string(),
2211 tags: Vec::new(),
2212 }];
2213 sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
2214 assert_eq!(config.host_entries().len(), 2);
2215
2216 let result = sync_provider(&mut config, &MockProvider, &[], &do_section, true, false);
2218 assert_eq!(result.removed, 1);
2219 let entries = config.host_entries();
2220 assert_eq!(entries.len(), 1);
2221 assert_eq!(entries[0].alias, "vultr-db");
2222 }
2223
2224 #[test]
2229 fn test_sync_rename_and_tag_change_simultaneously() {
2230 let mut config = empty_config();
2231 let section = make_section();
2232 let remote = vec![ProviderHost {
2233 server_id: "1".to_string(),
2234 name: "old-name".to_string(),
2235 ip: "1.2.3.4".to_string(),
2236 tags: vec!["staging".to_string()],
2237 }];
2238 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2239 assert_eq!(config.host_entries()[0].alias, "do-old-name");
2240 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
2241
2242 let remote2 = vec![ProviderHost {
2244 server_id: "1".to_string(),
2245 name: "new-name".to_string(),
2246 ip: "1.2.3.4".to_string(),
2247 tags: vec!["staging".to_string(), "prod".to_string()],
2248 }];
2249 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2250 assert_eq!(result.updated, 1);
2251 assert_eq!(result.renames.len(), 1);
2252
2253 let entries = config.host_entries();
2254 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
2255 assert!(entry.tags.contains(&"staging".to_string()));
2256 assert!(entry.tags.contains(&"prod".to_string()));
2257 }
2258
2259 #[test]
2264 fn test_sync_all_symbol_name_uses_server_fallback() {
2265 let mut config = empty_config();
2266 let section = make_section();
2267 let remote = vec![ProviderHost {
2268 server_id: "1".to_string(),
2269 name: "!!!".to_string(),
2270 ip: "1.2.3.4".to_string(),
2271 tags: Vec::new(),
2272 }];
2273 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2274 let entries = config.host_entries();
2275 assert_eq!(entries[0].alias, "do-server");
2276 }
2277
2278 #[test]
2279 fn test_sync_unicode_name_uses_ascii_fallback() {
2280 let mut config = empty_config();
2281 let section = make_section();
2282 let remote = vec![ProviderHost {
2283 server_id: "1".to_string(),
2284 name: "서버".to_string(),
2285 ip: "1.2.3.4".to_string(),
2286 tags: Vec::new(),
2287 }];
2288 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2289 let entries = config.host_entries();
2290 assert_eq!(entries[0].alias, "do-server");
2292 }
2293
2294 #[test]
2299 fn test_sync_dry_run_update_preserves_config() {
2300 let mut config = empty_config();
2301 let section = make_section();
2302 let remote = vec![ProviderHost {
2303 server_id: "1".to_string(),
2304 name: "web".to_string(),
2305 ip: "1.2.3.4".to_string(),
2306 tags: Vec::new(),
2307 }];
2308 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2309
2310 let remote2 = vec![ProviderHost {
2312 server_id: "1".to_string(),
2313 name: "web".to_string(),
2314 ip: "9.9.9.9".to_string(),
2315 tags: Vec::new(),
2316 }];
2317 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
2318 assert_eq!(result.updated, 1);
2319 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
2321 }
2322
2323 #[test]
2328 fn test_sync_empty_remote_empty_config_noop() {
2329 let mut config = empty_config();
2330 let section = make_section();
2331 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
2332 assert_eq!(result.added, 0);
2333 assert_eq!(result.updated, 0);
2334 assert_eq!(result.removed, 0);
2335 assert_eq!(result.unchanged, 0);
2336 assert!(config.host_entries().is_empty());
2337 }
2338
2339 #[test]
2344 fn test_sync_large_batch() {
2345 let mut config = empty_config();
2346 let section = make_section();
2347 let remote: Vec<ProviderHost> = (0..100)
2348 .map(|i| ProviderHost {
2349 server_id: format!("{}", i),
2350 name: format!("server-{}", i),
2351 ip: format!("10.0.0.{}", i % 256),
2352 tags: vec!["batch".to_string()],
2353 })
2354 .collect();
2355 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2356 assert_eq!(result.added, 100);
2357 assert_eq!(config.host_entries().len(), 100);
2358
2359 let result2 = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2361 assert_eq!(result2.unchanged, 100);
2362 assert_eq!(result2.added, 0);
2363 }
2364
2365 #[test]
2370 fn test_sync_rename_self_exclusion_no_collision() {
2371 let mut config = empty_config();
2374 let section = make_section();
2375 let remote = vec![ProviderHost {
2376 server_id: "1".to_string(),
2377 name: "web".to_string(),
2378 ip: "1.2.3.4".to_string(),
2379 tags: Vec::new(),
2380 }];
2381 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2382 assert_eq!(config.host_entries()[0].alias, "do-web");
2383
2384 let remote2 = vec![ProviderHost {
2386 server_id: "1".to_string(),
2387 name: "web".to_string(),
2388 ip: "9.9.9.9".to_string(),
2389 tags: Vec::new(),
2390 }];
2391 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2392 assert_eq!(result.updated, 1);
2393 assert!(result.renames.is_empty());
2394 assert_eq!(config.host_entries()[0].alias, "do-web"); }
2396
2397 #[test]
2402 fn test_sync_reset_tags_with_rename() {
2403 let mut config = empty_config();
2404 let section = make_section();
2405 let remote = vec![ProviderHost {
2406 server_id: "1".to_string(),
2407 name: "old-name".to_string(),
2408 ip: "1.2.3.4".to_string(),
2409 tags: vec!["staging".to_string()],
2410 }];
2411 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2412 config.set_host_tags("do-old-name", &["staging".to_string(), "custom".to_string()]);
2413
2414 let remote2 = vec![ProviderHost {
2416 server_id: "1".to_string(),
2417 name: "new-name".to_string(),
2418 ip: "1.2.3.4".to_string(),
2419 tags: vec!["production".to_string()],
2420 }];
2421 let result = sync_provider_with_options(
2422 &mut config, &MockProvider, &remote2, §ion, false, false, true,
2423 );
2424 assert_eq!(result.updated, 1);
2425 assert_eq!(result.renames.len(), 1);
2426
2427 let entries = config.host_entries();
2428 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
2429 assert_eq!(entry.tags, vec!["production"]);
2430 assert!(!entry.tags.contains(&"custom".to_string()));
2431 }
2432
2433 #[test]
2438 fn test_sync_empty_ip_with_tags_not_added() {
2439 let mut config = empty_config();
2440 let section = make_section();
2441 let remote = vec![ProviderHost {
2442 server_id: "1".to_string(),
2443 name: "stopped".to_string(),
2444 ip: String::new(),
2445 tags: vec!["prod".to_string()],
2446 }];
2447 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2448 assert_eq!(result.added, 0);
2449 assert!(config.host_entries().is_empty());
2450 }
2451
2452 #[test]
2457 fn test_sync_orphaned_provider_marker_counts_unchanged() {
2458 let content = "\
2463Host do-web
2464 HostName 1.2.3.4
2465 # purple:provider digitalocean:123
2466";
2467 let mut config = SshConfigFile {
2468 elements: SshConfigFile::parse_content(content),
2469 path: PathBuf::from("/tmp/test_config"),
2470 crlf: false,
2471 };
2472 let section = make_section();
2473 let remote = vec![ProviderHost {
2474 server_id: "123".to_string(),
2475 name: "web".to_string(),
2476 ip: "1.2.3.4".to_string(),
2477 tags: Vec::new(),
2478 }];
2479 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2480 assert_eq!(result.unchanged, 1);
2481 }
2482
2483 #[test]
2488 fn test_sync_no_double_blank_between_hosts() {
2489 let mut config = empty_config();
2490 let section = make_section();
2491 let remote = vec![
2492 ProviderHost {
2493 server_id: "1".to_string(),
2494 name: "web".to_string(),
2495 ip: "1.2.3.4".to_string(),
2496 tags: Vec::new(),
2497 },
2498 ProviderHost {
2499 server_id: "2".to_string(),
2500 name: "db".to_string(),
2501 ip: "5.6.7.8".to_string(),
2502 tags: Vec::new(),
2503 },
2504 ];
2505 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2506
2507 let mut prev_blank = false;
2509 for elem in &config.elements {
2510 if let ConfigElement::GlobalLine(line) = elem {
2511 let is_blank = line.trim().is_empty();
2512 assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
2513 prev_blank = is_blank;
2514 } else {
2515 prev_blank = false;
2516 }
2517 }
2518 }
2519
2520 #[test]
2525 fn test_sync_without_remove_flag_keeps_deleted() {
2526 let mut config = empty_config();
2527 let section = make_section();
2528 let remote = vec![ProviderHost {
2529 server_id: "1".to_string(),
2530 name: "web".to_string(),
2531 ip: "1.2.3.4".to_string(),
2532 tags: Vec::new(),
2533 }];
2534 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2535
2536 let result = sync_provider(&mut config, &MockProvider, &[], §ion, false, false);
2538 assert_eq!(result.removed, 0);
2539 assert_eq!(config.host_entries().len(), 1); }
2541
2542 #[test]
2547 fn test_sync_dry_run_rename_no_renames_tracked() {
2548 let mut config = empty_config();
2549 let section = make_section();
2550 let remote = vec![ProviderHost {
2551 server_id: "1".to_string(),
2552 name: "old".to_string(),
2553 ip: "1.2.3.4".to_string(),
2554 tags: Vec::new(),
2555 }];
2556 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2557
2558 let new_section = ProviderSection {
2559 alias_prefix: "ocean".to_string(),
2560 ..section
2561 };
2562 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
2563 assert_eq!(result.updated, 1);
2564 assert!(result.renames.is_empty());
2566 }
2567
2568 #[test]
2573 fn test_sanitize_name_whitespace_only() {
2574 assert_eq!(sanitize_name(" "), "server");
2575 }
2576
2577 #[test]
2578 fn test_sanitize_name_single_char() {
2579 assert_eq!(sanitize_name("a"), "a");
2580 assert_eq!(sanitize_name("Z"), "z");
2581 assert_eq!(sanitize_name("5"), "5");
2582 }
2583
2584 #[test]
2585 fn test_sanitize_name_single_special_char() {
2586 assert_eq!(sanitize_name("!"), "server");
2587 assert_eq!(sanitize_name("-"), "server");
2588 assert_eq!(sanitize_name("."), "server");
2589 }
2590
2591 #[test]
2592 fn test_sanitize_name_emoji() {
2593 assert_eq!(sanitize_name("server🚀"), "server");
2594 assert_eq!(sanitize_name("🔥hot🔥"), "hot");
2595 }
2596
2597 #[test]
2598 fn test_sanitize_name_long_mixed_separators() {
2599 assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
2600 }
2601
2602 #[test]
2603 fn test_sanitize_name_dots_and_underscores() {
2604 assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
2605 }
2606
2607 #[test]
2612 fn test_find_hosts_by_provider_in_includes() {
2613 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2614
2615 let include_content = "Host do-included\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n";
2616 let included_elements = SshConfigFile::parse_content(include_content);
2617
2618 let config = SshConfigFile {
2619 elements: vec![ConfigElement::Include(IncludeDirective {
2620 raw_line: "Include conf.d/*".to_string(),
2621 pattern: "conf.d/*".to_string(),
2622 resolved_files: vec![IncludedFile {
2623 path: PathBuf::from("/tmp/included.conf"),
2624 elements: included_elements,
2625 }],
2626 })],
2627 path: PathBuf::from("/tmp/test_config"),
2628 crlf: false,
2629 };
2630
2631 let hosts = config.find_hosts_by_provider("digitalocean");
2632 assert_eq!(hosts.len(), 1);
2633 assert_eq!(hosts[0].0, "do-included");
2634 assert_eq!(hosts[0].1, "inc1");
2635 }
2636
2637 #[test]
2638 fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
2639 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2640
2641 let top_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
2643 let top_elements = SshConfigFile::parse_content(top_content);
2644
2645 let inc_content = "Host do-db\n HostName 5.6.7.8\n # purple:provider digitalocean:2\n";
2647 let inc_elements = SshConfigFile::parse_content(inc_content);
2648
2649 let mut elements = top_elements;
2650 elements.push(ConfigElement::Include(IncludeDirective {
2651 raw_line: "Include conf.d/*".to_string(),
2652 pattern: "conf.d/*".to_string(),
2653 resolved_files: vec![IncludedFile {
2654 path: PathBuf::from("/tmp/included.conf"),
2655 elements: inc_elements,
2656 }],
2657 }));
2658
2659 let config = SshConfigFile {
2660 elements,
2661 path: PathBuf::from("/tmp/test_config"),
2662 crlf: false,
2663 };
2664
2665 let hosts = config.find_hosts_by_provider("digitalocean");
2666 assert_eq!(hosts.len(), 2);
2667 }
2668
2669 #[test]
2670 fn test_find_hosts_by_provider_empty_includes() {
2671 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2672
2673 let config = SshConfigFile {
2674 elements: vec![ConfigElement::Include(IncludeDirective {
2675 raw_line: "Include conf.d/*".to_string(),
2676 pattern: "conf.d/*".to_string(),
2677 resolved_files: vec![IncludedFile {
2678 path: PathBuf::from("/tmp/empty.conf"),
2679 elements: vec![],
2680 }],
2681 })],
2682 path: PathBuf::from("/tmp/test_config"),
2683 crlf: false,
2684 };
2685
2686 let hosts = config.find_hosts_by_provider("digitalocean");
2687 assert!(hosts.is_empty());
2688 }
2689
2690 #[test]
2691 fn test_find_hosts_by_provider_wrong_provider_name() {
2692 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
2693 let config = SshConfigFile {
2694 elements: SshConfigFile::parse_content(content),
2695 path: PathBuf::from("/tmp/test_config"),
2696 crlf: false,
2697 };
2698
2699 let hosts = config.find_hosts_by_provider("vultr");
2700 assert!(hosts.is_empty());
2701 }
2702
2703 #[test]
2708 fn test_deduplicate_alias_excluding_self() {
2709 let content = "Host do-web\n HostName 1.2.3.4\n";
2711 let config = SshConfigFile {
2712 elements: SshConfigFile::parse_content(content),
2713 path: PathBuf::from("/tmp/test_config"),
2714 crlf: false,
2715 };
2716
2717 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2718 assert_eq!(alias, "do-web"); }
2720
2721 #[test]
2722 fn test_deduplicate_alias_excluding_other() {
2723 let content = "Host do-web\n HostName 1.2.3.4\n";
2725 let config = SshConfigFile {
2726 elements: SshConfigFile::parse_content(content),
2727 path: PathBuf::from("/tmp/test_config"),
2728 crlf: false,
2729 };
2730
2731 let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
2732 assert_eq!(alias, "do-web-2"); }
2734
2735 #[test]
2736 fn test_deduplicate_alias_excluding_chain() {
2737 let content = "Host do-web\n HostName 1.1.1.1\n\nHost do-web-2\n HostName 2.2.2.2\n";
2739 let config = SshConfigFile {
2740 elements: SshConfigFile::parse_content(content),
2741 path: PathBuf::from("/tmp/test_config"),
2742 crlf: false,
2743 };
2744
2745 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2746 assert_eq!(alias, "do-web");
2748 }
2749
2750 #[test]
2751 fn test_deduplicate_alias_excluding_none() {
2752 let content = "Host do-web\n HostName 1.2.3.4\n";
2753 let config = SshConfigFile {
2754 elements: SshConfigFile::parse_content(content),
2755 path: PathBuf::from("/tmp/test_config"),
2756 crlf: false,
2757 };
2758
2759 let alias = config.deduplicate_alias_excluding("do-web", None);
2761 assert_eq!(alias, "do-web-2");
2762 }
2763
2764 #[test]
2769 fn test_set_host_tags_empty_clears_tags() {
2770 let content = "Host do-web\n HostName 1.2.3.4\n # purple:tags prod,staging\n";
2771 let mut config = SshConfigFile {
2772 elements: SshConfigFile::parse_content(content),
2773 path: PathBuf::from("/tmp/test_config"),
2774 crlf: false,
2775 };
2776
2777 config.set_host_tags("do-web", &[]);
2778 let entries = config.host_entries();
2779 assert!(entries[0].tags.is_empty());
2780 }
2781
2782 #[test]
2783 fn test_set_host_provider_updates_existing() {
2784 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:old-id\n";
2785 let mut config = SshConfigFile {
2786 elements: SshConfigFile::parse_content(content),
2787 path: PathBuf::from("/tmp/test_config"),
2788 crlf: false,
2789 };
2790
2791 config.set_host_provider("do-web", "digitalocean", "new-id");
2792 let hosts = config.find_hosts_by_provider("digitalocean");
2793 assert_eq!(hosts.len(), 1);
2794 assert_eq!(hosts[0].1, "new-id");
2795 }
2796
2797 #[test]
2802 fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
2803 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2804
2805 let include_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n";
2806 let included_elements = SshConfigFile::parse_content(include_content);
2807
2808 let mut config = SshConfigFile {
2809 elements: vec![ConfigElement::Include(IncludeDirective {
2810 raw_line: "Include conf.d/*".to_string(),
2811 pattern: "conf.d/*".to_string(),
2812 resolved_files: vec![IncludedFile {
2813 path: PathBuf::from("/tmp/included.conf"),
2814 elements: included_elements,
2815 }],
2816 })],
2817 path: PathBuf::from("/tmp/test_config"),
2818 crlf: false,
2819 };
2820
2821 let section = make_section();
2822 let remote = vec![ProviderHost {
2823 server_id: "123".to_string(),
2824 name: "web".to_string(),
2825 ip: "1.2.3.4".to_string(),
2826 tags: Vec::new(),
2827 }];
2828
2829 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2830 assert_eq!(result.unchanged, 1);
2831 assert_eq!(result.added, 0);
2832 let top_hosts = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
2834 assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
2835 }
2836
2837 #[test]
2842 fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
2843 let mut config = empty_config();
2844 let section = make_section();
2845
2846 let remote = vec![ProviderHost {
2848 server_id: "1".to_string(),
2849 name: "web".to_string(),
2850 ip: "1.2.3.4".to_string(),
2851 tags: Vec::new(),
2852 }];
2853 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2854 assert_eq!(config.host_entries()[0].alias, "do-web");
2855
2856 let other = vec![ProviderHost {
2858 server_id: "2".to_string(),
2859 name: "new-web".to_string(),
2860 ip: "5.5.5.5".to_string(),
2861 tags: Vec::new(),
2862 }];
2863 sync_provider(&mut config, &MockProvider, &other, §ion, false, false);
2864
2865 let remote_same = vec![
2873 ProviderHost {
2874 server_id: "1".to_string(),
2875 name: "web".to_string(),
2876 ip: "1.2.3.4".to_string(),
2877 tags: Vec::new(),
2878 },
2879 ProviderHost {
2880 server_id: "2".to_string(),
2881 name: "new-web".to_string(),
2882 ip: "5.5.5.5".to_string(),
2883 tags: Vec::new(),
2884 },
2885 ];
2886 let result = sync_provider(&mut config, &MockProvider, &remote_same, §ion, false, false);
2887 assert_eq!(result.unchanged, 2);
2888 assert_eq!(result.updated, 0);
2889 assert!(result.renames.is_empty());
2890 }
2891
2892 #[test]
2897 fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
2898 let mut config = empty_config();
2901 let section = make_section();
2902
2903 let remote = vec![
2904 ProviderHost {
2905 server_id: "1".to_string(),
2906 name: "web".to_string(),
2907 ip: "1.1.1.1".to_string(),
2908 tags: Vec::new(),
2909 },
2910 ProviderHost {
2911 server_id: "2".to_string(),
2912 name: "web".to_string(),
2913 ip: "2.2.2.2".to_string(),
2914 tags: Vec::new(),
2915 },
2916 ];
2917 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2918 assert_eq!(result.added, 2);
2919
2920 let entries = config.host_entries();
2921 assert_eq!(entries[0].alias, "do-web");
2922 assert_eq!(entries[1].alias, "do-web-2");
2923
2924 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2926 assert_eq!(result.unchanged, 2);
2927 }
2928
2929 #[test]
2934 fn test_sync_dry_run_remove_excludes_included_hosts() {
2935 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2936
2937 let include_content =
2938 "Host do-included\n HostName 1.1.1.1\n # purple:provider digitalocean:inc1\n";
2939 let included_elements = SshConfigFile::parse_content(include_content);
2940
2941 let mut config = SshConfigFile {
2943 elements: vec![ConfigElement::Include(IncludeDirective {
2944 raw_line: "Include conf.d/*".to_string(),
2945 pattern: "conf.d/*".to_string(),
2946 resolved_files: vec![IncludedFile {
2947 path: PathBuf::from("/tmp/included.conf"),
2948 elements: included_elements,
2949 }],
2950 })],
2951 path: PathBuf::from("/tmp/test_config"),
2952 crlf: false,
2953 };
2954
2955 let section = make_section();
2957 let remote = vec![ProviderHost {
2958 server_id: "top1".to_string(),
2959 name: "toplevel".to_string(),
2960 ip: "2.2.2.2".to_string(),
2961 tags: Vec::new(),
2962 }];
2963 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2964
2965 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
2968 assert_eq!(result.removed, 1, "Only top-level host counted in dry-run remove");
2969 }
2970
2971 #[test]
2976 fn test_sync_group_header_with_existing_trailing_blank() {
2977 let mut config = empty_config();
2978 config.elements.push(ConfigElement::GlobalLine("# some comment".to_string()));
2980 config.elements.push(ConfigElement::GlobalLine(String::new()));
2981
2982 let section = make_section();
2983 let remote = vec![ProviderHost {
2984 server_id: "1".to_string(),
2985 name: "web".to_string(),
2986 ip: "1.2.3.4".to_string(),
2987 tags: Vec::new(),
2988 }];
2989 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2990 assert_eq!(result.added, 1);
2991
2992 let blank_count = config
2995 .elements
2996 .iter()
2997 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
2998 .count();
2999 assert_eq!(blank_count, 1, "No extra blank line when one already exists");
3000 }
3001
3002 #[test]
3007 fn test_sync_no_group_header_for_second_host() {
3008 let mut config = empty_config();
3009 let section = make_section();
3010
3011 let remote = vec![ProviderHost {
3013 server_id: "1".to_string(),
3014 name: "web".to_string(),
3015 ip: "1.2.3.4".to_string(),
3016 tags: Vec::new(),
3017 }];
3018 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
3019
3020 let header_count_before = config
3021 .elements
3022 .iter()
3023 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
3024 .count();
3025 assert_eq!(header_count_before, 1);
3026
3027 let remote2 = vec![
3029 ProviderHost {
3030 server_id: "1".to_string(),
3031 name: "web".to_string(),
3032 ip: "1.2.3.4".to_string(),
3033 tags: Vec::new(),
3034 },
3035 ProviderHost {
3036 server_id: "2".to_string(),
3037 name: "db".to_string(),
3038 ip: "5.5.5.5".to_string(),
3039 tags: Vec::new(),
3040 },
3041 ];
3042 sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
3043
3044 let header_count_after = config
3046 .elements
3047 .iter()
3048 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
3049 .count();
3050 assert_eq!(header_count_after, 1, "No duplicate group header");
3051 }
3052
3053 #[test]
3058 fn test_sync_duplicate_server_id_in_remote_skipped() {
3059 let mut config = empty_config();
3060 let section = make_section();
3061
3062 let remote = vec![
3064 ProviderHost {
3065 server_id: "dup".to_string(),
3066 name: "first".to_string(),
3067 ip: "1.1.1.1".to_string(),
3068 tags: Vec::new(),
3069 },
3070 ProviderHost {
3071 server_id: "dup".to_string(),
3072 name: "second".to_string(),
3073 ip: "2.2.2.2".to_string(),
3074 tags: Vec::new(),
3075 },
3076 ];
3077 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
3078 assert_eq!(result.added, 1, "Only the first instance is added");
3079 assert_eq!(config.host_entries()[0].alias, "do-first");
3080 }
3081
3082 #[test]
3087 fn test_sync_empty_ip_existing_host_counted_unchanged() {
3088 let mut config = empty_config();
3089 let section = make_section();
3090
3091 let remote = vec![ProviderHost {
3093 server_id: "1".to_string(),
3094 name: "web".to_string(),
3095 ip: "1.2.3.4".to_string(),
3096 tags: Vec::new(),
3097 }];
3098 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
3099
3100 let remote2 = vec![ProviderHost {
3102 server_id: "1".to_string(),
3103 name: "web".to_string(),
3104 ip: String::new(),
3105 tags: Vec::new(),
3106 }];
3107 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
3108 assert_eq!(result.unchanged, 1);
3109 assert_eq!(result.removed, 0, "Host with empty IP not removed");
3110 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
3111 }
3112
3113 #[test]
3118 fn test_sync_reset_tags_case_insensitive_no_update() {
3119 let mut config = empty_config();
3120 let section = make_section();
3121
3122 let remote = vec![ProviderHost {
3123 server_id: "1".to_string(),
3124 name: "web".to_string(),
3125 ip: "1.2.3.4".to_string(),
3126 tags: vec!["Production".to_string()],
3127 }];
3128 sync_provider_with_options(
3129 &mut config, &MockProvider, &remote, §ion, false, false, true,
3130 );
3131
3132 let remote2 = vec![ProviderHost {
3134 server_id: "1".to_string(),
3135 name: "web".to_string(),
3136 ip: "1.2.3.4".to_string(),
3137 tags: vec!["production".to_string()],
3138 }];
3139 let result = sync_provider_with_options(
3140 &mut config, &MockProvider, &remote2, §ion, false, false, true,
3141 );
3142 assert_eq!(result.unchanged, 1, "Case-insensitive tag match = unchanged");
3143 }
3144
3145 #[test]
3150 fn test_sync_remove_cleans_up_group_header() {
3151 let mut config = empty_config();
3152 let section = make_section();
3153
3154 let remote = vec![ProviderHost {
3155 server_id: "1".to_string(),
3156 name: "web".to_string(),
3157 ip: "1.2.3.4".to_string(),
3158 tags: Vec::new(),
3159 }];
3160 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
3161
3162 let has_header = config.elements.iter().any(|e| {
3164 matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
3165 });
3166 assert!(has_header, "Group header present after add");
3167
3168 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
3170 assert_eq!(result.removed, 1);
3171
3172 let has_header_after = config.elements.iter().any(|e| {
3174 matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
3175 });
3176 assert!(!has_header_after, "Group header removed when all hosts gone");
3177 }
3178}