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 stale: usize,
17 pub renames: Vec<(String, String)>,
19}
20
21fn sanitize_name(name: &str) -> String {
25 let mut result = String::new();
26 for c in name.chars() {
27 if c.is_ascii_alphanumeric() {
28 result.push(c.to_ascii_lowercase());
29 } else if !result.ends_with('-') {
30 result.push('-');
31 }
32 }
33 let trimmed = result.trim_matches('-').to_string();
34 if trimmed.is_empty() {
35 "server".to_string()
36 } else {
37 trimmed
38 }
39}
40
41fn build_alias(prefix: &str, sanitized: &str) -> String {
44 if prefix.is_empty() {
45 sanitized.to_string()
46 } else {
47 format!("{}-{}", prefix, sanitized)
48 }
49}
50
51fn is_volatile_meta(key: &str) -> bool {
56 key == "status"
57}
58
59pub fn sync_provider(
63 config: &mut SshConfigFile,
64 provider: &dyn Provider,
65 remote_hosts: &[ProviderHost],
66 section: &ProviderSection,
67 remove_deleted: bool,
68 suppress_stale: bool,
69 dry_run: bool,
70) -> SyncResult {
71 let mut result = SyncResult::default();
72
73 let existing = config.find_hosts_by_provider(provider.name());
76 let mut existing_map: HashMap<String, String> = HashMap::new();
77 for (alias, server_id) in &existing {
78 existing_map
79 .entry(server_id.clone())
80 .or_insert_with(|| alias.clone());
81 }
82
83 let entries_map: HashMap<String, HostEntry> = config
85 .host_entries()
86 .into_iter()
87 .map(|e| (e.alias.clone(), e))
88 .collect();
89
90 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
92
93 let mut needs_header = !dry_run && existing_map.is_empty();
95
96 for remote in remote_hosts {
97 if !remote_ids.insert(remote.server_id.clone()) {
98 continue; }
100
101 if remote.ip.is_empty() {
106 if let Some(alias) = existing_map.get(&remote.server_id) {
107 if let Some(entry) = entries_map.get(alias.as_str()) {
108 if entry.stale.is_some() {
109 if !dry_run {
110 config.clear_host_stale(alias);
111 }
112 result.updated += 1;
113 continue;
114 }
115 }
116 result.unchanged += 1;
117 }
118 continue;
119 }
120
121 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
122 if let Some(entry) = entries_map.get(existing_alias) {
124 if entry.source_file.is_some() {
126 result.unchanged += 1;
127 continue;
128 }
129
130 let was_stale = entry.stale.is_some();
132 if was_stale && !dry_run {
133 config.clear_host_stale(existing_alias);
134 }
135
136 let sanitized = sanitize_name(&remote.name);
138 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
139 let alias_changed = *existing_alias != expected_alias;
140
141 let ip_changed = entry.hostname != remote.ip;
142 let meta_changed = {
143 let mut local: Vec<(&str, &str)> = entry
144 .provider_meta
145 .iter()
146 .filter(|(k, _)| !is_volatile_meta(k))
147 .map(|(k, v)| (k.as_str(), v.as_str()))
148 .collect();
149 local.sort();
150 let mut remote_m: Vec<(&str, &str)> = remote
151 .metadata
152 .iter()
153 .filter(|(k, _)| !is_volatile_meta(k))
154 .map(|(k, v)| (k.as_str(), v.as_str()))
155 .collect();
156 remote_m.sort();
157 local != remote_m
158 };
159 let trimmed_remote: Vec<String> =
160 remote.tags.iter().map(|t| t.trim().to_string()).collect();
161 let tags_changed = {
162 let mut sorted_local: Vec<String> = entry
164 .provider_tags
165 .iter()
166 .map(|t| t.trim().to_lowercase())
167 .collect();
168 sorted_local.sort();
169 let mut sorted_remote: Vec<String> =
170 trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
171 sorted_remote.sort();
172 sorted_local != sorted_remote
173 };
174 let first_migration = !entry.has_provider_tags && !entry.tags.is_empty();
177
178 let user_tags_overlap = !first_migration
180 && !trimmed_remote.is_empty()
181 && entry.tags.iter().any(|t| {
182 trimmed_remote
183 .iter()
184 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
185 });
186
187 if alias_changed
188 || ip_changed
189 || tags_changed
190 || meta_changed
191 || user_tags_overlap
192 || first_migration
193 || was_stale
194 {
195 if dry_run {
196 result.updated += 1;
197 } else {
198 let new_alias = if alias_changed {
201 config
202 .deduplicate_alias_excluding(&expected_alias, Some(existing_alias))
203 } else {
204 existing_alias.clone()
205 };
206 let alias_changed = new_alias != *existing_alias;
208
209 if alias_changed
210 || ip_changed
211 || tags_changed
212 || meta_changed
213 || user_tags_overlap
214 || first_migration
215 || was_stale
216 {
217 if alias_changed || ip_changed {
218 let updated = HostEntry {
219 alias: new_alias.clone(),
220 hostname: remote.ip.clone(),
221 ..entry.clone()
222 };
223 config.update_host(existing_alias, &updated);
224 }
225 let tags_alias = if alias_changed {
227 &new_alias
228 } else {
229 existing_alias
230 };
231 if tags_changed || first_migration {
232 config.set_host_provider_tags(tags_alias, &trimmed_remote);
233 }
234 if first_migration {
236 let user_only: Vec<String> = entry
241 .tags
242 .iter()
243 .filter(|t| {
244 !trimmed_remote
245 .iter()
246 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
247 })
248 .cloned()
249 .collect();
250 config.set_host_tags(tags_alias, &user_only);
251 } else if tags_changed || user_tags_overlap {
252 let cleaned: Vec<String> = entry
254 .tags
255 .iter()
256 .filter(|t| {
257 !trimmed_remote
258 .iter()
259 .any(|rt| rt.eq_ignore_ascii_case(t.trim()))
260 })
261 .cloned()
262 .collect();
263 if cleaned.len() != entry.tags.len() {
264 config.set_host_tags(tags_alias, &cleaned);
265 }
266 }
267 if alias_changed {
269 config.set_host_provider(
270 &new_alias,
271 provider.name(),
272 &remote.server_id,
273 );
274 result
275 .renames
276 .push((existing_alias.clone(), new_alias.clone()));
277 }
278 if meta_changed {
280 config.set_host_meta(tags_alias, &remote.metadata);
281 }
282 result.updated += 1;
283 } else {
284 result.unchanged += 1;
285 }
286 }
287 } else {
288 result.unchanged += 1;
289 }
290 } else {
291 result.unchanged += 1;
292 }
293 } else {
294 let sanitized = sanitize_name(&remote.name);
296 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
297 let alias = if dry_run {
298 base_alias
299 } else {
300 config.deduplicate_alias(&base_alias)
301 };
302
303 if !dry_run {
304 let wrote_header = needs_header;
306 if needs_header {
307 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
308 config
309 .elements
310 .push(ConfigElement::GlobalLine(String::new()));
311 }
312 config.elements.push(ConfigElement::GlobalLine(format!(
313 "# purple:group {}",
314 super::provider_display_name(provider.name())
315 )));
316 needs_header = false;
317 }
318
319 let entry = HostEntry {
320 alias: alias.clone(),
321 hostname: remote.ip.clone(),
322 user: section.user.clone(),
323 identity_file: section.identity_file.clone(),
324 provider: Some(provider.name().to_string()),
325 ..Default::default()
326 };
327
328 let block = SshConfigFile::entry_to_block(&entry);
329
330 let insert_pos = if !wrote_header {
333 config.find_provider_insert_position(provider.name())
334 } else {
335 None
336 };
337
338 if let Some(pos) = insert_pos {
339 config.elements.insert(pos, ConfigElement::HostBlock(block));
341 let after = pos + 1;
345 let needs_trailing_blank = config.elements.get(after).is_some_and(
346 |e| !matches!(e, ConfigElement::GlobalLine(line) if line.trim().is_empty()),
347 );
348 if needs_trailing_blank {
349 config
350 .elements
351 .insert(after, ConfigElement::GlobalLine(String::new()));
352 }
353 } else {
354 if !wrote_header
356 && !config.elements.is_empty()
357 && !config.last_element_has_trailing_blank()
358 {
359 config
360 .elements
361 .push(ConfigElement::GlobalLine(String::new()));
362 }
363 config.elements.push(ConfigElement::HostBlock(block));
364 }
365
366 config.set_host_provider(&alias, provider.name(), &remote.server_id);
367 if !remote.tags.is_empty() {
368 config.set_host_provider_tags(&alias, &remote.tags);
369 }
370 if !remote.metadata.is_empty() {
371 config.set_host_meta(&alias, &remote.metadata);
372 }
373 }
374
375 result.added += 1;
376 }
377 }
378
379 if remove_deleted && !dry_run {
381 let to_remove: Vec<String> = existing_map
382 .iter()
383 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
384 .filter(|(_, alias)| {
385 entries_map
386 .get(alias.as_str())
387 .is_none_or(|e| e.source_file.is_none())
388 })
389 .map(|(_, alias)| alias.clone())
390 .collect();
391 for alias in &to_remove {
392 config.delete_host(alias);
393 }
394 result.removed = to_remove.len();
395
396 if config.find_hosts_by_provider(provider.name()).is_empty() {
398 let header_text = format!(
399 "# purple:group {}",
400 super::provider_display_name(provider.name())
401 );
402 config
403 .elements
404 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
405 }
406 } else if remove_deleted {
407 result.removed = existing_map
408 .iter()
409 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
410 .filter(|(_, alias)| {
411 entries_map
412 .get(alias.as_str())
413 .is_none_or(|e| e.source_file.is_none())
414 })
415 .count();
416 }
417
418 if !remove_deleted && !suppress_stale {
420 let to_stale: Vec<String> = existing_map
421 .iter()
422 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
423 .filter(|(_, alias)| {
424 entries_map
425 .get(alias.as_str())
426 .is_none_or(|e| e.source_file.is_none())
427 })
428 .map(|(_, alias)| alias.clone())
429 .collect();
430 if !dry_run {
431 let now = std::time::SystemTime::now()
432 .duration_since(std::time::UNIX_EPOCH)
433 .unwrap_or_default()
434 .as_secs();
435 for alias in &to_stale {
436 if entries_map
438 .get(alias.as_str())
439 .is_none_or(|e| e.stale.is_none())
440 {
441 config.set_host_stale(alias, now);
442 }
443 }
444 }
445 result.stale = to_stale.len();
446 }
447
448 result
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use std::path::PathBuf;
455
456 fn empty_config() -> SshConfigFile {
457 SshConfigFile {
458 elements: Vec::new(),
459 path: PathBuf::from("/tmp/test_config"),
460 crlf: false,
461 bom: false,
462 }
463 }
464
465 fn make_section() -> ProviderSection {
466 ProviderSection {
467 provider: "digitalocean".to_string(),
468 token: "test".to_string(),
469 alias_prefix: "do".to_string(),
470 user: "root".to_string(),
471 identity_file: String::new(),
472 url: String::new(),
473 verify_tls: true,
474 auto_sync: true,
475 profile: String::new(),
476 regions: String::new(),
477 project: String::new(),
478 compartment: String::new(),
479 vault_role: String::new(),
480 vault_addr: String::new(),
481 }
482 }
483
484 struct MockProvider;
485 impl Provider for MockProvider {
486 fn name(&self) -> &str {
487 "digitalocean"
488 }
489 fn short_label(&self) -> &str {
490 "do"
491 }
492 fn fetch_hosts_cancellable(
493 &self,
494 _token: &str,
495 _cancel: &std::sync::atomic::AtomicBool,
496 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
497 Ok(Vec::new())
498 }
499 }
500
501 #[test]
502 fn test_build_alias() {
503 assert_eq!(build_alias("do", "web-1"), "do-web-1");
504 assert_eq!(build_alias("", "web-1"), "web-1");
505 assert_eq!(build_alias("ocean", "db"), "ocean-db");
506 }
507
508 #[test]
509 fn test_sanitize_name() {
510 assert_eq!(sanitize_name("web-1"), "web-1");
511 assert_eq!(sanitize_name("My Server"), "my-server");
512 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
513 assert_eq!(sanitize_name("--weird--"), "weird");
514 assert_eq!(sanitize_name("UPPER"), "upper");
515 assert_eq!(sanitize_name("a--b"), "a-b");
516 assert_eq!(sanitize_name(""), "server");
517 assert_eq!(sanitize_name("..."), "server");
518 }
519
520 #[test]
521 fn test_sync_adds_new_hosts() {
522 let mut config = empty_config();
523 let section = make_section();
524 let remote = vec![
525 ProviderHost::new(
526 "123".to_string(),
527 "web-1".to_string(),
528 "1.2.3.4".to_string(),
529 Vec::new(),
530 ),
531 ProviderHost::new(
532 "456".to_string(),
533 "db-1".to_string(),
534 "5.6.7.8".to_string(),
535 Vec::new(),
536 ),
537 ];
538
539 let result = sync_provider(
540 &mut config,
541 &MockProvider,
542 &remote,
543 §ion,
544 false,
545 false,
546 false,
547 );
548 assert_eq!(result.added, 2);
549 assert_eq!(result.updated, 0);
550 assert_eq!(result.unchanged, 0);
551
552 let entries = config.host_entries();
553 assert_eq!(entries.len(), 2);
554 assert_eq!(entries[0].alias, "do-web-1");
555 assert_eq!(entries[0].hostname, "1.2.3.4");
556 assert_eq!(entries[1].alias, "do-db-1");
557 }
558
559 #[test]
560 fn test_sync_updates_changed_ip() {
561 let mut config = empty_config();
562 let section = make_section();
563
564 let remote = vec![ProviderHost::new(
566 "123".to_string(),
567 "web-1".to_string(),
568 "1.2.3.4".to_string(),
569 Vec::new(),
570 )];
571 sync_provider(
572 &mut config,
573 &MockProvider,
574 &remote,
575 §ion,
576 false,
577 false,
578 false,
579 );
580
581 let remote = vec![ProviderHost::new(
583 "123".to_string(),
584 "web-1".to_string(),
585 "9.8.7.6".to_string(),
586 Vec::new(),
587 )];
588 let result = sync_provider(
589 &mut config,
590 &MockProvider,
591 &remote,
592 §ion,
593 false,
594 false,
595 false,
596 );
597 assert_eq!(result.updated, 1);
598 assert_eq!(result.added, 0);
599
600 let entries = config.host_entries();
601 assert_eq!(entries[0].hostname, "9.8.7.6");
602 }
603
604 #[test]
605 fn test_sync_unchanged() {
606 let mut config = empty_config();
607 let section = make_section();
608
609 let remote = vec![ProviderHost::new(
610 "123".to_string(),
611 "web-1".to_string(),
612 "1.2.3.4".to_string(),
613 Vec::new(),
614 )];
615 sync_provider(
616 &mut config,
617 &MockProvider,
618 &remote,
619 §ion,
620 false,
621 false,
622 false,
623 );
624
625 let result = sync_provider(
627 &mut config,
628 &MockProvider,
629 &remote,
630 §ion,
631 false,
632 false,
633 false,
634 );
635 assert_eq!(result.unchanged, 1);
636 assert_eq!(result.added, 0);
637 assert_eq!(result.updated, 0);
638 }
639
640 #[test]
641 fn test_sync_removes_deleted() {
642 let mut config = empty_config();
643 let section = make_section();
644
645 let remote = vec![ProviderHost::new(
646 "123".to_string(),
647 "web-1".to_string(),
648 "1.2.3.4".to_string(),
649 Vec::new(),
650 )];
651 sync_provider(
652 &mut config,
653 &MockProvider,
654 &remote,
655 §ion,
656 false,
657 false,
658 false,
659 );
660 assert_eq!(config.host_entries().len(), 1);
661
662 let result = sync_provider(
664 &mut config,
665 &MockProvider,
666 &[],
667 §ion,
668 true,
669 false,
670 false,
671 );
672 assert_eq!(result.removed, 1);
673 assert_eq!(config.host_entries().len(), 0);
674 }
675
676 #[test]
677 fn test_sync_dry_run_no_mutations() {
678 let mut config = empty_config();
679 let section = make_section();
680
681 let remote = vec![ProviderHost::new(
682 "123".to_string(),
683 "web-1".to_string(),
684 "1.2.3.4".to_string(),
685 Vec::new(),
686 )];
687
688 let result = sync_provider(
689 &mut config,
690 &MockProvider,
691 &remote,
692 §ion,
693 false,
694 false,
695 true,
696 );
697 assert_eq!(result.added, 1);
698 assert_eq!(config.host_entries().len(), 0); }
700
701 #[test]
702 fn test_sync_dedup_server_id_in_response() {
703 let mut config = empty_config();
704 let section = make_section();
705 let remote = vec![
706 ProviderHost::new(
707 "123".to_string(),
708 "web-1".to_string(),
709 "1.2.3.4".to_string(),
710 Vec::new(),
711 ),
712 ProviderHost::new(
713 "123".to_string(),
714 "web-1-dup".to_string(),
715 "5.6.7.8".to_string(),
716 Vec::new(),
717 ),
718 ];
719
720 let result = sync_provider(
721 &mut config,
722 &MockProvider,
723 &remote,
724 §ion,
725 false,
726 false,
727 false,
728 );
729 assert_eq!(result.added, 1);
730 assert_eq!(config.host_entries().len(), 1);
731 assert_eq!(config.host_entries()[0].alias, "do-web-1");
732 }
733
734 #[test]
735 fn test_sync_duplicate_local_server_id_keeps_first() {
736 let content = "\
738Host do-web-1
739 HostName 1.2.3.4
740 # purple:provider digitalocean:123
741
742Host do-web-1-copy
743 HostName 1.2.3.4
744 # purple:provider digitalocean:123
745";
746 let mut config = SshConfigFile {
747 elements: SshConfigFile::parse_content(content),
748 path: PathBuf::from("/tmp/test_config"),
749 crlf: false,
750 bom: false,
751 };
752 let section = make_section();
753
754 let remote = vec![ProviderHost::new(
756 "123".to_string(),
757 "web-1".to_string(),
758 "5.6.7.8".to_string(),
759 Vec::new(),
760 )];
761
762 let result = sync_provider(
763 &mut config,
764 &MockProvider,
765 &remote,
766 §ion,
767 false,
768 false,
769 false,
770 );
771 assert_eq!(result.updated, 1);
773 assert_eq!(result.added, 0);
774 let entries = config.host_entries();
775 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
776 assert_eq!(first.hostname, "5.6.7.8");
777 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
779 assert_eq!(copy.hostname, "1.2.3.4");
780 }
781
782 #[test]
783 fn test_sync_no_duplicate_header_on_repeated_sync() {
784 let mut config = empty_config();
785 let section = make_section();
786
787 let remote = vec![ProviderHost::new(
789 "123".to_string(),
790 "web-1".to_string(),
791 "1.2.3.4".to_string(),
792 Vec::new(),
793 )];
794 sync_provider(
795 &mut config,
796 &MockProvider,
797 &remote,
798 §ion,
799 false,
800 false,
801 false,
802 );
803
804 let remote = vec![
806 ProviderHost::new(
807 "123".to_string(),
808 "web-1".to_string(),
809 "1.2.3.4".to_string(),
810 Vec::new(),
811 ),
812 ProviderHost::new(
813 "456".to_string(),
814 "db-1".to_string(),
815 "5.6.7.8".to_string(),
816 Vec::new(),
817 ),
818 ];
819 sync_provider(
820 &mut config,
821 &MockProvider,
822 &remote,
823 §ion,
824 false,
825 false,
826 false,
827 );
828
829 let header_count = config
831 .elements
832 .iter()
833 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
834 .count();
835 assert_eq!(header_count, 1);
836 assert_eq!(config.host_entries().len(), 2);
837 }
838
839 #[test]
840 fn test_sync_removes_orphan_header() {
841 let mut config = empty_config();
842 let section = make_section();
843
844 let remote = vec![ProviderHost::new(
846 "123".to_string(),
847 "web-1".to_string(),
848 "1.2.3.4".to_string(),
849 Vec::new(),
850 )];
851 sync_provider(
852 &mut config,
853 &MockProvider,
854 &remote,
855 §ion,
856 false,
857 false,
858 false,
859 );
860
861 let has_header = config
863 .elements
864 .iter()
865 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
866 assert!(has_header);
867
868 let result = sync_provider(
870 &mut config,
871 &MockProvider,
872 &[],
873 §ion,
874 true,
875 false,
876 false,
877 );
878 assert_eq!(result.removed, 1);
879
880 let has_header = config
882 .elements
883 .iter()
884 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
885 assert!(!has_header);
886 }
887
888 #[test]
889 fn test_sync_writes_provider_tags() {
890 let mut config = empty_config();
891 let section = make_section();
892 let remote = vec![ProviderHost::new(
893 "123".to_string(),
894 "web-1".to_string(),
895 "1.2.3.4".to_string(),
896 vec!["production".to_string(), "us-east".to_string()],
897 )];
898
899 sync_provider(
900 &mut config,
901 &MockProvider,
902 &remote,
903 §ion,
904 false,
905 false,
906 false,
907 );
908
909 let entries = config.host_entries();
910 assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
911 }
912
913 #[test]
914 fn test_sync_updates_changed_tags() {
915 let mut config = empty_config();
916 let section = make_section();
917
918 let remote = vec![ProviderHost::new(
920 "123".to_string(),
921 "web-1".to_string(),
922 "1.2.3.4".to_string(),
923 vec!["staging".to_string()],
924 )];
925 sync_provider(
926 &mut config,
927 &MockProvider,
928 &remote,
929 §ion,
930 false,
931 false,
932 false,
933 );
934 assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
935
936 let remote = vec![ProviderHost::new(
938 "123".to_string(),
939 "web-1".to_string(),
940 "1.2.3.4".to_string(),
941 vec!["production".to_string(), "us-east".to_string()],
942 )];
943 let result = sync_provider(
944 &mut config,
945 &MockProvider,
946 &remote,
947 §ion,
948 false,
949 false,
950 false,
951 );
952 assert_eq!(result.updated, 1);
953 assert_eq!(
954 config.host_entries()[0].provider_tags,
955 vec!["production", "us-east"]
956 );
957 }
958
959 #[test]
960 fn test_sync_combined_add_update_remove() {
961 let mut config = empty_config();
962 let section = make_section();
963
964 let remote = vec![
966 ProviderHost::new(
967 "1".to_string(),
968 "web".to_string(),
969 "1.1.1.1".to_string(),
970 Vec::new(),
971 ),
972 ProviderHost::new(
973 "2".to_string(),
974 "db".to_string(),
975 "2.2.2.2".to_string(),
976 Vec::new(),
977 ),
978 ];
979 sync_provider(
980 &mut config,
981 &MockProvider,
982 &remote,
983 §ion,
984 false,
985 false,
986 false,
987 );
988 assert_eq!(config.host_entries().len(), 2);
989
990 let remote = vec![
992 ProviderHost::new(
993 "1".to_string(),
994 "web".to_string(),
995 "9.9.9.9".to_string(),
996 Vec::new(),
997 ),
998 ProviderHost::new(
999 "3".to_string(),
1000 "cache".to_string(),
1001 "3.3.3.3".to_string(),
1002 Vec::new(),
1003 ),
1004 ];
1005 let result = sync_provider(
1006 &mut config,
1007 &MockProvider,
1008 &remote,
1009 §ion,
1010 true,
1011 false,
1012 false,
1013 );
1014 assert_eq!(result.updated, 1);
1015 assert_eq!(result.added, 1);
1016 assert_eq!(result.removed, 1);
1017
1018 let entries = config.host_entries();
1019 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
1021 assert_eq!(entries[0].hostname, "9.9.9.9");
1022 assert_eq!(entries[1].alias, "do-cache");
1023 }
1024
1025 #[test]
1026 fn test_sync_tag_order_insensitive() {
1027 let mut config = empty_config();
1028 let section = make_section();
1029
1030 let remote = vec![ProviderHost::new(
1032 "123".to_string(),
1033 "web-1".to_string(),
1034 "1.2.3.4".to_string(),
1035 vec!["beta".to_string(), "alpha".to_string()],
1036 )];
1037 sync_provider(
1038 &mut config,
1039 &MockProvider,
1040 &remote,
1041 §ion,
1042 false,
1043 false,
1044 false,
1045 );
1046
1047 let remote = vec![ProviderHost::new(
1049 "123".to_string(),
1050 "web-1".to_string(),
1051 "1.2.3.4".to_string(),
1052 vec!["alpha".to_string(), "beta".to_string()],
1053 )];
1054 let result = sync_provider(
1055 &mut config,
1056 &MockProvider,
1057 &remote,
1058 §ion,
1059 false,
1060 false,
1061 false,
1062 );
1063 assert_eq!(result.unchanged, 1);
1064 assert_eq!(result.updated, 0);
1065 }
1066
1067 fn config_with_include_provider_host() -> SshConfigFile {
1068 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
1069
1070 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
1072 let included_elements = SshConfigFile::parse_content(content);
1073
1074 SshConfigFile {
1075 elements: vec![ConfigElement::Include(IncludeDirective {
1076 raw_line: "Include conf.d/*".to_string(),
1077 pattern: "conf.d/*".to_string(),
1078 resolved_files: vec![IncludedFile {
1079 path: PathBuf::from("/tmp/included.conf"),
1080 elements: included_elements,
1081 }],
1082 })],
1083 path: PathBuf::from("/tmp/test_config"),
1084 crlf: false,
1085 bom: false,
1086 }
1087 }
1088
1089 #[test]
1090 fn test_sync_include_host_skips_update() {
1091 let mut config = config_with_include_provider_host();
1092 let section = make_section();
1093
1094 let remote = vec![ProviderHost::new(
1096 "inc1".to_string(),
1097 "included".to_string(),
1098 "9.9.9.9".to_string(),
1099 Vec::new(),
1100 )];
1101 let result = sync_provider(
1102 &mut config,
1103 &MockProvider,
1104 &remote,
1105 §ion,
1106 false,
1107 false,
1108 false,
1109 );
1110 assert_eq!(result.unchanged, 1);
1111 assert_eq!(result.updated, 0);
1112 assert_eq!(result.added, 0);
1113
1114 let entries = config.host_entries();
1116 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
1117 assert_eq!(included.hostname, "1.2.3.4");
1118 }
1119
1120 #[test]
1121 fn test_sync_include_host_skips_remove() {
1122 let mut config = config_with_include_provider_host();
1123 let section = make_section();
1124
1125 let result = sync_provider(
1127 &mut config,
1128 &MockProvider,
1129 &[],
1130 §ion,
1131 true,
1132 false,
1133 false,
1134 );
1135 assert_eq!(result.removed, 0);
1136 assert_eq!(config.host_entries().len(), 1);
1137 }
1138
1139 #[test]
1140 fn test_sync_dry_run_remove_count() {
1141 let mut config = empty_config();
1142 let section = make_section();
1143
1144 let remote = vec![
1146 ProviderHost::new(
1147 "1".to_string(),
1148 "web".to_string(),
1149 "1.1.1.1".to_string(),
1150 Vec::new(),
1151 ),
1152 ProviderHost::new(
1153 "2".to_string(),
1154 "db".to_string(),
1155 "2.2.2.2".to_string(),
1156 Vec::new(),
1157 ),
1158 ];
1159 sync_provider(
1160 &mut config,
1161 &MockProvider,
1162 &remote,
1163 §ion,
1164 false,
1165 false,
1166 false,
1167 );
1168 assert_eq!(config.host_entries().len(), 2);
1169
1170 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
1172 assert_eq!(result.removed, 2);
1173 assert_eq!(config.host_entries().len(), 2); }
1175
1176 #[test]
1177 fn test_sync_tags_cleared_remotely_preserved_locally() {
1178 let mut config = empty_config();
1179 let section = make_section();
1180
1181 let remote = vec![ProviderHost::new(
1183 "123".to_string(),
1184 "web-1".to_string(),
1185 "1.2.3.4".to_string(),
1186 vec!["production".to_string()],
1187 )];
1188 sync_provider(
1189 &mut config,
1190 &MockProvider,
1191 &remote,
1192 §ion,
1193 false,
1194 false,
1195 false,
1196 );
1197 assert_eq!(config.host_entries()[0].provider_tags, vec!["production"]);
1198
1199 let remote = vec![ProviderHost::new(
1201 "123".to_string(),
1202 "web-1".to_string(),
1203 "1.2.3.4".to_string(),
1204 Vec::new(),
1205 )];
1206 let result = sync_provider(
1207 &mut config,
1208 &MockProvider,
1209 &remote,
1210 §ion,
1211 false,
1212 false,
1213 false,
1214 );
1215 assert_eq!(result.updated, 1);
1216 assert!(config.host_entries()[0].provider_tags.is_empty());
1217 }
1218
1219 #[test]
1220 fn test_sync_deduplicates_alias() {
1221 let content = "Host do-web-1\n HostName 10.0.0.1\n";
1222 let mut config = SshConfigFile {
1223 elements: SshConfigFile::parse_content(content),
1224 path: PathBuf::from("/tmp/test_config"),
1225 crlf: false,
1226 bom: false,
1227 };
1228 let section = make_section();
1229
1230 let remote = vec![ProviderHost::new(
1231 "999".to_string(),
1232 "web-1".to_string(),
1233 "1.2.3.4".to_string(),
1234 Vec::new(),
1235 )];
1236
1237 sync_provider(
1238 &mut config,
1239 &MockProvider,
1240 &remote,
1241 §ion,
1242 false,
1243 false,
1244 false,
1245 );
1246
1247 let entries = config.host_entries();
1248 assert_eq!(entries.len(), 2);
1250 assert_eq!(entries[0].alias, "do-web-1");
1251 assert_eq!(entries[1].alias, "do-web-1-2");
1252 }
1253
1254 #[test]
1255 fn test_sync_renames_on_prefix_change() {
1256 let mut config = empty_config();
1257 let section = make_section(); let remote = vec![ProviderHost::new(
1261 "123".to_string(),
1262 "web-1".to_string(),
1263 "1.2.3.4".to_string(),
1264 Vec::new(),
1265 )];
1266 sync_provider(
1267 &mut config,
1268 &MockProvider,
1269 &remote,
1270 §ion,
1271 false,
1272 false,
1273 false,
1274 );
1275 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1276
1277 let new_section = ProviderSection {
1279 alias_prefix: "ocean".to_string(),
1280 ..section
1281 };
1282 let result = sync_provider(
1283 &mut config,
1284 &MockProvider,
1285 &remote,
1286 &new_section,
1287 false,
1288 false,
1289 false,
1290 );
1291 assert_eq!(result.updated, 1);
1292 assert_eq!(result.unchanged, 0);
1293
1294 let entries = config.host_entries();
1295 assert_eq!(entries.len(), 1);
1296 assert_eq!(entries[0].alias, "ocean-web-1");
1297 assert_eq!(entries[0].hostname, "1.2.3.4");
1298 }
1299
1300 #[test]
1301 fn test_sync_rename_and_ip_change() {
1302 let mut config = empty_config();
1303 let section = make_section();
1304
1305 let remote = vec![ProviderHost::new(
1306 "123".to_string(),
1307 "web-1".to_string(),
1308 "1.2.3.4".to_string(),
1309 Vec::new(),
1310 )];
1311 sync_provider(
1312 &mut config,
1313 &MockProvider,
1314 &remote,
1315 §ion,
1316 false,
1317 false,
1318 false,
1319 );
1320
1321 let new_section = ProviderSection {
1323 alias_prefix: "ocean".to_string(),
1324 ..section
1325 };
1326 let remote = vec![ProviderHost::new(
1327 "123".to_string(),
1328 "web-1".to_string(),
1329 "9.9.9.9".to_string(),
1330 Vec::new(),
1331 )];
1332 let result = sync_provider(
1333 &mut config,
1334 &MockProvider,
1335 &remote,
1336 &new_section,
1337 false,
1338 false,
1339 false,
1340 );
1341 assert_eq!(result.updated, 1);
1342
1343 let entries = config.host_entries();
1344 assert_eq!(entries[0].alias, "ocean-web-1");
1345 assert_eq!(entries[0].hostname, "9.9.9.9");
1346 }
1347
1348 #[test]
1349 fn test_sync_rename_dry_run_no_mutation() {
1350 let mut config = empty_config();
1351 let section = make_section();
1352
1353 let remote = vec![ProviderHost::new(
1354 "123".to_string(),
1355 "web-1".to_string(),
1356 "1.2.3.4".to_string(),
1357 Vec::new(),
1358 )];
1359 sync_provider(
1360 &mut config,
1361 &MockProvider,
1362 &remote,
1363 §ion,
1364 false,
1365 false,
1366 false,
1367 );
1368
1369 let new_section = ProviderSection {
1370 alias_prefix: "ocean".to_string(),
1371 ..section
1372 };
1373 let result = sync_provider(
1374 &mut config,
1375 &MockProvider,
1376 &remote,
1377 &new_section,
1378 false,
1379 false,
1380 true,
1381 );
1382 assert_eq!(result.updated, 1);
1383
1384 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1386 }
1387
1388 #[test]
1389 fn test_sync_no_rename_when_prefix_unchanged() {
1390 let mut config = empty_config();
1391 let section = make_section();
1392
1393 let remote = vec![ProviderHost::new(
1394 "123".to_string(),
1395 "web-1".to_string(),
1396 "1.2.3.4".to_string(),
1397 Vec::new(),
1398 )];
1399 sync_provider(
1400 &mut config,
1401 &MockProvider,
1402 &remote,
1403 §ion,
1404 false,
1405 false,
1406 false,
1407 );
1408
1409 let result = sync_provider(
1411 &mut config,
1412 &MockProvider,
1413 &remote,
1414 §ion,
1415 false,
1416 false,
1417 false,
1418 );
1419 assert_eq!(result.unchanged, 1);
1420 assert_eq!(result.updated, 0);
1421 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1422 }
1423
1424 #[test]
1425 fn test_sync_manual_comment_survives_cleanup() {
1426 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
1429 let mut config = SshConfigFile {
1430 elements: SshConfigFile::parse_content(content),
1431 path: PathBuf::from("/tmp/test_config"),
1432 crlf: false,
1433 bom: false,
1434 };
1435 let section = make_section();
1436
1437 sync_provider(
1439 &mut config,
1440 &MockProvider,
1441 &[],
1442 §ion,
1443 true,
1444 false,
1445 false,
1446 );
1447
1448 let has_manual = config
1450 .elements
1451 .iter()
1452 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
1453 assert!(
1454 has_manual,
1455 "Manual comment without purple:group prefix should survive cleanup"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_sync_rename_skips_included_host() {
1461 let mut config = config_with_include_provider_host();
1462
1463 let new_section = ProviderSection {
1464 provider: "digitalocean".to_string(),
1465 token: "test".to_string(),
1466 alias_prefix: "ocean".to_string(), user: "root".to_string(),
1468 identity_file: String::new(),
1469 url: String::new(),
1470 verify_tls: true,
1471 auto_sync: true,
1472 profile: String::new(),
1473 regions: String::new(),
1474 project: String::new(),
1475 compartment: String::new(),
1476 vault_role: String::new(),
1477 vault_addr: String::new(),
1478 };
1479
1480 let remote = vec![ProviderHost::new(
1482 "inc1".to_string(),
1483 "included".to_string(),
1484 "1.2.3.4".to_string(),
1485 Vec::new(),
1486 )];
1487 let result = sync_provider(
1488 &mut config,
1489 &MockProvider,
1490 &remote,
1491 &new_section,
1492 false,
1493 false,
1494 false,
1495 );
1496 assert_eq!(result.unchanged, 1);
1497 assert_eq!(result.updated, 0);
1498
1499 assert_eq!(config.host_entries()[0].alias, "do-included");
1501 }
1502
1503 #[test]
1504 fn test_sync_rename_stable_with_manual_collision() {
1505 let mut config = empty_config();
1506 let section = make_section(); let remote = vec![ProviderHost::new(
1510 "123".to_string(),
1511 "web-1".to_string(),
1512 "1.2.3.4".to_string(),
1513 Vec::new(),
1514 )];
1515 sync_provider(
1516 &mut config,
1517 &MockProvider,
1518 &remote,
1519 §ion,
1520 false,
1521 false,
1522 false,
1523 );
1524 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1525
1526 let manual = HostEntry {
1528 alias: "ocean-web-1".to_string(),
1529 hostname: "5.5.5.5".to_string(),
1530 ..Default::default()
1531 };
1532 config.add_host(&manual);
1533
1534 let new_section = ProviderSection {
1536 alias_prefix: "ocean".to_string(),
1537 ..section.clone()
1538 };
1539 let result = sync_provider(
1540 &mut config,
1541 &MockProvider,
1542 &remote,
1543 &new_section,
1544 false,
1545 false,
1546 false,
1547 );
1548 assert_eq!(result.updated, 1);
1549
1550 let entries = config.host_entries();
1551 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1552 assert_eq!(provider_host.alias, "ocean-web-1-2");
1553
1554 let result = sync_provider(
1556 &mut config,
1557 &MockProvider,
1558 &remote,
1559 &new_section,
1560 false,
1561 false,
1562 false,
1563 );
1564 assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1565
1566 let entries = config.host_entries();
1567 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1568 assert_eq!(
1569 provider_host.alias, "ocean-web-1-2",
1570 "Alias should be stable across syncs"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_sync_preserves_user_tags() {
1576 let mut config = empty_config();
1577 let section = make_section();
1578
1579 let remote = vec![ProviderHost::new(
1581 "123".to_string(),
1582 "web-1".to_string(),
1583 "1.2.3.4".to_string(),
1584 vec!["nyc1".to_string()],
1585 )];
1586 sync_provider(
1587 &mut config,
1588 &MockProvider,
1589 &remote,
1590 §ion,
1591 false,
1592 false,
1593 false,
1594 );
1595 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1596
1597 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1599 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1600
1601 let result = sync_provider(
1603 &mut config,
1604 &MockProvider,
1605 &remote,
1606 §ion,
1607 false,
1608 false,
1609 false,
1610 );
1611 assert_eq!(result.updated, 1);
1612 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1613 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1615 }
1616
1617 #[test]
1618 fn test_sync_merges_new_provider_tag_with_user_tags() {
1619 let mut config = empty_config();
1620 let section = make_section();
1621
1622 let remote = vec![ProviderHost::new(
1624 "123".to_string(),
1625 "web-1".to_string(),
1626 "1.2.3.4".to_string(),
1627 vec!["nyc1".to_string()],
1628 )];
1629 sync_provider(
1630 &mut config,
1631 &MockProvider,
1632 &remote,
1633 §ion,
1634 false,
1635 false,
1636 false,
1637 );
1638
1639 config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
1641
1642 let remote = vec![ProviderHost::new(
1644 "123".to_string(),
1645 "web-1".to_string(),
1646 "1.2.3.4".to_string(),
1647 vec!["nyc1".to_string(), "v2".to_string()],
1648 )];
1649 let result = sync_provider(
1650 &mut config,
1651 &MockProvider,
1652 &remote,
1653 §ion,
1654 false,
1655 false,
1656 false,
1657 );
1658 assert_eq!(result.updated, 1);
1659 let ptags = &config.host_entries()[0].provider_tags;
1661 assert!(ptags.contains(&"nyc1".to_string()));
1662 assert!(ptags.contains(&"v2".to_string()));
1663 let tags = &config.host_entries()[0].tags;
1665 assert!(tags.contains(&"critical".to_string()));
1666 assert!(!tags.contains(&"nyc1".to_string()));
1667 }
1668
1669 #[test]
1670 fn test_sync_migration_cleans_overlapping_user_tags() {
1671 let mut config = empty_config();
1672 let section = make_section();
1673
1674 let remote = vec![ProviderHost::new(
1676 "123".to_string(),
1677 "web-1".to_string(),
1678 "1.2.3.4".to_string(),
1679 vec!["nyc1".to_string()],
1680 )];
1681 sync_provider(
1682 &mut config,
1683 &MockProvider,
1684 &remote,
1685 §ion,
1686 false,
1687 false,
1688 false,
1689 );
1690
1691 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1693 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1694
1695 let result = sync_provider(
1697 &mut config,
1698 &MockProvider,
1699 &remote,
1700 §ion,
1701 false,
1702 false,
1703 false,
1704 );
1705 assert_eq!(result.updated, 1);
1706 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1707 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1709 }
1710
1711 #[test]
1712 fn test_sync_provider_tags_cleared_remotely() {
1713 let mut config = empty_config();
1714 let section = make_section();
1715
1716 let remote = vec![ProviderHost::new(
1718 "123".to_string(),
1719 "web-1".to_string(),
1720 "1.2.3.4".to_string(),
1721 vec!["staging".to_string()],
1722 )];
1723 sync_provider(
1724 &mut config,
1725 &MockProvider,
1726 &remote,
1727 §ion,
1728 false,
1729 false,
1730 false,
1731 );
1732
1733 let remote = vec![ProviderHost::new(
1735 "123".to_string(),
1736 "web-1".to_string(),
1737 "1.2.3.4".to_string(),
1738 Vec::new(),
1739 )];
1740 let result = sync_provider(
1741 &mut config,
1742 &MockProvider,
1743 &remote,
1744 §ion,
1745 false,
1746 false,
1747 false,
1748 );
1749 assert_eq!(result.updated, 1);
1750 assert!(config.host_entries()[0].tags.is_empty());
1751 }
1752
1753 #[test]
1754 fn test_sync_provider_tags_cleared_user_tags_survive() {
1755 let mut config = empty_config();
1756 let section = make_section();
1757
1758 let remote = vec![ProviderHost::new(
1760 "123".to_string(),
1761 "web-1".to_string(),
1762 "1.2.3.4".to_string(),
1763 vec!["staging".to_string()],
1764 )];
1765 sync_provider(
1766 &mut config,
1767 &MockProvider,
1768 &remote,
1769 §ion,
1770 false,
1771 false,
1772 false,
1773 );
1774
1775 config.set_host_tags("do-web-1", &["my-custom".to_string()]);
1777
1778 let remote = vec![ProviderHost::new(
1780 "123".to_string(),
1781 "web-1".to_string(),
1782 "1.2.3.4".to_string(),
1783 Vec::new(),
1784 )];
1785 let result = sync_provider(
1786 &mut config,
1787 &MockProvider,
1788 &remote,
1789 §ion,
1790 false,
1791 false,
1792 false,
1793 );
1794 assert_eq!(result.updated, 1);
1795 assert!(config.host_entries()[0].provider_tags.is_empty());
1796 assert_eq!(config.host_entries()[0].tags, vec!["my-custom"]);
1798 }
1799
1800 #[test]
1801 fn test_sync_provider_tags_exact_match_unchanged() {
1802 let mut config = empty_config();
1803 let section = make_section();
1804
1805 let remote = vec![ProviderHost::new(
1807 "123".to_string(),
1808 "web-1".to_string(),
1809 "1.2.3.4".to_string(),
1810 vec!["prod".to_string(), "nyc1".to_string()],
1811 )];
1812 sync_provider(
1813 &mut config,
1814 &MockProvider,
1815 &remote,
1816 §ion,
1817 false,
1818 false,
1819 false,
1820 );
1821
1822 let remote = vec![ProviderHost::new(
1824 "123".to_string(),
1825 "web-1".to_string(),
1826 "1.2.3.4".to_string(),
1827 vec!["nyc1".to_string(), "prod".to_string()],
1828 )];
1829 let result = sync_provider(
1830 &mut config,
1831 &MockProvider,
1832 &remote,
1833 §ion,
1834 false,
1835 false,
1836 false,
1837 );
1838 assert_eq!(result.unchanged, 1);
1839 }
1840
1841 #[test]
1842 fn test_sync_merge_case_insensitive() {
1843 let mut config = empty_config();
1844 let section = make_section();
1845
1846 let remote = vec![ProviderHost::new(
1848 "123".to_string(),
1849 "web-1".to_string(),
1850 "1.2.3.4".to_string(),
1851 vec!["prod".to_string()],
1852 )];
1853 sync_provider(
1854 &mut config,
1855 &MockProvider,
1856 &remote,
1857 §ion,
1858 false,
1859 false,
1860 false,
1861 );
1862 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1863
1864 let remote = vec![ProviderHost::new(
1866 "123".to_string(),
1867 "web-1".to_string(),
1868 "1.2.3.4".to_string(),
1869 vec!["Prod".to_string()],
1870 )];
1871 let result = sync_provider(
1872 &mut config,
1873 &MockProvider,
1874 &remote,
1875 §ion,
1876 false,
1877 false,
1878 false,
1879 );
1880 assert_eq!(result.unchanged, 1);
1881 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1882 }
1883
1884 #[test]
1885 fn test_sync_provider_tags_case_insensitive_unchanged() {
1886 let mut config = empty_config();
1887 let section = make_section();
1888
1889 let remote = vec![ProviderHost::new(
1891 "123".to_string(),
1892 "web-1".to_string(),
1893 "1.2.3.4".to_string(),
1894 vec!["prod".to_string()],
1895 )];
1896 sync_provider(
1897 &mut config,
1898 &MockProvider,
1899 &remote,
1900 §ion,
1901 false,
1902 false,
1903 false,
1904 );
1905
1906 let remote = vec![ProviderHost::new(
1908 "123".to_string(),
1909 "web-1".to_string(),
1910 "1.2.3.4".to_string(),
1911 vec!["Prod".to_string()],
1912 )];
1913 let result = sync_provider(
1914 &mut config,
1915 &MockProvider,
1916 &remote,
1917 §ion,
1918 false,
1919 false,
1920 false,
1921 );
1922 assert_eq!(result.unchanged, 1);
1923 }
1924
1925 #[test]
1928 fn test_sync_empty_ip_not_added() {
1929 let mut config = empty_config();
1930 let section = make_section();
1931 let remote = vec![ProviderHost::new(
1932 "100".to_string(),
1933 "stopped-vm".to_string(),
1934 String::new(),
1935 Vec::new(),
1936 )];
1937 let result = sync_provider(
1938 &mut config,
1939 &MockProvider,
1940 &remote,
1941 §ion,
1942 false,
1943 false,
1944 false,
1945 );
1946 assert_eq!(result.added, 0);
1947 assert_eq!(config.host_entries().len(), 0);
1948 }
1949
1950 #[test]
1951 fn test_sync_empty_ip_existing_host_unchanged() {
1952 let mut config = empty_config();
1953 let section = make_section();
1954
1955 let remote = vec![ProviderHost::new(
1957 "100".to_string(),
1958 "web".to_string(),
1959 "1.2.3.4".to_string(),
1960 Vec::new(),
1961 )];
1962 sync_provider(
1963 &mut config,
1964 &MockProvider,
1965 &remote,
1966 §ion,
1967 false,
1968 false,
1969 false,
1970 );
1971 assert_eq!(config.host_entries().len(), 1);
1972 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1973
1974 let remote = vec![ProviderHost::new(
1976 "100".to_string(),
1977 "web".to_string(),
1978 String::new(),
1979 Vec::new(),
1980 )];
1981 let result = sync_provider(
1982 &mut config,
1983 &MockProvider,
1984 &remote,
1985 §ion,
1986 false,
1987 false,
1988 false,
1989 );
1990 assert_eq!(result.unchanged, 1);
1991 assert_eq!(result.updated, 0);
1992 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1993 }
1994
1995 #[test]
1996 fn test_sync_remove_skips_empty_ip_hosts() {
1997 let mut config = empty_config();
1998 let section = make_section();
1999
2000 let remote = vec![
2002 ProviderHost::new(
2003 "100".to_string(),
2004 "web".to_string(),
2005 "1.2.3.4".to_string(),
2006 Vec::new(),
2007 ),
2008 ProviderHost::new(
2009 "200".to_string(),
2010 "db".to_string(),
2011 "5.6.7.8".to_string(),
2012 Vec::new(),
2013 ),
2014 ];
2015 sync_provider(
2016 &mut config,
2017 &MockProvider,
2018 &remote,
2019 §ion,
2020 false,
2021 false,
2022 false,
2023 );
2024 assert_eq!(config.host_entries().len(), 2);
2025
2026 let remote = vec![
2029 ProviderHost::new(
2030 "100".to_string(),
2031 "web".to_string(),
2032 "1.2.3.4".to_string(),
2033 Vec::new(),
2034 ),
2035 ProviderHost::new(
2036 "200".to_string(),
2037 "db".to_string(),
2038 String::new(),
2039 Vec::new(),
2040 ),
2041 ];
2042 let result = sync_provider(
2043 &mut config,
2044 &MockProvider,
2045 &remote,
2046 §ion,
2047 true,
2048 false,
2049 false,
2050 );
2051 assert_eq!(result.removed, 0);
2052 assert_eq!(result.unchanged, 2);
2053 assert_eq!(config.host_entries().len(), 2);
2054 }
2055
2056 #[test]
2057 fn test_sync_remove_deletes_truly_gone_hosts() {
2058 let mut config = empty_config();
2059 let section = make_section();
2060
2061 let remote = vec![
2063 ProviderHost::new(
2064 "100".to_string(),
2065 "web".to_string(),
2066 "1.2.3.4".to_string(),
2067 Vec::new(),
2068 ),
2069 ProviderHost::new(
2070 "200".to_string(),
2071 "db".to_string(),
2072 "5.6.7.8".to_string(),
2073 Vec::new(),
2074 ),
2075 ];
2076 sync_provider(
2077 &mut config,
2078 &MockProvider,
2079 &remote,
2080 §ion,
2081 false,
2082 false,
2083 false,
2084 );
2085 assert_eq!(config.host_entries().len(), 2);
2086
2087 let remote = vec![ProviderHost::new(
2089 "100".to_string(),
2090 "web".to_string(),
2091 "1.2.3.4".to_string(),
2092 Vec::new(),
2093 )];
2094 let result = sync_provider(
2095 &mut config,
2096 &MockProvider,
2097 &remote,
2098 §ion,
2099 true,
2100 false,
2101 false,
2102 );
2103 assert_eq!(result.removed, 1);
2104 assert_eq!(config.host_entries().len(), 1);
2105 assert_eq!(config.host_entries()[0].alias, "do-web");
2106 }
2107
2108 #[test]
2109 fn test_sync_mixed_resolved_empty_and_missing() {
2110 let mut config = empty_config();
2111 let section = make_section();
2112
2113 let remote = vec![
2115 ProviderHost::new(
2116 "1".to_string(),
2117 "running".to_string(),
2118 "1.1.1.1".to_string(),
2119 Vec::new(),
2120 ),
2121 ProviderHost::new(
2122 "2".to_string(),
2123 "stopped".to_string(),
2124 "2.2.2.2".to_string(),
2125 Vec::new(),
2126 ),
2127 ProviderHost::new(
2128 "3".to_string(),
2129 "deleted".to_string(),
2130 "3.3.3.3".to_string(),
2131 Vec::new(),
2132 ),
2133 ];
2134 sync_provider(
2135 &mut config,
2136 &MockProvider,
2137 &remote,
2138 §ion,
2139 false,
2140 false,
2141 false,
2142 );
2143 assert_eq!(config.host_entries().len(), 3);
2144
2145 let remote = vec![
2150 ProviderHost::new(
2151 "1".to_string(),
2152 "running".to_string(),
2153 "9.9.9.9".to_string(),
2154 Vec::new(),
2155 ),
2156 ProviderHost::new(
2157 "2".to_string(),
2158 "stopped".to_string(),
2159 String::new(),
2160 Vec::new(),
2161 ),
2162 ];
2163 let result = sync_provider(
2164 &mut config,
2165 &MockProvider,
2166 &remote,
2167 §ion,
2168 true,
2169 false,
2170 false,
2171 );
2172 assert_eq!(result.updated, 1);
2173 assert_eq!(result.unchanged, 1);
2174 assert_eq!(result.removed, 1);
2175
2176 let entries = config.host_entries();
2177 assert_eq!(entries.len(), 2);
2178 let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
2180 assert_eq!(running.hostname, "9.9.9.9");
2181 let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
2183 assert_eq!(stopped.hostname, "2.2.2.2");
2184 }
2185
2186 #[test]
2191 fn test_sanitize_name_unicode() {
2192 assert_eq!(sanitize_name("서버-1"), "1");
2194 }
2195
2196 #[test]
2197 fn test_sanitize_name_numbers_only() {
2198 assert_eq!(sanitize_name("12345"), "12345");
2199 }
2200
2201 #[test]
2202 fn test_sanitize_name_mixed_special_chars() {
2203 assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
2204 }
2205
2206 #[test]
2207 fn test_sanitize_name_tabs_and_newlines() {
2208 assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
2209 }
2210
2211 #[test]
2212 fn test_sanitize_name_consecutive_specials() {
2213 assert_eq!(sanitize_name("a!!!b"), "a-b");
2214 }
2215
2216 #[test]
2217 fn test_sanitize_name_trailing_special() {
2218 assert_eq!(sanitize_name("web-"), "web");
2219 }
2220
2221 #[test]
2222 fn test_sanitize_name_leading_special() {
2223 assert_eq!(sanitize_name("-web"), "web");
2224 }
2225
2226 #[test]
2231 fn test_build_alias_prefix_with_hyphen() {
2232 assert_eq!(build_alias("do-", "web-1"), "do--web-1");
2235 }
2236
2237 #[test]
2238 fn test_build_alias_long_names() {
2239 assert_eq!(
2240 build_alias("my-provider", "my-very-long-server-name"),
2241 "my-provider-my-very-long-server-name"
2242 );
2243 }
2244
2245 #[test]
2250 fn test_sync_applies_user_from_section() {
2251 let mut config = empty_config();
2252 let mut section = make_section();
2253 section.user = "admin".to_string();
2254 let remote = vec![ProviderHost::new(
2255 "1".to_string(),
2256 "web".to_string(),
2257 "1.2.3.4".to_string(),
2258 Vec::new(),
2259 )];
2260 sync_provider(
2261 &mut config,
2262 &MockProvider,
2263 &remote,
2264 §ion,
2265 false,
2266 false,
2267 false,
2268 );
2269 let entries = config.host_entries();
2270 assert_eq!(entries[0].user, "admin");
2271 }
2272
2273 #[test]
2274 fn test_sync_applies_identity_file_from_section() {
2275 let mut config = empty_config();
2276 let mut section = make_section();
2277 section.identity_file = "~/.ssh/id_rsa".to_string();
2278 let remote = vec![ProviderHost::new(
2279 "1".to_string(),
2280 "web".to_string(),
2281 "1.2.3.4".to_string(),
2282 Vec::new(),
2283 )];
2284 sync_provider(
2285 &mut config,
2286 &MockProvider,
2287 &remote,
2288 §ion,
2289 false,
2290 false,
2291 false,
2292 );
2293 let entries = config.host_entries();
2294 assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
2295 }
2296
2297 #[test]
2298 fn test_sync_empty_user_not_set() {
2299 let mut config = empty_config();
2300 let mut section = make_section();
2301 section.user = String::new(); let remote = vec![ProviderHost::new(
2303 "1".to_string(),
2304 "web".to_string(),
2305 "1.2.3.4".to_string(),
2306 Vec::new(),
2307 )];
2308 sync_provider(
2309 &mut config,
2310 &MockProvider,
2311 &remote,
2312 §ion,
2313 false,
2314 false,
2315 false,
2316 );
2317 let entries = config.host_entries();
2318 assert!(entries[0].user.is_empty());
2319 }
2320
2321 #[test]
2326 fn test_sync_result_default() {
2327 let result = SyncResult::default();
2328 assert_eq!(result.added, 0);
2329 assert_eq!(result.updated, 0);
2330 assert_eq!(result.removed, 0);
2331 assert_eq!(result.unchanged, 0);
2332 assert!(result.renames.is_empty());
2333 }
2334
2335 #[test]
2340 fn test_sync_server_name_change_updates_alias() {
2341 let mut config = empty_config();
2342 let section = make_section();
2343 let remote = vec![ProviderHost::new(
2345 "1".to_string(),
2346 "old-name".to_string(),
2347 "1.2.3.4".to_string(),
2348 Vec::new(),
2349 )];
2350 sync_provider(
2351 &mut config,
2352 &MockProvider,
2353 &remote,
2354 §ion,
2355 false,
2356 false,
2357 false,
2358 );
2359 assert_eq!(config.host_entries()[0].alias, "do-old-name");
2360
2361 let remote_renamed = vec![ProviderHost::new(
2363 "1".to_string(),
2364 "new-name".to_string(),
2365 "1.2.3.4".to_string(),
2366 Vec::new(),
2367 )];
2368 let result = sync_provider(
2369 &mut config,
2370 &MockProvider,
2371 &remote_renamed,
2372 §ion,
2373 false,
2374 false,
2375 false,
2376 );
2377 assert!(!result.renames.is_empty() || result.updated > 0);
2379 }
2380
2381 #[test]
2382 fn test_sync_idempotent_same_data() {
2383 let mut config = empty_config();
2384 let section = make_section();
2385 let remote = vec![ProviderHost::new(
2386 "1".to_string(),
2387 "web".to_string(),
2388 "1.2.3.4".to_string(),
2389 vec!["prod".to_string()],
2390 )];
2391 sync_provider(
2392 &mut config,
2393 &MockProvider,
2394 &remote,
2395 §ion,
2396 false,
2397 false,
2398 false,
2399 );
2400 let result = sync_provider(
2401 &mut config,
2402 &MockProvider,
2403 &remote,
2404 §ion,
2405 false,
2406 false,
2407 false,
2408 );
2409 assert_eq!(result.added, 0);
2410 assert_eq!(result.updated, 0);
2411 assert_eq!(result.unchanged, 1);
2412 }
2413
2414 #[test]
2419 fn test_sync_tag_merge_case_insensitive_no_duplicate() {
2420 let mut config = empty_config();
2421 let section = make_section();
2422 let remote = vec![ProviderHost::new(
2424 "1".to_string(),
2425 "web".to_string(),
2426 "1.2.3.4".to_string(),
2427 vec!["Prod".to_string()],
2428 )];
2429 sync_provider(
2430 &mut config,
2431 &MockProvider,
2432 &remote,
2433 §ion,
2434 false,
2435 false,
2436 false,
2437 );
2438
2439 let remote2 = vec![ProviderHost::new(
2441 "1".to_string(),
2442 "web".to_string(),
2443 "1.2.3.4".to_string(),
2444 vec!["prod".to_string()],
2445 )];
2446 let result = sync_provider(
2447 &mut config,
2448 &MockProvider,
2449 &remote2,
2450 §ion,
2451 false,
2452 false,
2453 false,
2454 );
2455 assert_eq!(result.unchanged, 1);
2456 assert_eq!(result.updated, 0);
2457 }
2458
2459 #[test]
2460 fn test_sync_tag_merge_adds_new_remote_tag() {
2461 let mut config = empty_config();
2462 let section = make_section();
2463 let remote = vec![ProviderHost::new(
2464 "1".to_string(),
2465 "web".to_string(),
2466 "1.2.3.4".to_string(),
2467 vec!["prod".to_string()],
2468 )];
2469 sync_provider(
2470 &mut config,
2471 &MockProvider,
2472 &remote,
2473 §ion,
2474 false,
2475 false,
2476 false,
2477 );
2478
2479 let remote2 = vec![ProviderHost::new(
2481 "1".to_string(),
2482 "web".to_string(),
2483 "1.2.3.4".to_string(),
2484 vec!["prod".to_string(), "us-east".to_string()],
2485 )];
2486 let result = sync_provider(
2487 &mut config,
2488 &MockProvider,
2489 &remote2,
2490 §ion,
2491 false,
2492 false,
2493 false,
2494 );
2495 assert_eq!(result.updated, 1);
2496
2497 let entries = config.host_entries();
2499 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2500 assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2501 assert!(entry.provider_tags.iter().any(|t| t == "us-east"));
2502 }
2503
2504 #[test]
2505 fn test_sync_tag_merge_preserves_local_tags() {
2506 let mut config = empty_config();
2507 let section = make_section();
2508 let remote = vec![ProviderHost::new(
2509 "1".to_string(),
2510 "web".to_string(),
2511 "1.2.3.4".to_string(),
2512 vec!["prod".to_string()],
2513 )];
2514 sync_provider(
2515 &mut config,
2516 &MockProvider,
2517 &remote,
2518 §ion,
2519 false,
2520 false,
2521 false,
2522 );
2523
2524 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2526
2527 let result = sync_provider(
2529 &mut config,
2530 &MockProvider,
2531 &remote,
2532 §ion,
2533 false,
2534 false,
2535 false,
2536 );
2537 assert_eq!(result.updated, 1);
2538 let entries = config.host_entries();
2539 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2540 assert!(entry.tags.iter().any(|t| t == "my-custom"));
2541 assert!(!entry.tags.iter().any(|t| t == "prod")); }
2543
2544 #[test]
2545 fn test_sync_provider_tags_replaces_with_migration() {
2546 let mut config = empty_config();
2547 let section = make_section();
2548 let remote = vec![ProviderHost::new(
2549 "1".to_string(),
2550 "web".to_string(),
2551 "1.2.3.4".to_string(),
2552 vec!["prod".to_string()],
2553 )];
2554 sync_provider(
2555 &mut config,
2556 &MockProvider,
2557 &remote,
2558 §ion,
2559 false,
2560 false,
2561 false,
2562 );
2563
2564 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2566
2567 let remote2 = vec![ProviderHost::new(
2569 "1".to_string(),
2570 "web".to_string(),
2571 "1.2.3.4".to_string(),
2572 vec!["prod".to_string(), "new-tag".to_string()],
2573 )];
2574 let result = sync_provider(
2575 &mut config,
2576 &MockProvider,
2577 &remote2,
2578 §ion,
2579 false,
2580 false,
2581 false,
2582 );
2583 assert_eq!(result.updated, 1);
2584
2585 let entries = config.host_entries();
2586 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2587 assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2589 assert!(entry.provider_tags.iter().any(|t| t == "new-tag"));
2590 assert!(!entry.tags.iter().any(|t| t == "prod"));
2592 assert!(entry.tags.iter().any(|t| t == "my-custom"));
2593 }
2594
2595 #[test]
2600 fn test_sync_rename_and_ip_change_simultaneously() {
2601 let mut config = empty_config();
2602 let section = make_section();
2603 let remote = vec![ProviderHost::new(
2604 "1".to_string(),
2605 "old-name".to_string(),
2606 "1.2.3.4".to_string(),
2607 Vec::new(),
2608 )];
2609 sync_provider(
2610 &mut config,
2611 &MockProvider,
2612 &remote,
2613 §ion,
2614 false,
2615 false,
2616 false,
2617 );
2618
2619 let remote2 = vec![ProviderHost::new(
2621 "1".to_string(),
2622 "new-name".to_string(),
2623 "9.8.7.6".to_string(),
2624 Vec::new(),
2625 )];
2626 let result = sync_provider(
2627 &mut config,
2628 &MockProvider,
2629 &remote2,
2630 §ion,
2631 false,
2632 false,
2633 false,
2634 );
2635 assert_eq!(result.updated, 1);
2636 assert_eq!(result.renames.len(), 1);
2637 assert_eq!(result.renames[0].0, "do-old-name");
2638 assert_eq!(result.renames[0].1, "do-new-name");
2639
2640 let entries = config.host_entries();
2641 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
2642 assert_eq!(entry.hostname, "9.8.7.6");
2643 }
2644
2645 #[test]
2650 fn test_sync_duplicate_server_id_deduped() {
2651 let mut config = empty_config();
2652 let section = make_section();
2653 let remote = vec![
2654 ProviderHost::new(
2655 "1".to_string(),
2656 "web".to_string(),
2657 "1.2.3.4".to_string(),
2658 Vec::new(),
2659 ),
2660 ProviderHost::new(
2661 "1".to_string(),
2662 "web-copy".to_string(),
2663 "5.6.7.8".to_string(),
2664 Vec::new(),
2665 ), ];
2667 let result = sync_provider(
2668 &mut config,
2669 &MockProvider,
2670 &remote,
2671 §ion,
2672 false,
2673 false,
2674 false,
2675 );
2676 assert_eq!(result.added, 1); assert_eq!(config.host_entries().len(), 1);
2678 }
2679
2680 #[test]
2685 fn test_sync_remove_all_when_remote_empty() {
2686 let mut config = empty_config();
2687 let section = make_section();
2688 let remote = vec![
2689 ProviderHost::new(
2690 "1".to_string(),
2691 "web".to_string(),
2692 "1.2.3.4".to_string(),
2693 Vec::new(),
2694 ),
2695 ProviderHost::new(
2696 "2".to_string(),
2697 "db".to_string(),
2698 "5.6.7.8".to_string(),
2699 Vec::new(),
2700 ),
2701 ];
2702 sync_provider(
2703 &mut config,
2704 &MockProvider,
2705 &remote,
2706 §ion,
2707 false,
2708 false,
2709 false,
2710 );
2711 assert_eq!(config.host_entries().len(), 2);
2712
2713 let result = sync_provider(
2715 &mut config,
2716 &MockProvider,
2717 &[],
2718 §ion,
2719 true,
2720 false,
2721 false,
2722 );
2723 assert_eq!(result.removed, 2);
2724 assert_eq!(config.host_entries().len(), 0);
2725 }
2726
2727 #[test]
2732 fn test_sync_adds_group_header_on_first_host() {
2733 let mut config = empty_config();
2734 let section = make_section();
2735 let remote = vec![ProviderHost::new(
2736 "1".to_string(),
2737 "web".to_string(),
2738 "1.2.3.4".to_string(),
2739 Vec::new(),
2740 )];
2741 sync_provider(
2742 &mut config,
2743 &MockProvider,
2744 &remote,
2745 §ion,
2746 false,
2747 false,
2748 false,
2749 );
2750
2751 let has_header = config.elements.iter().any(|e| {
2753 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2754 });
2755 assert!(has_header);
2756 }
2757
2758 #[test]
2759 fn test_sync_removes_header_when_all_hosts_deleted() {
2760 let mut config = empty_config();
2761 let section = make_section();
2762 let remote = vec![ProviderHost::new(
2763 "1".to_string(),
2764 "web".to_string(),
2765 "1.2.3.4".to_string(),
2766 Vec::new(),
2767 )];
2768 sync_provider(
2769 &mut config,
2770 &MockProvider,
2771 &remote,
2772 §ion,
2773 false,
2774 false,
2775 false,
2776 );
2777
2778 let result = sync_provider(
2780 &mut config,
2781 &MockProvider,
2782 &[],
2783 §ion,
2784 true,
2785 false,
2786 false,
2787 );
2788 assert_eq!(result.removed, 1);
2789
2790 let has_header = config.elements.iter().any(|e| {
2792 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2793 });
2794 assert!(!has_header);
2795 }
2796
2797 #[test]
2802 fn test_sync_identity_file_set_on_new_host() {
2803 let mut config = empty_config();
2804 let mut section = make_section();
2805 section.identity_file = "~/.ssh/do_key".to_string();
2806 let remote = vec![ProviderHost::new(
2807 "1".to_string(),
2808 "web".to_string(),
2809 "1.2.3.4".to_string(),
2810 Vec::new(),
2811 )];
2812 sync_provider(
2813 &mut config,
2814 &MockProvider,
2815 &remote,
2816 §ion,
2817 false,
2818 false,
2819 false,
2820 );
2821 let entries = config.host_entries();
2822 assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
2823 }
2824
2825 #[test]
2830 fn test_sync_alias_collision_dedup() {
2831 let mut config = empty_config();
2832 let section = make_section();
2833 let remote = vec![
2835 ProviderHost::new(
2836 "1".to_string(),
2837 "web".to_string(),
2838 "1.2.3.4".to_string(),
2839 Vec::new(),
2840 ),
2841 ProviderHost::new(
2842 "2".to_string(),
2843 "web".to_string(),
2844 "5.6.7.8".to_string(),
2845 Vec::new(),
2846 ), ];
2848 let result = sync_provider(
2849 &mut config,
2850 &MockProvider,
2851 &remote,
2852 §ion,
2853 false,
2854 false,
2855 false,
2856 );
2857 assert_eq!(result.added, 2);
2858
2859 let entries = config.host_entries();
2860 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
2861 assert!(aliases.contains(&"do-web"));
2862 assert!(aliases.contains(&"do-web-2")); }
2864
2865 #[test]
2870 fn test_sync_empty_alias_prefix() {
2871 let mut config = empty_config();
2872 let mut section = make_section();
2873 section.alias_prefix = String::new();
2874 let remote = vec![ProviderHost::new(
2875 "1".to_string(),
2876 "web-1".to_string(),
2877 "1.2.3.4".to_string(),
2878 Vec::new(),
2879 )];
2880 sync_provider(
2881 &mut config,
2882 &MockProvider,
2883 &remote,
2884 §ion,
2885 false,
2886 false,
2887 false,
2888 );
2889 let entries = config.host_entries();
2890 assert_eq!(entries[0].alias, "web-1"); }
2892
2893 #[test]
2898 fn test_sync_dry_run_add_count() {
2899 let mut config = empty_config();
2900 let section = make_section();
2901 let remote = vec![
2902 ProviderHost::new(
2903 "1".to_string(),
2904 "web".to_string(),
2905 "1.2.3.4".to_string(),
2906 Vec::new(),
2907 ),
2908 ProviderHost::new(
2909 "2".to_string(),
2910 "db".to_string(),
2911 "5.6.7.8".to_string(),
2912 Vec::new(),
2913 ),
2914 ];
2915 let result = sync_provider(
2916 &mut config,
2917 &MockProvider,
2918 &remote,
2919 §ion,
2920 false,
2921 false,
2922 true,
2923 );
2924 assert_eq!(result.added, 2);
2925 assert_eq!(config.host_entries().len(), 0);
2927 }
2928
2929 #[test]
2930 fn test_sync_dry_run_remove_count_preserves_config() {
2931 let mut config = empty_config();
2932 let section = make_section();
2933 let remote = vec![ProviderHost::new(
2934 "1".to_string(),
2935 "web".to_string(),
2936 "1.2.3.4".to_string(),
2937 Vec::new(),
2938 )];
2939 sync_provider(
2940 &mut config,
2941 &MockProvider,
2942 &remote,
2943 §ion,
2944 false,
2945 false,
2946 false,
2947 );
2948 assert_eq!(config.host_entries().len(), 1);
2949
2950 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
2952 assert_eq!(result.removed, 1);
2953 assert_eq!(config.host_entries().len(), 1);
2955 }
2956
2957 #[test]
2962 fn test_sync_result_counts_add_up() {
2963 let mut config = empty_config();
2964 let section = make_section();
2965 let remote = vec![
2967 ProviderHost::new(
2968 "1".to_string(),
2969 "a".to_string(),
2970 "1.1.1.1".to_string(),
2971 Vec::new(),
2972 ),
2973 ProviderHost::new(
2974 "2".to_string(),
2975 "b".to_string(),
2976 "2.2.2.2".to_string(),
2977 Vec::new(),
2978 ),
2979 ProviderHost::new(
2980 "3".to_string(),
2981 "c".to_string(),
2982 "3.3.3.3".to_string(),
2983 Vec::new(),
2984 ),
2985 ];
2986 sync_provider(
2987 &mut config,
2988 &MockProvider,
2989 &remote,
2990 §ion,
2991 false,
2992 false,
2993 false,
2994 );
2995
2996 let remote2 = vec![
2998 ProviderHost::new(
2999 "1".to_string(),
3000 "a".to_string(),
3001 "1.1.1.1".to_string(),
3002 Vec::new(),
3003 ), ProviderHost::new(
3005 "2".to_string(),
3006 "b".to_string(),
3007 "9.9.9.9".to_string(),
3008 Vec::new(),
3009 ), ];
3012 let result = sync_provider(
3013 &mut config,
3014 &MockProvider,
3015 &remote2,
3016 §ion,
3017 true,
3018 false,
3019 false,
3020 );
3021 assert_eq!(result.unchanged, 1);
3022 assert_eq!(result.updated, 1);
3023 assert_eq!(result.removed, 1);
3024 assert_eq!(result.added, 0);
3025 }
3026
3027 #[test]
3032 fn test_sync_multiple_renames() {
3033 let mut config = empty_config();
3034 let section = make_section();
3035 let remote = vec![
3036 ProviderHost::new(
3037 "1".to_string(),
3038 "old-a".to_string(),
3039 "1.1.1.1".to_string(),
3040 Vec::new(),
3041 ),
3042 ProviderHost::new(
3043 "2".to_string(),
3044 "old-b".to_string(),
3045 "2.2.2.2".to_string(),
3046 Vec::new(),
3047 ),
3048 ];
3049 sync_provider(
3050 &mut config,
3051 &MockProvider,
3052 &remote,
3053 §ion,
3054 false,
3055 false,
3056 false,
3057 );
3058
3059 let remote2 = vec![
3060 ProviderHost::new(
3061 "1".to_string(),
3062 "new-a".to_string(),
3063 "1.1.1.1".to_string(),
3064 Vec::new(),
3065 ),
3066 ProviderHost::new(
3067 "2".to_string(),
3068 "new-b".to_string(),
3069 "2.2.2.2".to_string(),
3070 Vec::new(),
3071 ),
3072 ];
3073 let result = sync_provider(
3074 &mut config,
3075 &MockProvider,
3076 &remote2,
3077 §ion,
3078 false,
3079 false,
3080 false,
3081 );
3082 assert_eq!(result.renames.len(), 2);
3083 assert_eq!(result.updated, 2);
3084 }
3085
3086 #[test]
3091 fn test_sync_tag_whitespace_trimmed_on_store() {
3092 let mut config = empty_config();
3093 let section = make_section();
3094 let remote = vec![ProviderHost::new(
3096 "1".to_string(),
3097 "web".to_string(),
3098 "1.2.3.4".to_string(),
3099 vec![" production ".to_string(), " us-east ".to_string()],
3100 )];
3101 sync_provider(
3102 &mut config,
3103 &MockProvider,
3104 &remote,
3105 §ion,
3106 false,
3107 false,
3108 false,
3109 );
3110 let entries = config.host_entries();
3111 assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
3113 }
3114
3115 #[test]
3116 fn test_sync_tag_trimmed_remote_triggers_merge() {
3117 let mut config = empty_config();
3118 let section = make_section();
3119 let remote = vec![ProviderHost::new(
3121 "1".to_string(),
3122 "web".to_string(),
3123 "1.2.3.4".to_string(),
3124 vec!["production".to_string()],
3125 )];
3126 sync_provider(
3127 &mut config,
3128 &MockProvider,
3129 &remote,
3130 §ion,
3131 false,
3132 false,
3133 false,
3134 );
3135
3136 let remote2 = vec![ProviderHost::new(
3138 "1".to_string(),
3139 "web".to_string(),
3140 "1.2.3.4".to_string(),
3141 vec![" production ".to_string()],
3142 )]; let result = sync_provider(
3144 &mut config,
3145 &MockProvider,
3146 &remote2,
3147 §ion,
3148 false,
3149 false,
3150 false,
3151 );
3152 assert_eq!(result.unchanged, 1);
3154 }
3155
3156 struct MockProvider2;
3161 impl Provider for MockProvider2 {
3162 fn name(&self) -> &str {
3163 "vultr"
3164 }
3165 fn short_label(&self) -> &str {
3166 "vultr"
3167 }
3168 fn fetch_hosts_cancellable(
3169 &self,
3170 _token: &str,
3171 _cancel: &std::sync::atomic::AtomicBool,
3172 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
3173 Ok(Vec::new())
3174 }
3175 }
3176
3177 #[test]
3178 fn test_sync_two_providers_independent() {
3179 let mut config = empty_config();
3180
3181 let do_section = make_section(); let vultr_section = ProviderSection {
3183 provider: "vultr".to_string(),
3184 token: "test".to_string(),
3185 alias_prefix: "vultr".to_string(),
3186 user: String::new(),
3187 identity_file: String::new(),
3188 url: String::new(),
3189 verify_tls: true,
3190 auto_sync: true,
3191 profile: String::new(),
3192 regions: String::new(),
3193 project: String::new(),
3194 compartment: String::new(),
3195 vault_role: String::new(),
3196 vault_addr: String::new(),
3197 };
3198
3199 let do_remote = vec![ProviderHost::new(
3201 "1".to_string(),
3202 "web".to_string(),
3203 "1.2.3.4".to_string(),
3204 Vec::new(),
3205 )];
3206 sync_provider(
3207 &mut config,
3208 &MockProvider,
3209 &do_remote,
3210 &do_section,
3211 false,
3212 false,
3213 false,
3214 );
3215
3216 let vultr_remote = vec![ProviderHost::new(
3218 "abc".to_string(),
3219 "web".to_string(),
3220 "5.6.7.8".to_string(),
3221 Vec::new(),
3222 )];
3223 sync_provider(
3224 &mut config,
3225 &MockProvider2,
3226 &vultr_remote,
3227 &vultr_section,
3228 false,
3229 false,
3230 false,
3231 );
3232
3233 let entries = config.host_entries();
3234 assert_eq!(entries.len(), 2);
3235 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
3236 assert!(aliases.contains(&"do-web"));
3237 assert!(aliases.contains(&"vultr-web"));
3238 }
3239
3240 #[test]
3241 fn test_sync_remove_only_affects_own_provider() {
3242 let mut config = empty_config();
3243 let do_section = make_section();
3244 let vultr_section = ProviderSection {
3245 provider: "vultr".to_string(),
3246 token: "test".to_string(),
3247 alias_prefix: "vultr".to_string(),
3248 user: String::new(),
3249 identity_file: String::new(),
3250 url: String::new(),
3251 verify_tls: true,
3252 auto_sync: true,
3253 profile: String::new(),
3254 regions: String::new(),
3255 project: String::new(),
3256 compartment: String::new(),
3257 vault_role: String::new(),
3258 vault_addr: String::new(),
3259 };
3260
3261 let do_remote = vec![ProviderHost::new(
3263 "1".to_string(),
3264 "web".to_string(),
3265 "1.2.3.4".to_string(),
3266 Vec::new(),
3267 )];
3268 sync_provider(
3269 &mut config,
3270 &MockProvider,
3271 &do_remote,
3272 &do_section,
3273 false,
3274 false,
3275 false,
3276 );
3277
3278 let vultr_remote = vec![ProviderHost::new(
3279 "abc".to_string(),
3280 "db".to_string(),
3281 "5.6.7.8".to_string(),
3282 Vec::new(),
3283 )];
3284 sync_provider(
3285 &mut config,
3286 &MockProvider2,
3287 &vultr_remote,
3288 &vultr_section,
3289 false,
3290 false,
3291 false,
3292 );
3293 assert_eq!(config.host_entries().len(), 2);
3294
3295 let result = sync_provider(
3297 &mut config,
3298 &MockProvider,
3299 &[],
3300 &do_section,
3301 true,
3302 false,
3303 false,
3304 );
3305 assert_eq!(result.removed, 1);
3306 let entries = config.host_entries();
3307 assert_eq!(entries.len(), 1);
3308 assert_eq!(entries[0].alias, "vultr-db");
3309 }
3310
3311 #[test]
3316 fn test_sync_rename_and_tag_change_simultaneously() {
3317 let mut config = empty_config();
3318 let section = make_section();
3319 let remote = vec![ProviderHost::new(
3320 "1".to_string(),
3321 "old-name".to_string(),
3322 "1.2.3.4".to_string(),
3323 vec!["staging".to_string()],
3324 )];
3325 sync_provider(
3326 &mut config,
3327 &MockProvider,
3328 &remote,
3329 §ion,
3330 false,
3331 false,
3332 false,
3333 );
3334 assert_eq!(config.host_entries()[0].alias, "do-old-name");
3335 assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
3336
3337 let remote2 = vec![ProviderHost::new(
3339 "1".to_string(),
3340 "new-name".to_string(),
3341 "1.2.3.4".to_string(),
3342 vec!["staging".to_string(), "prod".to_string()],
3343 )];
3344 let result = sync_provider(
3345 &mut config,
3346 &MockProvider,
3347 &remote2,
3348 §ion,
3349 false,
3350 false,
3351 false,
3352 );
3353 assert_eq!(result.updated, 1);
3354 assert_eq!(result.renames.len(), 1);
3355
3356 let entries = config.host_entries();
3357 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3358 assert!(entry.provider_tags.contains(&"staging".to_string()));
3359 assert!(entry.provider_tags.contains(&"prod".to_string()));
3360 }
3361
3362 #[test]
3367 fn test_sync_all_symbol_name_uses_server_fallback() {
3368 let mut config = empty_config();
3369 let section = make_section();
3370 let remote = vec![ProviderHost::new(
3371 "1".to_string(),
3372 "!!!".to_string(),
3373 "1.2.3.4".to_string(),
3374 Vec::new(),
3375 )];
3376 sync_provider(
3377 &mut config,
3378 &MockProvider,
3379 &remote,
3380 §ion,
3381 false,
3382 false,
3383 false,
3384 );
3385 let entries = config.host_entries();
3386 assert_eq!(entries[0].alias, "do-server");
3387 }
3388
3389 #[test]
3390 fn test_sync_unicode_name_uses_ascii_fallback() {
3391 let mut config = empty_config();
3392 let section = make_section();
3393 let remote = vec![ProviderHost::new(
3394 "1".to_string(),
3395 "서버".to_string(),
3396 "1.2.3.4".to_string(),
3397 Vec::new(),
3398 )];
3399 sync_provider(
3400 &mut config,
3401 &MockProvider,
3402 &remote,
3403 §ion,
3404 false,
3405 false,
3406 false,
3407 );
3408 let entries = config.host_entries();
3409 assert_eq!(entries[0].alias, "do-server");
3411 }
3412
3413 #[test]
3418 fn test_sync_dry_run_update_preserves_config() {
3419 let mut config = empty_config();
3420 let section = make_section();
3421 let remote = vec![ProviderHost::new(
3422 "1".to_string(),
3423 "web".to_string(),
3424 "1.2.3.4".to_string(),
3425 Vec::new(),
3426 )];
3427 sync_provider(
3428 &mut config,
3429 &MockProvider,
3430 &remote,
3431 §ion,
3432 false,
3433 false,
3434 false,
3435 );
3436
3437 let remote2 = vec![ProviderHost::new(
3439 "1".to_string(),
3440 "web".to_string(),
3441 "9.9.9.9".to_string(),
3442 Vec::new(),
3443 )];
3444 let result = sync_provider(
3445 &mut config,
3446 &MockProvider,
3447 &remote2,
3448 §ion,
3449 false,
3450 false,
3451 true,
3452 );
3453 assert_eq!(result.updated, 1);
3454 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
3456 }
3457
3458 #[test]
3463 fn test_sync_empty_remote_empty_config_noop() {
3464 let mut config = empty_config();
3465 let section = make_section();
3466 let result = sync_provider(
3467 &mut config,
3468 &MockProvider,
3469 &[],
3470 §ion,
3471 true,
3472 false,
3473 false,
3474 );
3475 assert_eq!(result.added, 0);
3476 assert_eq!(result.updated, 0);
3477 assert_eq!(result.removed, 0);
3478 assert_eq!(result.unchanged, 0);
3479 assert!(config.host_entries().is_empty());
3480 }
3481
3482 #[test]
3487 fn test_sync_large_batch() {
3488 let mut config = empty_config();
3489 let section = make_section();
3490 let remote: Vec<ProviderHost> = (0..100)
3491 .map(|i| {
3492 ProviderHost::new(
3493 format!("{}", i),
3494 format!("server-{}", i),
3495 format!("10.0.0.{}", i % 256),
3496 vec!["batch".to_string()],
3497 )
3498 })
3499 .collect();
3500 let result = sync_provider(
3501 &mut config,
3502 &MockProvider,
3503 &remote,
3504 §ion,
3505 false,
3506 false,
3507 false,
3508 );
3509 assert_eq!(result.added, 100);
3510 assert_eq!(config.host_entries().len(), 100);
3511
3512 let result2 = sync_provider(
3514 &mut config,
3515 &MockProvider,
3516 &remote,
3517 §ion,
3518 false,
3519 false,
3520 false,
3521 );
3522 assert_eq!(result2.unchanged, 100);
3523 assert_eq!(result2.added, 0);
3524 }
3525
3526 #[test]
3531 fn test_sync_rename_self_exclusion_no_collision() {
3532 let mut config = empty_config();
3535 let section = make_section();
3536 let remote = vec![ProviderHost::new(
3537 "1".to_string(),
3538 "web".to_string(),
3539 "1.2.3.4".to_string(),
3540 Vec::new(),
3541 )];
3542 sync_provider(
3543 &mut config,
3544 &MockProvider,
3545 &remote,
3546 §ion,
3547 false,
3548 false,
3549 false,
3550 );
3551 assert_eq!(config.host_entries()[0].alias, "do-web");
3552
3553 let remote2 = vec![ProviderHost::new(
3555 "1".to_string(),
3556 "web".to_string(),
3557 "9.9.9.9".to_string(),
3558 Vec::new(),
3559 )];
3560 let result = sync_provider(
3561 &mut config,
3562 &MockProvider,
3563 &remote2,
3564 §ion,
3565 false,
3566 false,
3567 false,
3568 );
3569 assert_eq!(result.updated, 1);
3570 assert!(result.renames.is_empty());
3571 assert_eq!(config.host_entries()[0].alias, "do-web"); }
3573
3574 #[test]
3579 fn test_sync_provider_tags_with_rename() {
3580 let mut config = empty_config();
3581 let section = make_section();
3582 let remote = vec![ProviderHost::new(
3583 "1".to_string(),
3584 "old-name".to_string(),
3585 "1.2.3.4".to_string(),
3586 vec!["staging".to_string()],
3587 )];
3588 sync_provider(
3589 &mut config,
3590 &MockProvider,
3591 &remote,
3592 §ion,
3593 false,
3594 false,
3595 false,
3596 );
3597 config.set_host_tags(
3598 "do-old-name",
3599 &["staging".to_string(), "custom".to_string()],
3600 );
3601
3602 let remote2 = vec![ProviderHost::new(
3604 "1".to_string(),
3605 "new-name".to_string(),
3606 "1.2.3.4".to_string(),
3607 vec!["production".to_string()],
3608 )];
3609 let result = sync_provider(
3610 &mut config,
3611 &MockProvider,
3612 &remote2,
3613 §ion,
3614 false,
3615 false,
3616 false,
3617 );
3618 assert_eq!(result.updated, 1);
3619 assert_eq!(result.renames.len(), 1);
3620
3621 let entries = config.host_entries();
3622 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3623 assert_eq!(entry.provider_tags, vec!["production"]);
3625 assert!(entry.tags.contains(&"custom".to_string()));
3627 assert!(entry.tags.contains(&"staging".to_string()));
3628 }
3629
3630 #[test]
3635 fn test_sync_empty_ip_with_tags_not_added() {
3636 let mut config = empty_config();
3637 let section = make_section();
3638 let remote = vec![ProviderHost::new(
3639 "1".to_string(),
3640 "stopped".to_string(),
3641 String::new(),
3642 vec!["prod".to_string()],
3643 )];
3644 let result = sync_provider(
3645 &mut config,
3646 &MockProvider,
3647 &remote,
3648 §ion,
3649 false,
3650 false,
3651 false,
3652 );
3653 assert_eq!(result.added, 0);
3654 assert!(config.host_entries().is_empty());
3655 }
3656
3657 #[test]
3662 fn test_sync_orphaned_provider_marker_counts_unchanged() {
3663 let content = "\
3668Host do-web
3669 HostName 1.2.3.4
3670 # purple:provider digitalocean:123
3671";
3672 let mut config = SshConfigFile {
3673 elements: SshConfigFile::parse_content(content),
3674 path: PathBuf::from("/tmp/test_config"),
3675 crlf: false,
3676 bom: false,
3677 };
3678 let section = make_section();
3679 let remote = vec![ProviderHost::new(
3680 "123".to_string(),
3681 "web".to_string(),
3682 "1.2.3.4".to_string(),
3683 Vec::new(),
3684 )];
3685 let result = sync_provider(
3686 &mut config,
3687 &MockProvider,
3688 &remote,
3689 §ion,
3690 false,
3691 false,
3692 false,
3693 );
3694 assert_eq!(result.unchanged, 1);
3695 }
3696
3697 #[test]
3702 fn test_sync_no_double_blank_between_hosts() {
3703 let mut config = empty_config();
3704 let section = make_section();
3705 let remote = vec![
3706 ProviderHost::new(
3707 "1".to_string(),
3708 "web".to_string(),
3709 "1.2.3.4".to_string(),
3710 Vec::new(),
3711 ),
3712 ProviderHost::new(
3713 "2".to_string(),
3714 "db".to_string(),
3715 "5.6.7.8".to_string(),
3716 Vec::new(),
3717 ),
3718 ];
3719 sync_provider(
3720 &mut config,
3721 &MockProvider,
3722 &remote,
3723 §ion,
3724 false,
3725 false,
3726 false,
3727 );
3728
3729 let mut prev_blank = false;
3731 for elem in &config.elements {
3732 if let ConfigElement::GlobalLine(line) = elem {
3733 let is_blank = line.trim().is_empty();
3734 assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
3735 prev_blank = is_blank;
3736 } else {
3737 prev_blank = false;
3738 }
3739 }
3740 }
3741
3742 #[test]
3747 fn test_sync_without_remove_flag_keeps_deleted() {
3748 let mut config = empty_config();
3749 let section = make_section();
3750 let remote = vec![ProviderHost::new(
3751 "1".to_string(),
3752 "web".to_string(),
3753 "1.2.3.4".to_string(),
3754 Vec::new(),
3755 )];
3756 sync_provider(
3757 &mut config,
3758 &MockProvider,
3759 &remote,
3760 §ion,
3761 false,
3762 false,
3763 false,
3764 );
3765
3766 let result = sync_provider(
3768 &mut config,
3769 &MockProvider,
3770 &[],
3771 §ion,
3772 false,
3773 false,
3774 false,
3775 );
3776 assert_eq!(result.removed, 0);
3777 assert_eq!(config.host_entries().len(), 1); }
3779
3780 #[test]
3785 fn test_sync_dry_run_rename_no_renames_tracked() {
3786 let mut config = empty_config();
3787 let section = make_section();
3788 let remote = vec![ProviderHost::new(
3789 "1".to_string(),
3790 "old".to_string(),
3791 "1.2.3.4".to_string(),
3792 Vec::new(),
3793 )];
3794 sync_provider(
3795 &mut config,
3796 &MockProvider,
3797 &remote,
3798 §ion,
3799 false,
3800 false,
3801 false,
3802 );
3803
3804 let new_section = ProviderSection {
3805 alias_prefix: "ocean".to_string(),
3806 ..section
3807 };
3808 let result = sync_provider(
3809 &mut config,
3810 &MockProvider,
3811 &remote,
3812 &new_section,
3813 false,
3814 false,
3815 true,
3816 );
3817 assert_eq!(result.updated, 1);
3818 assert!(result.renames.is_empty());
3820 }
3821
3822 #[test]
3827 fn test_sanitize_name_whitespace_only() {
3828 assert_eq!(sanitize_name(" "), "server");
3829 }
3830
3831 #[test]
3832 fn test_sanitize_name_single_char() {
3833 assert_eq!(sanitize_name("a"), "a");
3834 assert_eq!(sanitize_name("Z"), "z");
3835 assert_eq!(sanitize_name("5"), "5");
3836 }
3837
3838 #[test]
3839 fn test_sanitize_name_single_special_char() {
3840 assert_eq!(sanitize_name("!"), "server");
3841 assert_eq!(sanitize_name("-"), "server");
3842 assert_eq!(sanitize_name("."), "server");
3843 }
3844
3845 #[test]
3846 fn test_sanitize_name_emoji() {
3847 assert_eq!(sanitize_name("server🚀"), "server");
3848 assert_eq!(sanitize_name("🔥hot🔥"), "hot");
3849 }
3850
3851 #[test]
3852 fn test_sanitize_name_long_mixed_separators() {
3853 assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
3854 }
3855
3856 #[test]
3857 fn test_sanitize_name_dots_and_underscores() {
3858 assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
3859 }
3860
3861 #[test]
3866 fn test_find_hosts_by_provider_in_includes() {
3867 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3868
3869 let include_content =
3870 "Host do-included\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n";
3871 let included_elements = SshConfigFile::parse_content(include_content);
3872
3873 let config = SshConfigFile {
3874 elements: vec![ConfigElement::Include(IncludeDirective {
3875 raw_line: "Include conf.d/*".to_string(),
3876 pattern: "conf.d/*".to_string(),
3877 resolved_files: vec![IncludedFile {
3878 path: PathBuf::from("/tmp/included.conf"),
3879 elements: included_elements,
3880 }],
3881 })],
3882 path: PathBuf::from("/tmp/test_config"),
3883 crlf: false,
3884 bom: false,
3885 };
3886
3887 let hosts = config.find_hosts_by_provider("digitalocean");
3888 assert_eq!(hosts.len(), 1);
3889 assert_eq!(hosts[0].0, "do-included");
3890 assert_eq!(hosts[0].1, "inc1");
3891 }
3892
3893 #[test]
3894 fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
3895 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3896
3897 let top_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
3899 let top_elements = SshConfigFile::parse_content(top_content);
3900
3901 let inc_content = "Host do-db\n HostName 5.6.7.8\n # purple:provider digitalocean:2\n";
3903 let inc_elements = SshConfigFile::parse_content(inc_content);
3904
3905 let mut elements = top_elements;
3906 elements.push(ConfigElement::Include(IncludeDirective {
3907 raw_line: "Include conf.d/*".to_string(),
3908 pattern: "conf.d/*".to_string(),
3909 resolved_files: vec![IncludedFile {
3910 path: PathBuf::from("/tmp/included.conf"),
3911 elements: inc_elements,
3912 }],
3913 }));
3914
3915 let config = SshConfigFile {
3916 elements,
3917 path: PathBuf::from("/tmp/test_config"),
3918 crlf: false,
3919 bom: false,
3920 };
3921
3922 let hosts = config.find_hosts_by_provider("digitalocean");
3923 assert_eq!(hosts.len(), 2);
3924 }
3925
3926 #[test]
3927 fn test_find_hosts_by_provider_empty_includes() {
3928 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3929
3930 let config = SshConfigFile {
3931 elements: vec![ConfigElement::Include(IncludeDirective {
3932 raw_line: "Include conf.d/*".to_string(),
3933 pattern: "conf.d/*".to_string(),
3934 resolved_files: vec![IncludedFile {
3935 path: PathBuf::from("/tmp/empty.conf"),
3936 elements: vec![],
3937 }],
3938 })],
3939 path: PathBuf::from("/tmp/test_config"),
3940 crlf: false,
3941 bom: false,
3942 };
3943
3944 let hosts = config.find_hosts_by_provider("digitalocean");
3945 assert!(hosts.is_empty());
3946 }
3947
3948 #[test]
3949 fn test_find_hosts_by_provider_wrong_provider_name() {
3950 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
3951 let config = SshConfigFile {
3952 elements: SshConfigFile::parse_content(content),
3953 path: PathBuf::from("/tmp/test_config"),
3954 crlf: false,
3955 bom: false,
3956 };
3957
3958 let hosts = config.find_hosts_by_provider("vultr");
3959 assert!(hosts.is_empty());
3960 }
3961
3962 #[test]
3967 fn test_deduplicate_alias_excluding_self() {
3968 let content = "Host do-web\n HostName 1.2.3.4\n";
3970 let config = SshConfigFile {
3971 elements: SshConfigFile::parse_content(content),
3972 path: PathBuf::from("/tmp/test_config"),
3973 crlf: false,
3974 bom: false,
3975 };
3976
3977 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
3978 assert_eq!(alias, "do-web"); }
3980
3981 #[test]
3982 fn test_deduplicate_alias_excluding_other() {
3983 let content = "Host do-web\n HostName 1.2.3.4\n";
3985 let config = SshConfigFile {
3986 elements: SshConfigFile::parse_content(content),
3987 path: PathBuf::from("/tmp/test_config"),
3988 crlf: false,
3989 bom: false,
3990 };
3991
3992 let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
3993 assert_eq!(alias, "do-web-2"); }
3995
3996 #[test]
3997 fn test_deduplicate_alias_excluding_chain() {
3998 let content = "Host do-web\n HostName 1.1.1.1\n\nHost do-web-2\n HostName 2.2.2.2\n";
4000 let config = SshConfigFile {
4001 elements: SshConfigFile::parse_content(content),
4002 path: PathBuf::from("/tmp/test_config"),
4003 crlf: false,
4004 bom: false,
4005 };
4006
4007 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
4008 assert_eq!(alias, "do-web");
4010 }
4011
4012 #[test]
4013 fn test_deduplicate_alias_excluding_none() {
4014 let content = "Host do-web\n HostName 1.2.3.4\n";
4015 let config = SshConfigFile {
4016 elements: SshConfigFile::parse_content(content),
4017 path: PathBuf::from("/tmp/test_config"),
4018 crlf: false,
4019 bom: false,
4020 };
4021
4022 let alias = config.deduplicate_alias_excluding("do-web", None);
4024 assert_eq!(alias, "do-web-2");
4025 }
4026
4027 #[test]
4032 fn test_set_host_tags_empty_clears_tags() {
4033 let content = "Host do-web\n HostName 1.2.3.4\n # purple:tags prod,staging\n";
4034 let mut config = SshConfigFile {
4035 elements: SshConfigFile::parse_content(content),
4036 path: PathBuf::from("/tmp/test_config"),
4037 crlf: false,
4038 bom: false,
4039 };
4040
4041 config.set_host_tags("do-web", &[]);
4042 let entries = config.host_entries();
4043 assert!(entries[0].tags.is_empty());
4044 }
4045
4046 #[test]
4047 fn test_set_host_provider_updates_existing() {
4048 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:old-id\n";
4049 let mut config = SshConfigFile {
4050 elements: SshConfigFile::parse_content(content),
4051 path: PathBuf::from("/tmp/test_config"),
4052 crlf: false,
4053 bom: false,
4054 };
4055
4056 config.set_host_provider("do-web", "digitalocean", "new-id");
4057 let hosts = config.find_hosts_by_provider("digitalocean");
4058 assert_eq!(hosts.len(), 1);
4059 assert_eq!(hosts[0].1, "new-id");
4060 }
4061
4062 #[test]
4067 fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
4068 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4069
4070 let include_content =
4071 "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n";
4072 let included_elements = SshConfigFile::parse_content(include_content);
4073
4074 let mut config = SshConfigFile {
4075 elements: vec![ConfigElement::Include(IncludeDirective {
4076 raw_line: "Include conf.d/*".to_string(),
4077 pattern: "conf.d/*".to_string(),
4078 resolved_files: vec![IncludedFile {
4079 path: PathBuf::from("/tmp/included.conf"),
4080 elements: included_elements,
4081 }],
4082 })],
4083 path: PathBuf::from("/tmp/test_config"),
4084 crlf: false,
4085 bom: false,
4086 };
4087
4088 let section = make_section();
4089 let remote = vec![ProviderHost::new(
4090 "123".to_string(),
4091 "web".to_string(),
4092 "1.2.3.4".to_string(),
4093 Vec::new(),
4094 )];
4095
4096 let result = sync_provider(
4097 &mut config,
4098 &MockProvider,
4099 &remote,
4100 §ion,
4101 false,
4102 false,
4103 false,
4104 );
4105 assert_eq!(result.unchanged, 1);
4106 assert_eq!(result.added, 0);
4107 let top_hosts = config
4109 .elements
4110 .iter()
4111 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
4112 .count();
4113 assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
4114 }
4115
4116 #[test]
4121 fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
4122 let mut config = empty_config();
4123 let section = make_section();
4124
4125 let remote = vec![ProviderHost::new(
4127 "1".to_string(),
4128 "web".to_string(),
4129 "1.2.3.4".to_string(),
4130 Vec::new(),
4131 )];
4132 sync_provider(
4133 &mut config,
4134 &MockProvider,
4135 &remote,
4136 §ion,
4137 false,
4138 false,
4139 false,
4140 );
4141 assert_eq!(config.host_entries()[0].alias, "do-web");
4142
4143 let other = vec![ProviderHost::new(
4145 "2".to_string(),
4146 "new-web".to_string(),
4147 "5.5.5.5".to_string(),
4148 Vec::new(),
4149 )];
4150 sync_provider(
4151 &mut config,
4152 &MockProvider,
4153 &other,
4154 §ion,
4155 false,
4156 false,
4157 false,
4158 );
4159
4160 let remote_same = vec![
4168 ProviderHost::new(
4169 "1".to_string(),
4170 "web".to_string(),
4171 "1.2.3.4".to_string(),
4172 Vec::new(),
4173 ),
4174 ProviderHost::new(
4175 "2".to_string(),
4176 "new-web".to_string(),
4177 "5.5.5.5".to_string(),
4178 Vec::new(),
4179 ),
4180 ];
4181 let result = sync_provider(
4182 &mut config,
4183 &MockProvider,
4184 &remote_same,
4185 §ion,
4186 false,
4187 false,
4188 false,
4189 );
4190 assert_eq!(result.unchanged, 1);
4193 assert_eq!(result.updated, 1);
4194 assert!(result.renames.is_empty());
4195 }
4196
4197 #[test]
4202 fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
4203 let mut config = empty_config();
4206 let section = make_section();
4207
4208 let remote = vec![
4209 ProviderHost::new(
4210 "1".to_string(),
4211 "web".to_string(),
4212 "1.1.1.1".to_string(),
4213 Vec::new(),
4214 ),
4215 ProviderHost::new(
4216 "2".to_string(),
4217 "web".to_string(),
4218 "2.2.2.2".to_string(),
4219 Vec::new(),
4220 ),
4221 ];
4222 let result = sync_provider(
4223 &mut config,
4224 &MockProvider,
4225 &remote,
4226 §ion,
4227 false,
4228 false,
4229 false,
4230 );
4231 assert_eq!(result.added, 2);
4232
4233 let entries = config.host_entries();
4234 assert_eq!(entries[0].alias, "do-web");
4235 assert_eq!(entries[1].alias, "do-web-2");
4236
4237 let result = sync_provider(
4239 &mut config,
4240 &MockProvider,
4241 &remote,
4242 §ion,
4243 false,
4244 false,
4245 false,
4246 );
4247 assert_eq!(result.unchanged, 2);
4248 }
4249
4250 #[test]
4255 fn test_sync_dry_run_remove_excludes_included_hosts() {
4256 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4257
4258 let include_content =
4259 "Host do-included\n HostName 1.1.1.1\n # purple:provider digitalocean:inc1\n";
4260 let included_elements = SshConfigFile::parse_content(include_content);
4261
4262 let mut config = SshConfigFile {
4264 elements: vec![ConfigElement::Include(IncludeDirective {
4265 raw_line: "Include conf.d/*".to_string(),
4266 pattern: "conf.d/*".to_string(),
4267 resolved_files: vec![IncludedFile {
4268 path: PathBuf::from("/tmp/included.conf"),
4269 elements: included_elements,
4270 }],
4271 })],
4272 path: PathBuf::from("/tmp/test_config"),
4273 crlf: false,
4274 bom: false,
4275 };
4276
4277 let section = make_section();
4279 let remote = vec![ProviderHost::new(
4280 "top1".to_string(),
4281 "toplevel".to_string(),
4282 "2.2.2.2".to_string(),
4283 Vec::new(),
4284 )];
4285 sync_provider(
4286 &mut config,
4287 &MockProvider,
4288 &remote,
4289 §ion,
4290 false,
4291 false,
4292 false,
4293 );
4294
4295 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
4298 assert_eq!(
4299 result.removed, 1,
4300 "Only top-level host counted in dry-run remove"
4301 );
4302 }
4303
4304 #[test]
4309 fn test_sync_group_header_with_existing_trailing_blank() {
4310 let mut config = empty_config();
4311 config
4313 .elements
4314 .push(ConfigElement::GlobalLine("# some comment".to_string()));
4315 config
4316 .elements
4317 .push(ConfigElement::GlobalLine(String::new()));
4318
4319 let section = make_section();
4320 let remote = vec![ProviderHost::new(
4321 "1".to_string(),
4322 "web".to_string(),
4323 "1.2.3.4".to_string(),
4324 Vec::new(),
4325 )];
4326 let result = sync_provider(
4327 &mut config,
4328 &MockProvider,
4329 &remote,
4330 §ion,
4331 false,
4332 false,
4333 false,
4334 );
4335 assert_eq!(result.added, 1);
4336
4337 let blank_count = config
4340 .elements
4341 .iter()
4342 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
4343 .count();
4344 assert_eq!(
4345 blank_count, 1,
4346 "No extra blank line when one already exists"
4347 );
4348 }
4349
4350 #[test]
4355 fn test_sync_no_group_header_for_second_host() {
4356 let mut config = empty_config();
4357 let section = make_section();
4358
4359 let remote = vec![ProviderHost::new(
4361 "1".to_string(),
4362 "web".to_string(),
4363 "1.2.3.4".to_string(),
4364 Vec::new(),
4365 )];
4366 sync_provider(
4367 &mut config,
4368 &MockProvider,
4369 &remote,
4370 §ion,
4371 false,
4372 false,
4373 false,
4374 );
4375
4376 let header_count_before = config
4377 .elements
4378 .iter()
4379 .filter(
4380 |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4381 )
4382 .count();
4383 assert_eq!(header_count_before, 1);
4384
4385 let remote2 = vec![
4387 ProviderHost::new(
4388 "1".to_string(),
4389 "web".to_string(),
4390 "1.2.3.4".to_string(),
4391 Vec::new(),
4392 ),
4393 ProviderHost::new(
4394 "2".to_string(),
4395 "db".to_string(),
4396 "5.5.5.5".to_string(),
4397 Vec::new(),
4398 ),
4399 ];
4400 sync_provider(
4401 &mut config,
4402 &MockProvider,
4403 &remote2,
4404 §ion,
4405 false,
4406 false,
4407 false,
4408 );
4409
4410 let header_count_after = config
4412 .elements
4413 .iter()
4414 .filter(
4415 |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4416 )
4417 .count();
4418 assert_eq!(header_count_after, 1, "No duplicate group header");
4419 }
4420
4421 #[test]
4426 fn test_sync_duplicate_server_id_in_remote_skipped() {
4427 let mut config = empty_config();
4428 let section = make_section();
4429
4430 let remote = vec![
4432 ProviderHost::new(
4433 "dup".to_string(),
4434 "first".to_string(),
4435 "1.1.1.1".to_string(),
4436 Vec::new(),
4437 ),
4438 ProviderHost::new(
4439 "dup".to_string(),
4440 "second".to_string(),
4441 "2.2.2.2".to_string(),
4442 Vec::new(),
4443 ),
4444 ];
4445 let result = sync_provider(
4446 &mut config,
4447 &MockProvider,
4448 &remote,
4449 §ion,
4450 false,
4451 false,
4452 false,
4453 );
4454 assert_eq!(result.added, 1, "Only the first instance is added");
4455 assert_eq!(config.host_entries()[0].alias, "do-first");
4456 }
4457
4458 #[test]
4463 fn test_sync_empty_ip_existing_host_counted_unchanged() {
4464 let mut config = empty_config();
4465 let section = make_section();
4466
4467 let remote = vec![ProviderHost::new(
4469 "1".to_string(),
4470 "web".to_string(),
4471 "1.2.3.4".to_string(),
4472 Vec::new(),
4473 )];
4474 sync_provider(
4475 &mut config,
4476 &MockProvider,
4477 &remote,
4478 §ion,
4479 false,
4480 false,
4481 false,
4482 );
4483
4484 let remote2 = vec![ProviderHost::new(
4486 "1".to_string(),
4487 "web".to_string(),
4488 String::new(),
4489 Vec::new(),
4490 )];
4491 let result = sync_provider(
4492 &mut config,
4493 &MockProvider,
4494 &remote2,
4495 §ion,
4496 false,
4497 false,
4498 true,
4499 );
4500 assert_eq!(result.unchanged, 1);
4501 assert_eq!(result.removed, 0, "Host with empty IP not removed");
4502 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
4503 }
4504
4505 #[test]
4510 fn test_sync_provider_tags_case_insensitive_no_update() {
4511 let mut config = empty_config();
4512 let section = make_section();
4513
4514 let remote = vec![ProviderHost::new(
4515 "1".to_string(),
4516 "web".to_string(),
4517 "1.2.3.4".to_string(),
4518 vec!["Production".to_string()],
4519 )];
4520 sync_provider(
4521 &mut config,
4522 &MockProvider,
4523 &remote,
4524 §ion,
4525 false,
4526 false,
4527 false,
4528 );
4529
4530 let remote2 = vec![ProviderHost::new(
4532 "1".to_string(),
4533 "web".to_string(),
4534 "1.2.3.4".to_string(),
4535 vec!["production".to_string()],
4536 )];
4537 let result = sync_provider(
4538 &mut config,
4539 &MockProvider,
4540 &remote2,
4541 §ion,
4542 false,
4543 false,
4544 false,
4545 );
4546 assert_eq!(
4547 result.unchanged, 1,
4548 "Case-insensitive tag match = unchanged"
4549 );
4550 }
4551
4552 #[test]
4557 fn test_sync_remove_cleans_up_group_header() {
4558 let mut config = empty_config();
4559 let section = make_section();
4560
4561 let remote = vec![ProviderHost::new(
4562 "1".to_string(),
4563 "web".to_string(),
4564 "1.2.3.4".to_string(),
4565 Vec::new(),
4566 )];
4567 sync_provider(
4568 &mut config,
4569 &MockProvider,
4570 &remote,
4571 §ion,
4572 false,
4573 false,
4574 false,
4575 );
4576
4577 let has_header = config
4579 .elements
4580 .iter()
4581 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4582 assert!(has_header, "Group header present after add");
4583
4584 let result = sync_provider(
4586 &mut config,
4587 &MockProvider,
4588 &[],
4589 §ion,
4590 true,
4591 false,
4592 false,
4593 );
4594 assert_eq!(result.removed, 1);
4595
4596 let has_header_after = config
4598 .elements
4599 .iter()
4600 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4601 assert!(
4602 !has_header_after,
4603 "Group header removed when all hosts gone"
4604 );
4605 }
4606
4607 #[test]
4612 fn test_sync_adds_host_with_metadata() {
4613 let mut config = empty_config();
4614 let section = make_section();
4615 let remote = vec![ProviderHost {
4616 server_id: "1".to_string(),
4617 name: "web".to_string(),
4618 ip: "1.2.3.4".to_string(),
4619 tags: Vec::new(),
4620 metadata: vec![
4621 ("region".to_string(), "nyc3".to_string()),
4622 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4623 ],
4624 }];
4625 let result = sync_provider(
4626 &mut config,
4627 &MockProvider,
4628 &remote,
4629 §ion,
4630 false,
4631 false,
4632 false,
4633 );
4634 assert_eq!(result.added, 1);
4635 let entries = config.host_entries();
4636 assert_eq!(entries[0].provider_meta.len(), 2);
4637 assert_eq!(
4638 entries[0].provider_meta[0],
4639 ("region".to_string(), "nyc3".to_string())
4640 );
4641 assert_eq!(
4642 entries[0].provider_meta[1],
4643 ("plan".to_string(), "s-1vcpu-1gb".to_string())
4644 );
4645 }
4646
4647 #[test]
4648 fn test_sync_updates_changed_metadata() {
4649 let mut config = empty_config();
4650 let section = make_section();
4651 let remote = vec![ProviderHost {
4652 server_id: "1".to_string(),
4653 name: "web".to_string(),
4654 ip: "1.2.3.4".to_string(),
4655 tags: Vec::new(),
4656 metadata: vec![("region".to_string(), "nyc3".to_string())],
4657 }];
4658 sync_provider(
4659 &mut config,
4660 &MockProvider,
4661 &remote,
4662 §ion,
4663 false,
4664 false,
4665 false,
4666 );
4667
4668 let remote2 = vec![ProviderHost {
4670 server_id: "1".to_string(),
4671 name: "web".to_string(),
4672 ip: "1.2.3.4".to_string(),
4673 tags: Vec::new(),
4674 metadata: vec![
4675 ("region".to_string(), "sfo3".to_string()),
4676 ("plan".to_string(), "s-2vcpu-2gb".to_string()),
4677 ],
4678 }];
4679 let result = sync_provider(
4680 &mut config,
4681 &MockProvider,
4682 &remote2,
4683 §ion,
4684 false,
4685 false,
4686 false,
4687 );
4688 assert_eq!(result.updated, 1);
4689 let entries = config.host_entries();
4690 assert_eq!(entries[0].provider_meta.len(), 2);
4691 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4692 assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
4693 }
4694
4695 #[test]
4696 fn test_sync_metadata_unchanged_no_update() {
4697 let mut config = empty_config();
4698 let section = make_section();
4699 let remote = vec![ProviderHost {
4700 server_id: "1".to_string(),
4701 name: "web".to_string(),
4702 ip: "1.2.3.4".to_string(),
4703 tags: Vec::new(),
4704 metadata: vec![("region".to_string(), "nyc3".to_string())],
4705 }];
4706 sync_provider(
4707 &mut config,
4708 &MockProvider,
4709 &remote,
4710 §ion,
4711 false,
4712 false,
4713 false,
4714 );
4715
4716 let result = sync_provider(
4718 &mut config,
4719 &MockProvider,
4720 &remote,
4721 §ion,
4722 false,
4723 false,
4724 false,
4725 );
4726 assert_eq!(result.unchanged, 1);
4727 assert_eq!(result.updated, 0);
4728 }
4729
4730 #[test]
4731 fn test_sync_metadata_order_insensitive() {
4732 let mut config = empty_config();
4733 let section = make_section();
4734 let remote = vec![ProviderHost {
4735 server_id: "1".to_string(),
4736 name: "web".to_string(),
4737 ip: "1.2.3.4".to_string(),
4738 tags: Vec::new(),
4739 metadata: vec![
4740 ("region".to_string(), "nyc3".to_string()),
4741 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4742 ],
4743 }];
4744 sync_provider(
4745 &mut config,
4746 &MockProvider,
4747 &remote,
4748 §ion,
4749 false,
4750 false,
4751 false,
4752 );
4753
4754 let remote2 = vec![ProviderHost {
4756 server_id: "1".to_string(),
4757 name: "web".to_string(),
4758 ip: "1.2.3.4".to_string(),
4759 tags: Vec::new(),
4760 metadata: vec![
4761 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4762 ("region".to_string(), "nyc3".to_string()),
4763 ],
4764 }];
4765 let result = sync_provider(
4766 &mut config,
4767 &MockProvider,
4768 &remote2,
4769 §ion,
4770 false,
4771 false,
4772 false,
4773 );
4774 assert_eq!(result.unchanged, 1);
4775 assert_eq!(result.updated, 0);
4776 }
4777
4778 #[test]
4779 fn test_sync_metadata_with_rename() {
4780 let mut config = empty_config();
4781 let section = make_section();
4782 let remote = vec![ProviderHost {
4783 server_id: "1".to_string(),
4784 name: "old-name".to_string(),
4785 ip: "1.2.3.4".to_string(),
4786 tags: Vec::new(),
4787 metadata: vec![("region".to_string(), "nyc3".to_string())],
4788 }];
4789 sync_provider(
4790 &mut config,
4791 &MockProvider,
4792 &remote,
4793 §ion,
4794 false,
4795 false,
4796 false,
4797 );
4798 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4799
4800 let remote2 = vec![ProviderHost {
4802 server_id: "1".to_string(),
4803 name: "new-name".to_string(),
4804 ip: "1.2.3.4".to_string(),
4805 tags: Vec::new(),
4806 metadata: vec![("region".to_string(), "sfo3".to_string())],
4807 }];
4808 let result = sync_provider(
4809 &mut config,
4810 &MockProvider,
4811 &remote2,
4812 §ion,
4813 false,
4814 false,
4815 false,
4816 );
4817 assert_eq!(result.updated, 1);
4818 assert!(!result.renames.is_empty());
4819 let entries = config.host_entries();
4820 assert_eq!(entries[0].alias, "do-new-name");
4821 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4822 }
4823
4824 #[test]
4825 fn test_sync_metadata_dry_run_no_mutation() {
4826 let mut config = empty_config();
4827 let section = make_section();
4828 let remote = vec![ProviderHost {
4829 server_id: "1".to_string(),
4830 name: "web".to_string(),
4831 ip: "1.2.3.4".to_string(),
4832 tags: Vec::new(),
4833 metadata: vec![("region".to_string(), "nyc3".to_string())],
4834 }];
4835 sync_provider(
4836 &mut config,
4837 &MockProvider,
4838 &remote,
4839 §ion,
4840 false,
4841 false,
4842 false,
4843 );
4844
4845 let remote2 = vec![ProviderHost {
4847 server_id: "1".to_string(),
4848 name: "web".to_string(),
4849 ip: "1.2.3.4".to_string(),
4850 tags: Vec::new(),
4851 metadata: vec![("region".to_string(), "sfo3".to_string())],
4852 }];
4853 let result = sync_provider(
4854 &mut config,
4855 &MockProvider,
4856 &remote2,
4857 §ion,
4858 false,
4859 false,
4860 true,
4861 );
4862 assert_eq!(result.updated, 1);
4863 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4865 }
4866
4867 #[test]
4868 fn test_sync_metadata_only_change_triggers_update() {
4869 let mut config = empty_config();
4870 let section = make_section();
4871 let remote = vec![ProviderHost {
4872 server_id: "1".to_string(),
4873 name: "web".to_string(),
4874 ip: "1.2.3.4".to_string(),
4875 tags: vec!["prod".to_string()],
4876 metadata: vec![("region".to_string(), "nyc3".to_string())],
4877 }];
4878 sync_provider(
4879 &mut config,
4880 &MockProvider,
4881 &remote,
4882 §ion,
4883 false,
4884 false,
4885 false,
4886 );
4887
4888 let remote2 = vec![ProviderHost {
4890 server_id: "1".to_string(),
4891 name: "web".to_string(),
4892 ip: "1.2.3.4".to_string(),
4893 tags: vec!["prod".to_string()],
4894 metadata: vec![
4895 ("region".to_string(), "nyc3".to_string()),
4896 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4897 ],
4898 }];
4899 let result = sync_provider(
4900 &mut config,
4901 &MockProvider,
4902 &remote2,
4903 §ion,
4904 false,
4905 false,
4906 false,
4907 );
4908 assert_eq!(result.updated, 1);
4909 assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
4910 }
4911
4912 #[test]
4917 fn test_sync_upgrade_migration() {
4918 let content = "\
4922Host do-web-1
4923 HostName 1.2.3.4
4924 User root
4925 # purple:tags prod,us-east,my-custom
4926 # purple:provider digitalocean:123
4927";
4928 let mut config = SshConfigFile {
4929 elements: SshConfigFile::parse_content(content),
4930 path: PathBuf::from("/tmp/test_config"),
4931 crlf: false,
4932 bom: false,
4933 };
4934 let section = make_section();
4935
4936 let remote = vec![ProviderHost::new(
4938 "123".to_string(),
4939 "web-1".to_string(),
4940 "1.2.3.4".to_string(),
4941 vec!["prod".to_string(), "us-east".to_string()],
4942 )];
4943
4944 let result = sync_provider(
4945 &mut config,
4946 &MockProvider,
4947 &remote,
4948 §ion,
4949 false,
4950 false,
4951 false,
4952 );
4953 assert_eq!(result.updated, 1);
4955
4956 let entry = &config.host_entries()[0];
4957 let mut ptags = entry.provider_tags.clone();
4959 ptags.sort();
4960 assert_eq!(ptags, vec!["prod", "us-east"]);
4961
4962 assert_eq!(entry.tags, vec!["my-custom"]);
4964 }
4965
4966 #[test]
4967 fn test_sync_duplicate_user_provider_tag() {
4968 let mut config = empty_config();
4973 let section = make_section();
4974
4975 let remote = vec![ProviderHost::new(
4977 "123".to_string(),
4978 "web-1".to_string(),
4979 "1.2.3.4".to_string(),
4980 vec!["prod".to_string()],
4981 )];
4982 sync_provider(
4983 &mut config,
4984 &MockProvider,
4985 &remote,
4986 §ion,
4987 false,
4988 false,
4989 false,
4990 );
4991 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
4992
4993 config.set_host_tags("do-web-1", &["prod".to_string(), "custom".to_string()]);
4995 assert_eq!(config.host_entries()[0].tags, vec!["prod", "custom"]);
4996
4997 sync_provider(
4999 &mut config,
5000 &MockProvider,
5001 &remote,
5002 §ion,
5003 false,
5004 false,
5005 false,
5006 );
5007
5008 let entry = &config.host_entries()[0];
5010 assert!(
5011 !entry.tags.contains(&"prod".to_string()),
5012 "User tag 'prod' should be cleaned since it duplicates a provider tag"
5013 );
5014 assert!(
5015 entry.tags.contains(&"custom".to_string()),
5016 "User tag 'custom' should be preserved"
5017 );
5018 assert_eq!(entry.provider_tags, vec!["prod"]);
5020 }
5021
5022 #[test]
5023 fn test_sync_set_provider_tags_empty_writes_sentinel() {
5024 let content = "\
5026Host do-web-1
5027 HostName 1.2.3.4
5028 # purple:provider_tags prod
5029 # purple:provider digitalocean:123
5030";
5031 let mut config = SshConfigFile {
5032 elements: SshConfigFile::parse_content(content),
5033 path: PathBuf::from("/tmp/test_config"),
5034 crlf: false,
5035 bom: false,
5036 };
5037
5038 config.set_host_provider_tags("do-web-1", &[]);
5040
5041 let serialized = config.serialize();
5042 assert!(
5043 serialized.contains("# purple:provider_tags"),
5044 "empty sentinel should exist. Got:\n{}",
5045 serialized
5046 );
5047 assert!(
5048 !serialized.contains("# purple:provider_tags "),
5049 "sentinel should have no trailing content. Got:\n{}",
5050 serialized
5051 );
5052 assert!(serialized.contains("Host do-web-1"));
5054 assert!(serialized.contains("purple:provider digitalocean:123"));
5055 }
5056
5057 #[test]
5058 fn test_sync_set_provider_does_not_clobber_provider_tags() {
5059 let content = "\
5061Host do-web-1
5062 HostName 1.2.3.4
5063 # purple:provider digitalocean:123
5064 # purple:provider_tags prod
5065";
5066 let mut config = SshConfigFile {
5067 elements: SshConfigFile::parse_content(content),
5068 path: PathBuf::from("/tmp/test_config"),
5069 crlf: false,
5070 bom: false,
5071 };
5072
5073 config.set_host_provider("do-web-1", "digitalocean", "456");
5075
5076 let serialized = config.serialize();
5077 assert!(
5078 serialized.contains("# purple:provider_tags prod"),
5079 "provider_tags should survive set_provider. Got:\n{}",
5080 serialized
5081 );
5082 assert!(
5083 serialized.contains("# purple:provider digitalocean:456"),
5084 "provider marker should be updated. Got:\n{}",
5085 serialized
5086 );
5087 }
5088
5089 #[test]
5090 fn test_sync_provider_tags_roundtrip() {
5091 let content = "\
5093Host do-web-1
5094 HostName 1.2.3.4
5095 User root
5096 # purple:provider_tags prod,us-east
5097 # purple:provider digitalocean:123
5098";
5099 let config = SshConfigFile {
5100 elements: SshConfigFile::parse_content(content),
5101 path: PathBuf::from("/tmp/test_config"),
5102 crlf: false,
5103 bom: false,
5104 };
5105
5106 let entries = config.host_entries();
5108 assert_eq!(entries.len(), 1);
5109 let mut ptags = entries[0].provider_tags.clone();
5110 ptags.sort();
5111 assert_eq!(ptags, vec!["prod", "us-east"]);
5112
5113 let serialized = config.serialize();
5115 let config2 = SshConfigFile {
5116 elements: SshConfigFile::parse_content(&serialized),
5117 path: PathBuf::from("/tmp/test_config"),
5118 crlf: false,
5119 bom: false,
5120 };
5121
5122 let entries2 = config2.host_entries();
5123 assert_eq!(entries2.len(), 1);
5124 let mut ptags2 = entries2[0].provider_tags.clone();
5125 ptags2.sort();
5126 assert_eq!(ptags2, vec!["prod", "us-east"]);
5127 }
5128
5129 #[test]
5130 fn test_sync_first_migration_empty_remote_writes_sentinel() {
5131 let mut config = SshConfigFile {
5133 elements: SshConfigFile::parse_content(
5134 "Host do-web-1\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n # purple:tags prod\n",
5135 ),
5136 path: PathBuf::from("/tmp/test_config"),
5137 crlf: false,
5138 bom: false,
5139 };
5140 let section = make_section();
5141
5142 let entries = config.host_entries();
5144 assert!(!entries[0].has_provider_tags);
5145 assert_eq!(entries[0].tags, vec!["prod"]);
5146
5147 let remote = vec![ProviderHost::new(
5149 "123".to_string(),
5150 "web-1".to_string(),
5151 "1.2.3.4".to_string(),
5152 Vec::new(),
5153 )];
5154 let result = sync_provider(
5155 &mut config,
5156 &MockProvider,
5157 &remote,
5158 §ion,
5159 false,
5160 false,
5161 false,
5162 );
5163 assert_eq!(result.updated, 1);
5164
5165 let entries = config.host_entries();
5167 assert!(entries[0].has_provider_tags);
5168 assert!(entries[0].provider_tags.is_empty());
5169 assert_eq!(entries[0].tags, vec!["prod"]);
5171
5172 let result2 = sync_provider(
5175 &mut config,
5176 &MockProvider,
5177 &remote,
5178 §ion,
5179 false,
5180 false,
5181 false,
5182 );
5183 assert_eq!(result2.unchanged, 1);
5184 }
5185
5186 #[test]
5191 fn test_sync_marks_stale_when_host_disappears() {
5192 let mut config = empty_config();
5193 let section = make_section();
5194 let remote = vec![ProviderHost::new(
5195 "1".to_string(),
5196 "web".to_string(),
5197 "1.2.3.4".to_string(),
5198 Vec::new(),
5199 )];
5200 sync_provider(
5201 &mut config,
5202 &MockProvider,
5203 &remote,
5204 §ion,
5205 false,
5206 false,
5207 false,
5208 );
5209 assert_eq!(config.host_entries().len(), 1);
5210
5211 let result = sync_provider(
5213 &mut config,
5214 &MockProvider,
5215 &[],
5216 §ion,
5217 false,
5218 false,
5219 false,
5220 );
5221 assert_eq!(result.stale, 1);
5222 assert_eq!(result.removed, 0);
5223 let entries = config.host_entries();
5224 assert_eq!(entries.len(), 1);
5225 assert!(entries[0].stale.is_some());
5226 }
5227
5228 #[test]
5229 fn test_sync_clears_stale_when_host_returns() {
5230 let mut config = empty_config();
5231 let section = make_section();
5232 let remote = vec![ProviderHost::new(
5233 "1".to_string(),
5234 "web".to_string(),
5235 "1.2.3.4".to_string(),
5236 Vec::new(),
5237 )];
5238 sync_provider(
5239 &mut config,
5240 &MockProvider,
5241 &remote,
5242 §ion,
5243 false,
5244 false,
5245 false,
5246 );
5247
5248 sync_provider(
5250 &mut config,
5251 &MockProvider,
5252 &[],
5253 §ion,
5254 false,
5255 false,
5256 false,
5257 );
5258 assert!(config.host_entries()[0].stale.is_some());
5259
5260 let result = sync_provider(
5262 &mut config,
5263 &MockProvider,
5264 &remote,
5265 §ion,
5266 false,
5267 false,
5268 false,
5269 );
5270 assert_eq!(result.updated, 1);
5271 assert!(config.host_entries()[0].stale.is_none());
5272 }
5273
5274 #[test]
5275 fn test_sync_stale_timestamp_preserved_not_refreshed() {
5276 let mut config = empty_config();
5277 let section = make_section();
5278 let remote = vec![ProviderHost::new(
5279 "1".to_string(),
5280 "web".to_string(),
5281 "1.2.3.4".to_string(),
5282 Vec::new(),
5283 )];
5284 sync_provider(
5285 &mut config,
5286 &MockProvider,
5287 &remote,
5288 §ion,
5289 false,
5290 false,
5291 false,
5292 );
5293
5294 sync_provider(
5296 &mut config,
5297 &MockProvider,
5298 &[],
5299 §ion,
5300 false,
5301 false,
5302 false,
5303 );
5304 let ts1 = config.host_entries()[0].stale.unwrap();
5305
5306 sync_provider(
5308 &mut config,
5309 &MockProvider,
5310 &[],
5311 §ion,
5312 false,
5313 false,
5314 false,
5315 );
5316 let ts2 = config.host_entries()[0].stale.unwrap();
5317 assert_eq!(ts1, ts2);
5318 }
5319
5320 #[test]
5321 fn test_sync_stale_host_returns_with_new_ip() {
5322 let mut config = empty_config();
5323 let section = make_section();
5324 let remote = vec![ProviderHost::new(
5325 "1".to_string(),
5326 "web".to_string(),
5327 "1.2.3.4".to_string(),
5328 Vec::new(),
5329 )];
5330 sync_provider(
5331 &mut config,
5332 &MockProvider,
5333 &remote,
5334 §ion,
5335 false,
5336 false,
5337 false,
5338 );
5339
5340 sync_provider(
5342 &mut config,
5343 &MockProvider,
5344 &[],
5345 §ion,
5346 false,
5347 false,
5348 false,
5349 );
5350
5351 let remote_new = vec![ProviderHost::new(
5353 "1".to_string(),
5354 "web".to_string(),
5355 "9.9.9.9".to_string(),
5356 Vec::new(),
5357 )];
5358 let result = sync_provider(
5359 &mut config,
5360 &MockProvider,
5361 &remote_new,
5362 §ion,
5363 false,
5364 false,
5365 false,
5366 );
5367 assert_eq!(result.updated, 1);
5368 let entries = config.host_entries();
5369 assert!(entries[0].stale.is_none());
5370 assert_eq!(entries[0].hostname, "9.9.9.9");
5371 }
5372
5373 #[test]
5374 fn test_sync_remove_deleted_still_hard_deletes() {
5375 let mut config = empty_config();
5376 let section = make_section();
5377 let remote = vec![ProviderHost::new(
5378 "1".to_string(),
5379 "web".to_string(),
5380 "1.2.3.4".to_string(),
5381 Vec::new(),
5382 )];
5383 sync_provider(
5384 &mut config,
5385 &MockProvider,
5386 &remote,
5387 §ion,
5388 false,
5389 false,
5390 false,
5391 );
5392
5393 let result = sync_provider(
5395 &mut config,
5396 &MockProvider,
5397 &[],
5398 §ion,
5399 true,
5400 false,
5401 false,
5402 );
5403 assert_eq!(result.removed, 1);
5404 assert_eq!(result.stale, 0);
5405 assert!(config.host_entries().is_empty());
5406 }
5407
5408 #[test]
5409 fn test_sync_partial_failure_no_stale_marking() {
5410 let mut config = empty_config();
5411 let section = make_section();
5412 let remote = vec![ProviderHost::new(
5413 "1".to_string(),
5414 "web".to_string(),
5415 "1.2.3.4".to_string(),
5416 Vec::new(),
5417 )];
5418 sync_provider(
5419 &mut config,
5420 &MockProvider,
5421 &remote,
5422 §ion,
5423 false,
5424 false,
5425 false,
5426 );
5427
5428 let result = sync_provider(
5430 &mut config,
5431 &MockProvider,
5432 &[],
5433 §ion,
5434 false,
5435 true,
5436 false,
5437 );
5438 assert_eq!(result.stale, 0);
5439 assert!(config.host_entries()[0].stale.is_none());
5440 }
5441
5442 #[test]
5443 fn test_sync_dry_run_reports_stale_count() {
5444 let mut config = empty_config();
5445 let section = make_section();
5446 let remote = vec![ProviderHost::new(
5447 "1".to_string(),
5448 "web".to_string(),
5449 "1.2.3.4".to_string(),
5450 Vec::new(),
5451 )];
5452 sync_provider(
5453 &mut config,
5454 &MockProvider,
5455 &remote,
5456 §ion,
5457 false,
5458 false,
5459 false,
5460 );
5461
5462 let result = sync_provider(
5464 &mut config,
5465 &MockProvider,
5466 &[],
5467 §ion,
5468 false,
5469 false,
5470 true,
5471 );
5472 assert_eq!(result.stale, 1);
5473 assert!(config.host_entries()[0].stale.is_none()); }
5475
5476 #[test]
5477 fn test_sync_top_level_host_marked_stale() {
5478 let config_str = "\
5480Host do-web
5481 HostName 1.2.3.4
5482 # purple:provider digitalocean:1
5483";
5484 let mut config = SshConfigFile {
5485 elements: SshConfigFile::parse_content(config_str),
5486 path: PathBuf::from("/tmp/test_config"),
5487 crlf: false,
5488 bom: false,
5489 };
5490 let section = make_section();
5491 let result = sync_provider(
5492 &mut config,
5493 &MockProvider,
5494 &[],
5495 §ion,
5496 false,
5497 false,
5498 false,
5499 );
5500 assert_eq!(result.stale, 1);
5501 }
5502
5503 #[test]
5504 fn test_sync_multiple_hosts_disappear() {
5505 let mut config = empty_config();
5506 let section = make_section();
5507 let remote = vec![
5508 ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5509 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5510 ProviderHost::new("3".into(), "app".into(), "3.3.3.3".into(), Vec::new()),
5511 ];
5512 sync_provider(
5513 &mut config,
5514 &MockProvider,
5515 &remote,
5516 §ion,
5517 false,
5518 false,
5519 false,
5520 );
5521 assert_eq!(config.host_entries().len(), 3);
5522
5523 let remaining = vec![ProviderHost::new(
5525 "2".into(),
5526 "db".into(),
5527 "2.2.2.2".into(),
5528 Vec::new(),
5529 )];
5530 let result = sync_provider(
5531 &mut config,
5532 &MockProvider,
5533 &remaining,
5534 §ion,
5535 false,
5536 false,
5537 false,
5538 );
5539 assert_eq!(result.stale, 2);
5540 assert_eq!(result.unchanged, 1);
5541 let entries = config.host_entries();
5542 assert!(
5543 entries
5544 .iter()
5545 .find(|e| e.alias == "do-web")
5546 .unwrap()
5547 .stale
5548 .is_some()
5549 );
5550 assert!(
5551 entries
5552 .iter()
5553 .find(|e| e.alias == "do-db")
5554 .unwrap()
5555 .stale
5556 .is_none()
5557 );
5558 assert!(
5559 entries
5560 .iter()
5561 .find(|e| e.alias == "do-app")
5562 .unwrap()
5563 .stale
5564 .is_some()
5565 );
5566 }
5567
5568 #[test]
5569 fn test_sync_already_stale_then_remove_deleted() {
5570 let mut config = empty_config();
5571 let section = make_section();
5572 let remote = vec![ProviderHost::new(
5573 "1".into(),
5574 "web".into(),
5575 "1.1.1.1".into(),
5576 Vec::new(),
5577 )];
5578 sync_provider(
5579 &mut config,
5580 &MockProvider,
5581 &remote,
5582 §ion,
5583 false,
5584 false,
5585 false,
5586 );
5587
5588 sync_provider(
5590 &mut config,
5591 &MockProvider,
5592 &[],
5593 §ion,
5594 false,
5595 false,
5596 false,
5597 );
5598 assert!(config.host_entries()[0].stale.is_some());
5599
5600 let result = sync_provider(
5602 &mut config,
5603 &MockProvider,
5604 &[],
5605 §ion,
5606 true,
5607 false,
5608 false,
5609 );
5610 assert_eq!(result.removed, 1);
5611 assert!(config.host_entries().is_empty());
5612 }
5613
5614 #[test]
5615 fn test_sync_stale_cross_provider_isolation() {
5616 let mut config = empty_config();
5617 let do_section = make_section();
5618 let vultr_section = ProviderSection {
5619 alias_prefix: "vultr".to_string(),
5620 ..make_section()
5621 };
5622
5623 let do_remote = vec![ProviderHost::new(
5625 "1".into(),
5626 "web".into(),
5627 "1.1.1.1".into(),
5628 Vec::new(),
5629 )];
5630 sync_provider(
5631 &mut config,
5632 &MockProvider,
5633 &do_remote,
5634 &do_section,
5635 false,
5636 false,
5637 false,
5638 );
5639
5640 let vultr_remote = vec![ProviderHost::new(
5642 "1".into(),
5643 "db".into(),
5644 "2.2.2.2".into(),
5645 Vec::new(),
5646 )];
5647 sync_provider(
5648 &mut config,
5649 &MockProvider2,
5650 &vultr_remote,
5651 &vultr_section,
5652 false,
5653 false,
5654 false,
5655 );
5656
5657 let result = sync_provider(
5659 &mut config,
5660 &MockProvider,
5661 &[],
5662 &do_section,
5663 false,
5664 false,
5665 false,
5666 );
5667 assert_eq!(result.stale, 1);
5668 let entries = config.host_entries();
5669 assert!(
5670 entries
5671 .iter()
5672 .find(|e| e.alias == "do-web")
5673 .unwrap()
5674 .stale
5675 .is_some()
5676 );
5677 assert!(
5678 entries
5679 .iter()
5680 .find(|e| e.alias == "vultr-db")
5681 .unwrap()
5682 .stale
5683 .is_none()
5684 );
5685 }
5686
5687 #[test]
5688 fn test_sync_stale_host_returns_with_tag_changes() {
5689 let mut config = empty_config();
5690 let section = make_section();
5691 let remote = vec![ProviderHost::new(
5692 "1".into(),
5693 "web".into(),
5694 "1.1.1.1".into(),
5695 vec!["prod".into()],
5696 )];
5697 sync_provider(
5698 &mut config,
5699 &MockProvider,
5700 &remote,
5701 §ion,
5702 false,
5703 false,
5704 false,
5705 );
5706
5707 sync_provider(
5709 &mut config,
5710 &MockProvider,
5711 &[],
5712 §ion,
5713 false,
5714 false,
5715 false,
5716 );
5717 assert!(config.host_entries()[0].stale.is_some());
5718
5719 let remote_new = vec![ProviderHost::new(
5721 "1".into(),
5722 "web".into(),
5723 "1.1.1.1".into(),
5724 vec!["staging".into()],
5725 )];
5726 let result = sync_provider(
5727 &mut config,
5728 &MockProvider,
5729 &remote_new,
5730 §ion,
5731 false,
5732 false,
5733 false,
5734 );
5735 assert_eq!(result.updated, 1);
5736 let entries = config.host_entries();
5737 assert!(entries[0].stale.is_none());
5738 assert!(entries[0].provider_tags.contains(&"staging".to_string()));
5739 }
5740
5741 #[test]
5742 fn test_sync_stale_result_count_includes_already_stale() {
5743 let mut config = empty_config();
5744 let section = make_section();
5745 let remote = vec![ProviderHost::new(
5746 "1".into(),
5747 "web".into(),
5748 "1.2.3.4".into(),
5749 Vec::new(),
5750 )];
5751 sync_provider(
5752 &mut config,
5753 &MockProvider,
5754 &remote,
5755 §ion,
5756 false,
5757 false,
5758 false,
5759 );
5760
5761 let r1 = sync_provider(
5763 &mut config,
5764 &MockProvider,
5765 &[],
5766 §ion,
5767 false,
5768 false,
5769 false,
5770 );
5771 assert_eq!(r1.stale, 1);
5772
5773 let r2 = sync_provider(
5775 &mut config,
5776 &MockProvider,
5777 &[],
5778 §ion,
5779 false,
5780 false,
5781 false,
5782 );
5783 assert_eq!(r2.stale, 1);
5784 }
5785
5786 #[test]
5791 fn test_sync_stale_config_byte_identical_after_clear() {
5792 let mut config = empty_config();
5793 let section = make_section();
5794 let remote = vec![
5795 ProviderHost::new(
5796 "1".into(),
5797 "web".into(),
5798 "1.1.1.1".into(),
5799 vec!["prod".into()],
5800 ),
5801 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5802 ];
5803 sync_provider(
5805 &mut config,
5806 &MockProvider,
5807 &remote,
5808 §ion,
5809 false,
5810 false,
5811 false,
5812 );
5813 let config_after_add = config.serialize();
5814
5815 sync_provider(
5817 &mut config,
5818 &MockProvider,
5819 &[],
5820 §ion,
5821 false,
5822 false,
5823 false,
5824 );
5825 let config_after_stale = config.serialize();
5826 assert_ne!(config_after_stale, config_after_add);
5827
5828 sync_provider(
5830 &mut config,
5831 &MockProvider,
5832 &remote,
5833 §ion,
5834 false,
5835 false,
5836 false,
5837 );
5838 let config_after_return = config.serialize();
5839 assert_eq!(
5840 config_after_return, config_after_add,
5841 "config must be byte-identical after stale->return cycle"
5842 );
5843 }
5844
5845 #[test]
5846 fn test_sync_stale_preserves_neighboring_hosts() {
5847 let config_str = "\
5848Host manual
5849 HostName 10.0.0.1
5850 User admin
5851
5852";
5853 let mut config = SshConfigFile {
5854 elements: SshConfigFile::parse_content(config_str),
5855 path: PathBuf::from("/tmp/test_config"),
5856 crlf: false,
5857 bom: false,
5858 };
5859 let section = make_section();
5860 let remote = vec![ProviderHost::new(
5861 "1".into(),
5862 "web".into(),
5863 "1.1.1.1".into(),
5864 Vec::new(),
5865 )];
5866 sync_provider(
5867 &mut config,
5868 &MockProvider,
5869 &remote,
5870 §ion,
5871 false,
5872 false,
5873 false,
5874 );
5875
5876 sync_provider(
5878 &mut config,
5879 &MockProvider,
5880 &[],
5881 §ion,
5882 false,
5883 false,
5884 false,
5885 );
5886 let output = config.serialize();
5887 assert!(
5888 output.contains("Host manual"),
5889 "manual host lost after stale marking"
5890 );
5891 assert!(
5892 output.contains("HostName 10.0.0.1"),
5893 "manual host directives lost after stale marking"
5894 );
5895 assert!(
5896 output.contains("User admin"),
5897 "manual host user lost after stale marking"
5898 );
5899 }
5900
5901 #[test]
5902 fn test_sync_stale_then_purge_leaves_clean_config() {
5903 let config_str = "\
5904Host manual
5905 HostName 10.0.0.1
5906 User admin
5907
5908";
5909 let mut config = SshConfigFile {
5910 elements: SshConfigFile::parse_content(config_str),
5911 path: PathBuf::from("/tmp/test_config"),
5912 crlf: false,
5913 bom: false,
5914 };
5915 let section = make_section();
5916 let remote = vec![
5917 ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5918 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5919 ];
5920 sync_provider(
5921 &mut config,
5922 &MockProvider,
5923 &remote,
5924 §ion,
5925 false,
5926 false,
5927 false,
5928 );
5929
5930 sync_provider(
5932 &mut config,
5933 &MockProvider,
5934 &[],
5935 §ion,
5936 false,
5937 false,
5938 false,
5939 );
5940
5941 let stale = config.stale_hosts();
5943 for (alias, _) in &stale {
5944 config.delete_host(alias);
5945 }
5946
5947 let output = config.serialize();
5948 assert!(output.contains("Host manual"));
5950 assert!(output.contains("HostName 10.0.0.1"));
5951 assert!(!output.contains("purple:stale"));
5953 assert!(!output.contains("purple:group"));
5955 assert!(
5957 !output.contains("\n\n\n"),
5958 "excessive blank lines after purge:\n{}",
5959 output
5960 );
5961 }
5962
5963 #[test]
5964 fn test_sync_stale_empty_ip_return_preserves_hostname() {
5965 let mut config = empty_config();
5966 let section = make_section();
5967 let remote = vec![ProviderHost::new(
5968 "1".into(),
5969 "web".into(),
5970 "1.1.1.1".into(),
5971 Vec::new(),
5972 )];
5973 sync_provider(
5974 &mut config,
5975 &MockProvider,
5976 &remote,
5977 §ion,
5978 false,
5979 false,
5980 false,
5981 );
5982
5983 sync_provider(
5985 &mut config,
5986 &MockProvider,
5987 &[],
5988 §ion,
5989 false,
5990 false,
5991 false,
5992 );
5993 assert!(config.host_entries()[0].stale.is_some());
5994
5995 let remote_empty_ip = vec![ProviderHost::new(
5997 "1".into(),
5998 "web".into(),
5999 "".into(),
6000 Vec::new(),
6001 )];
6002 let result = sync_provider(
6003 &mut config,
6004 &MockProvider,
6005 &remote_empty_ip,
6006 §ion,
6007 false,
6008 false,
6009 false,
6010 );
6011 assert_eq!(result.updated, 1);
6012 assert!(config.host_entries()[0].stale.is_none());
6014 assert_eq!(config.host_entries()[0].hostname, "1.1.1.1");
6016 }
6017
6018 #[test]
6019 fn test_sync_insert_adds_blank_line_before_next_group() {
6020 let config_str = "\
6023# purple:group DigitalOcean
6024
6025Host do-web
6026 HostName 1.1.1.1
6027 User root
6028 # purple:provider digitalocean:111
6029
6030# purple:group Hetzner
6031
6032Host hz-build
6033 HostName 2.2.2.2
6034 User ci
6035 # purple:provider hetzner:222
6036";
6037 let mut config = SshConfigFile {
6038 elements: SshConfigFile::parse_content(config_str),
6039 path: PathBuf::from("/tmp/test_config"),
6040 crlf: false,
6041 bom: false,
6042 };
6043 let section = make_section();
6044 let remote = vec![
6045 ProviderHost::new("111".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
6046 ProviderHost::new("333".into(), "db".into(), "3.3.3.3".into(), Vec::new()),
6047 ];
6048 sync_provider(
6049 &mut config,
6050 &MockProvider,
6051 &remote,
6052 §ion,
6053 false,
6054 false,
6055 false,
6056 );
6057 let output = config.serialize();
6058 assert!(
6060 output.contains("\n\n# purple:group Hetzner"),
6061 "missing blank line before next group header:\n{}",
6062 output
6063 );
6064 assert!(
6066 !output.contains("\n\n\n"),
6067 "triple blank lines found:\n{}",
6068 output
6069 );
6070 }
6071
6072 #[test]
6073 fn test_sync_insert_blank_line_real_world_scenario() {
6074 let config_str = "\
6078# purple:group DigitalOcean
6079
6080Host do-signalproxy
6081 HostName 128.199.41.235
6082 User root
6083 IdentityFile ~/.ssh/id_ed25519
6084 # purple:provider digitalocean:517532225
6085 # purple:meta region=ams3,size=s-1vcpu-512mb-10gb,status=active
6086 Port 60022
6087 # purple:provider_tags
6088 # purple:tags signal
6089# purple:group Proxmox VE
6090
6091Host pve-testvm
6092 HostName 192.168.1.100
6093 User root
6094 # purple:provider proxmox:100
6095";
6096 let mut config = SshConfigFile {
6097 elements: SshConfigFile::parse_content(config_str),
6098 path: PathBuf::from("/tmp/test_config"),
6099 crlf: false,
6100 bom: false,
6101 };
6102 let section = make_section();
6103 let remote = vec![
6105 ProviderHost::new(
6106 "517532225".into(),
6107 "signalproxy-nl".into(),
6108 "128.199.41.235".into(),
6109 Vec::new(),
6110 ),
6111 ProviderHost::new(
6112 "560734563".into(),
6113 "ubuntu-nyc1".into(),
6114 "167.172.128.123".into(),
6115 Vec::new(),
6116 ),
6117 ];
6118 sync_provider(
6119 &mut config,
6120 &MockProvider,
6121 &remote,
6122 §ion,
6123 false,
6124 false,
6125 false,
6126 );
6127 let output = config.serialize();
6128
6129 assert!(
6131 output.contains("\n\n# purple:group Proxmox VE"),
6132 "missing blank line before Proxmox group:\n{}",
6133 output
6134 );
6135 assert!(
6137 output.contains("Host do-signalproxy") || output.contains("Host do-signalproxy-nl")
6138 );
6139 assert!(output.contains("Host do-ubuntu-nyc1"));
6140 assert!(output.contains("Host pve-testvm"));
6142 assert!(
6144 !output.contains("\n\n\n"),
6145 "triple blank lines:\n{}",
6146 output
6147 );
6148 }
6149}