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 }
480 }
481
482 struct MockProvider;
483 impl Provider for MockProvider {
484 fn name(&self) -> &str {
485 "digitalocean"
486 }
487 fn short_label(&self) -> &str {
488 "do"
489 }
490 fn fetch_hosts_cancellable(
491 &self,
492 _token: &str,
493 _cancel: &std::sync::atomic::AtomicBool,
494 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
495 Ok(Vec::new())
496 }
497 }
498
499 #[test]
500 fn test_build_alias() {
501 assert_eq!(build_alias("do", "web-1"), "do-web-1");
502 assert_eq!(build_alias("", "web-1"), "web-1");
503 assert_eq!(build_alias("ocean", "db"), "ocean-db");
504 }
505
506 #[test]
507 fn test_sanitize_name() {
508 assert_eq!(sanitize_name("web-1"), "web-1");
509 assert_eq!(sanitize_name("My Server"), "my-server");
510 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
511 assert_eq!(sanitize_name("--weird--"), "weird");
512 assert_eq!(sanitize_name("UPPER"), "upper");
513 assert_eq!(sanitize_name("a--b"), "a-b");
514 assert_eq!(sanitize_name(""), "server");
515 assert_eq!(sanitize_name("..."), "server");
516 }
517
518 #[test]
519 fn test_sync_adds_new_hosts() {
520 let mut config = empty_config();
521 let section = make_section();
522 let remote = vec![
523 ProviderHost::new(
524 "123".to_string(),
525 "web-1".to_string(),
526 "1.2.3.4".to_string(),
527 Vec::new(),
528 ),
529 ProviderHost::new(
530 "456".to_string(),
531 "db-1".to_string(),
532 "5.6.7.8".to_string(),
533 Vec::new(),
534 ),
535 ];
536
537 let result = sync_provider(
538 &mut config,
539 &MockProvider,
540 &remote,
541 §ion,
542 false,
543 false,
544 false,
545 );
546 assert_eq!(result.added, 2);
547 assert_eq!(result.updated, 0);
548 assert_eq!(result.unchanged, 0);
549
550 let entries = config.host_entries();
551 assert_eq!(entries.len(), 2);
552 assert_eq!(entries[0].alias, "do-web-1");
553 assert_eq!(entries[0].hostname, "1.2.3.4");
554 assert_eq!(entries[1].alias, "do-db-1");
555 }
556
557 #[test]
558 fn test_sync_updates_changed_ip() {
559 let mut config = empty_config();
560 let section = make_section();
561
562 let remote = vec![ProviderHost::new(
564 "123".to_string(),
565 "web-1".to_string(),
566 "1.2.3.4".to_string(),
567 Vec::new(),
568 )];
569 sync_provider(
570 &mut config,
571 &MockProvider,
572 &remote,
573 §ion,
574 false,
575 false,
576 false,
577 );
578
579 let remote = vec![ProviderHost::new(
581 "123".to_string(),
582 "web-1".to_string(),
583 "9.8.7.6".to_string(),
584 Vec::new(),
585 )];
586 let result = sync_provider(
587 &mut config,
588 &MockProvider,
589 &remote,
590 §ion,
591 false,
592 false,
593 false,
594 );
595 assert_eq!(result.updated, 1);
596 assert_eq!(result.added, 0);
597
598 let entries = config.host_entries();
599 assert_eq!(entries[0].hostname, "9.8.7.6");
600 }
601
602 #[test]
603 fn test_sync_unchanged() {
604 let mut config = empty_config();
605 let section = make_section();
606
607 let remote = vec![ProviderHost::new(
608 "123".to_string(),
609 "web-1".to_string(),
610 "1.2.3.4".to_string(),
611 Vec::new(),
612 )];
613 sync_provider(
614 &mut config,
615 &MockProvider,
616 &remote,
617 §ion,
618 false,
619 false,
620 false,
621 );
622
623 let result = sync_provider(
625 &mut config,
626 &MockProvider,
627 &remote,
628 §ion,
629 false,
630 false,
631 false,
632 );
633 assert_eq!(result.unchanged, 1);
634 assert_eq!(result.added, 0);
635 assert_eq!(result.updated, 0);
636 }
637
638 #[test]
639 fn test_sync_removes_deleted() {
640 let mut config = empty_config();
641 let section = make_section();
642
643 let remote = vec![ProviderHost::new(
644 "123".to_string(),
645 "web-1".to_string(),
646 "1.2.3.4".to_string(),
647 Vec::new(),
648 )];
649 sync_provider(
650 &mut config,
651 &MockProvider,
652 &remote,
653 §ion,
654 false,
655 false,
656 false,
657 );
658 assert_eq!(config.host_entries().len(), 1);
659
660 let result = sync_provider(
662 &mut config,
663 &MockProvider,
664 &[],
665 §ion,
666 true,
667 false,
668 false,
669 );
670 assert_eq!(result.removed, 1);
671 assert_eq!(config.host_entries().len(), 0);
672 }
673
674 #[test]
675 fn test_sync_dry_run_no_mutations() {
676 let mut config = empty_config();
677 let section = make_section();
678
679 let remote = vec![ProviderHost::new(
680 "123".to_string(),
681 "web-1".to_string(),
682 "1.2.3.4".to_string(),
683 Vec::new(),
684 )];
685
686 let result = sync_provider(
687 &mut config,
688 &MockProvider,
689 &remote,
690 §ion,
691 false,
692 false,
693 true,
694 );
695 assert_eq!(result.added, 1);
696 assert_eq!(config.host_entries().len(), 0); }
698
699 #[test]
700 fn test_sync_dedup_server_id_in_response() {
701 let mut config = empty_config();
702 let section = make_section();
703 let remote = vec![
704 ProviderHost::new(
705 "123".to_string(),
706 "web-1".to_string(),
707 "1.2.3.4".to_string(),
708 Vec::new(),
709 ),
710 ProviderHost::new(
711 "123".to_string(),
712 "web-1-dup".to_string(),
713 "5.6.7.8".to_string(),
714 Vec::new(),
715 ),
716 ];
717
718 let result = sync_provider(
719 &mut config,
720 &MockProvider,
721 &remote,
722 §ion,
723 false,
724 false,
725 false,
726 );
727 assert_eq!(result.added, 1);
728 assert_eq!(config.host_entries().len(), 1);
729 assert_eq!(config.host_entries()[0].alias, "do-web-1");
730 }
731
732 #[test]
733 fn test_sync_duplicate_local_server_id_keeps_first() {
734 let content = "\
736Host do-web-1
737 HostName 1.2.3.4
738 # purple:provider digitalocean:123
739
740Host do-web-1-copy
741 HostName 1.2.3.4
742 # purple:provider digitalocean:123
743";
744 let mut config = SshConfigFile {
745 elements: SshConfigFile::parse_content(content),
746 path: PathBuf::from("/tmp/test_config"),
747 crlf: false,
748 bom: false,
749 };
750 let section = make_section();
751
752 let remote = vec![ProviderHost::new(
754 "123".to_string(),
755 "web-1".to_string(),
756 "5.6.7.8".to_string(),
757 Vec::new(),
758 )];
759
760 let result = sync_provider(
761 &mut config,
762 &MockProvider,
763 &remote,
764 §ion,
765 false,
766 false,
767 false,
768 );
769 assert_eq!(result.updated, 1);
771 assert_eq!(result.added, 0);
772 let entries = config.host_entries();
773 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
774 assert_eq!(first.hostname, "5.6.7.8");
775 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
777 assert_eq!(copy.hostname, "1.2.3.4");
778 }
779
780 #[test]
781 fn test_sync_no_duplicate_header_on_repeated_sync() {
782 let mut config = empty_config();
783 let section = make_section();
784
785 let remote = vec![ProviderHost::new(
787 "123".to_string(),
788 "web-1".to_string(),
789 "1.2.3.4".to_string(),
790 Vec::new(),
791 )];
792 sync_provider(
793 &mut config,
794 &MockProvider,
795 &remote,
796 §ion,
797 false,
798 false,
799 false,
800 );
801
802 let remote = vec![
804 ProviderHost::new(
805 "123".to_string(),
806 "web-1".to_string(),
807 "1.2.3.4".to_string(),
808 Vec::new(),
809 ),
810 ProviderHost::new(
811 "456".to_string(),
812 "db-1".to_string(),
813 "5.6.7.8".to_string(),
814 Vec::new(),
815 ),
816 ];
817 sync_provider(
818 &mut config,
819 &MockProvider,
820 &remote,
821 §ion,
822 false,
823 false,
824 false,
825 );
826
827 let header_count = config
829 .elements
830 .iter()
831 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
832 .count();
833 assert_eq!(header_count, 1);
834 assert_eq!(config.host_entries().len(), 2);
835 }
836
837 #[test]
838 fn test_sync_removes_orphan_header() {
839 let mut config = empty_config();
840 let section = make_section();
841
842 let remote = vec![ProviderHost::new(
844 "123".to_string(),
845 "web-1".to_string(),
846 "1.2.3.4".to_string(),
847 Vec::new(),
848 )];
849 sync_provider(
850 &mut config,
851 &MockProvider,
852 &remote,
853 §ion,
854 false,
855 false,
856 false,
857 );
858
859 let has_header = config
861 .elements
862 .iter()
863 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
864 assert!(has_header);
865
866 let result = sync_provider(
868 &mut config,
869 &MockProvider,
870 &[],
871 §ion,
872 true,
873 false,
874 false,
875 );
876 assert_eq!(result.removed, 1);
877
878 let has_header = config
880 .elements
881 .iter()
882 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
883 assert!(!has_header);
884 }
885
886 #[test]
887 fn test_sync_writes_provider_tags() {
888 let mut config = empty_config();
889 let section = make_section();
890 let remote = vec![ProviderHost::new(
891 "123".to_string(),
892 "web-1".to_string(),
893 "1.2.3.4".to_string(),
894 vec!["production".to_string(), "us-east".to_string()],
895 )];
896
897 sync_provider(
898 &mut config,
899 &MockProvider,
900 &remote,
901 §ion,
902 false,
903 false,
904 false,
905 );
906
907 let entries = config.host_entries();
908 assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
909 }
910
911 #[test]
912 fn test_sync_updates_changed_tags() {
913 let mut config = empty_config();
914 let section = make_section();
915
916 let remote = vec![ProviderHost::new(
918 "123".to_string(),
919 "web-1".to_string(),
920 "1.2.3.4".to_string(),
921 vec!["staging".to_string()],
922 )];
923 sync_provider(
924 &mut config,
925 &MockProvider,
926 &remote,
927 §ion,
928 false,
929 false,
930 false,
931 );
932 assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
933
934 let remote = vec![ProviderHost::new(
936 "123".to_string(),
937 "web-1".to_string(),
938 "1.2.3.4".to_string(),
939 vec!["production".to_string(), "us-east".to_string()],
940 )];
941 let result = sync_provider(
942 &mut config,
943 &MockProvider,
944 &remote,
945 §ion,
946 false,
947 false,
948 false,
949 );
950 assert_eq!(result.updated, 1);
951 assert_eq!(
952 config.host_entries()[0].provider_tags,
953 vec!["production", "us-east"]
954 );
955 }
956
957 #[test]
958 fn test_sync_combined_add_update_remove() {
959 let mut config = empty_config();
960 let section = make_section();
961
962 let remote = vec![
964 ProviderHost::new(
965 "1".to_string(),
966 "web".to_string(),
967 "1.1.1.1".to_string(),
968 Vec::new(),
969 ),
970 ProviderHost::new(
971 "2".to_string(),
972 "db".to_string(),
973 "2.2.2.2".to_string(),
974 Vec::new(),
975 ),
976 ];
977 sync_provider(
978 &mut config,
979 &MockProvider,
980 &remote,
981 §ion,
982 false,
983 false,
984 false,
985 );
986 assert_eq!(config.host_entries().len(), 2);
987
988 let remote = vec![
990 ProviderHost::new(
991 "1".to_string(),
992 "web".to_string(),
993 "9.9.9.9".to_string(),
994 Vec::new(),
995 ),
996 ProviderHost::new(
997 "3".to_string(),
998 "cache".to_string(),
999 "3.3.3.3".to_string(),
1000 Vec::new(),
1001 ),
1002 ];
1003 let result = sync_provider(
1004 &mut config,
1005 &MockProvider,
1006 &remote,
1007 §ion,
1008 true,
1009 false,
1010 false,
1011 );
1012 assert_eq!(result.updated, 1);
1013 assert_eq!(result.added, 1);
1014 assert_eq!(result.removed, 1);
1015
1016 let entries = config.host_entries();
1017 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
1019 assert_eq!(entries[0].hostname, "9.9.9.9");
1020 assert_eq!(entries[1].alias, "do-cache");
1021 }
1022
1023 #[test]
1024 fn test_sync_tag_order_insensitive() {
1025 let mut config = empty_config();
1026 let section = make_section();
1027
1028 let remote = vec![ProviderHost::new(
1030 "123".to_string(),
1031 "web-1".to_string(),
1032 "1.2.3.4".to_string(),
1033 vec!["beta".to_string(), "alpha".to_string()],
1034 )];
1035 sync_provider(
1036 &mut config,
1037 &MockProvider,
1038 &remote,
1039 §ion,
1040 false,
1041 false,
1042 false,
1043 );
1044
1045 let remote = vec![ProviderHost::new(
1047 "123".to_string(),
1048 "web-1".to_string(),
1049 "1.2.3.4".to_string(),
1050 vec!["alpha".to_string(), "beta".to_string()],
1051 )];
1052 let result = sync_provider(
1053 &mut config,
1054 &MockProvider,
1055 &remote,
1056 §ion,
1057 false,
1058 false,
1059 false,
1060 );
1061 assert_eq!(result.unchanged, 1);
1062 assert_eq!(result.updated, 0);
1063 }
1064
1065 fn config_with_include_provider_host() -> SshConfigFile {
1066 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
1067
1068 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
1070 let included_elements = SshConfigFile::parse_content(content);
1071
1072 SshConfigFile {
1073 elements: vec![ConfigElement::Include(IncludeDirective {
1074 raw_line: "Include conf.d/*".to_string(),
1075 pattern: "conf.d/*".to_string(),
1076 resolved_files: vec![IncludedFile {
1077 path: PathBuf::from("/tmp/included.conf"),
1078 elements: included_elements,
1079 }],
1080 })],
1081 path: PathBuf::from("/tmp/test_config"),
1082 crlf: false,
1083 bom: false,
1084 }
1085 }
1086
1087 #[test]
1088 fn test_sync_include_host_skips_update() {
1089 let mut config = config_with_include_provider_host();
1090 let section = make_section();
1091
1092 let remote = vec![ProviderHost::new(
1094 "inc1".to_string(),
1095 "included".to_string(),
1096 "9.9.9.9".to_string(),
1097 Vec::new(),
1098 )];
1099 let result = sync_provider(
1100 &mut config,
1101 &MockProvider,
1102 &remote,
1103 §ion,
1104 false,
1105 false,
1106 false,
1107 );
1108 assert_eq!(result.unchanged, 1);
1109 assert_eq!(result.updated, 0);
1110 assert_eq!(result.added, 0);
1111
1112 let entries = config.host_entries();
1114 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
1115 assert_eq!(included.hostname, "1.2.3.4");
1116 }
1117
1118 #[test]
1119 fn test_sync_include_host_skips_remove() {
1120 let mut config = config_with_include_provider_host();
1121 let section = make_section();
1122
1123 let result = sync_provider(
1125 &mut config,
1126 &MockProvider,
1127 &[],
1128 §ion,
1129 true,
1130 false,
1131 false,
1132 );
1133 assert_eq!(result.removed, 0);
1134 assert_eq!(config.host_entries().len(), 1);
1135 }
1136
1137 #[test]
1138 fn test_sync_dry_run_remove_count() {
1139 let mut config = empty_config();
1140 let section = make_section();
1141
1142 let remote = vec![
1144 ProviderHost::new(
1145 "1".to_string(),
1146 "web".to_string(),
1147 "1.1.1.1".to_string(),
1148 Vec::new(),
1149 ),
1150 ProviderHost::new(
1151 "2".to_string(),
1152 "db".to_string(),
1153 "2.2.2.2".to_string(),
1154 Vec::new(),
1155 ),
1156 ];
1157 sync_provider(
1158 &mut config,
1159 &MockProvider,
1160 &remote,
1161 §ion,
1162 false,
1163 false,
1164 false,
1165 );
1166 assert_eq!(config.host_entries().len(), 2);
1167
1168 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
1170 assert_eq!(result.removed, 2);
1171 assert_eq!(config.host_entries().len(), 2); }
1173
1174 #[test]
1175 fn test_sync_tags_cleared_remotely_preserved_locally() {
1176 let mut config = empty_config();
1177 let section = make_section();
1178
1179 let remote = vec![ProviderHost::new(
1181 "123".to_string(),
1182 "web-1".to_string(),
1183 "1.2.3.4".to_string(),
1184 vec!["production".to_string()],
1185 )];
1186 sync_provider(
1187 &mut config,
1188 &MockProvider,
1189 &remote,
1190 §ion,
1191 false,
1192 false,
1193 false,
1194 );
1195 assert_eq!(config.host_entries()[0].provider_tags, vec!["production"]);
1196
1197 let remote = vec![ProviderHost::new(
1199 "123".to_string(),
1200 "web-1".to_string(),
1201 "1.2.3.4".to_string(),
1202 Vec::new(),
1203 )];
1204 let result = sync_provider(
1205 &mut config,
1206 &MockProvider,
1207 &remote,
1208 §ion,
1209 false,
1210 false,
1211 false,
1212 );
1213 assert_eq!(result.updated, 1);
1214 assert!(config.host_entries()[0].provider_tags.is_empty());
1215 }
1216
1217 #[test]
1218 fn test_sync_deduplicates_alias() {
1219 let content = "Host do-web-1\n HostName 10.0.0.1\n";
1220 let mut config = SshConfigFile {
1221 elements: SshConfigFile::parse_content(content),
1222 path: PathBuf::from("/tmp/test_config"),
1223 crlf: false,
1224 bom: false,
1225 };
1226 let section = make_section();
1227
1228 let remote = vec![ProviderHost::new(
1229 "999".to_string(),
1230 "web-1".to_string(),
1231 "1.2.3.4".to_string(),
1232 Vec::new(),
1233 )];
1234
1235 sync_provider(
1236 &mut config,
1237 &MockProvider,
1238 &remote,
1239 §ion,
1240 false,
1241 false,
1242 false,
1243 );
1244
1245 let entries = config.host_entries();
1246 assert_eq!(entries.len(), 2);
1248 assert_eq!(entries[0].alias, "do-web-1");
1249 assert_eq!(entries[1].alias, "do-web-1-2");
1250 }
1251
1252 #[test]
1253 fn test_sync_renames_on_prefix_change() {
1254 let mut config = empty_config();
1255 let section = make_section(); let remote = vec![ProviderHost::new(
1259 "123".to_string(),
1260 "web-1".to_string(),
1261 "1.2.3.4".to_string(),
1262 Vec::new(),
1263 )];
1264 sync_provider(
1265 &mut config,
1266 &MockProvider,
1267 &remote,
1268 §ion,
1269 false,
1270 false,
1271 false,
1272 );
1273 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1274
1275 let new_section = ProviderSection {
1277 alias_prefix: "ocean".to_string(),
1278 ..section
1279 };
1280 let result = sync_provider(
1281 &mut config,
1282 &MockProvider,
1283 &remote,
1284 &new_section,
1285 false,
1286 false,
1287 false,
1288 );
1289 assert_eq!(result.updated, 1);
1290 assert_eq!(result.unchanged, 0);
1291
1292 let entries = config.host_entries();
1293 assert_eq!(entries.len(), 1);
1294 assert_eq!(entries[0].alias, "ocean-web-1");
1295 assert_eq!(entries[0].hostname, "1.2.3.4");
1296 }
1297
1298 #[test]
1299 fn test_sync_rename_and_ip_change() {
1300 let mut config = empty_config();
1301 let section = make_section();
1302
1303 let remote = vec![ProviderHost::new(
1304 "123".to_string(),
1305 "web-1".to_string(),
1306 "1.2.3.4".to_string(),
1307 Vec::new(),
1308 )];
1309 sync_provider(
1310 &mut config,
1311 &MockProvider,
1312 &remote,
1313 §ion,
1314 false,
1315 false,
1316 false,
1317 );
1318
1319 let new_section = ProviderSection {
1321 alias_prefix: "ocean".to_string(),
1322 ..section
1323 };
1324 let remote = vec![ProviderHost::new(
1325 "123".to_string(),
1326 "web-1".to_string(),
1327 "9.9.9.9".to_string(),
1328 Vec::new(),
1329 )];
1330 let result = sync_provider(
1331 &mut config,
1332 &MockProvider,
1333 &remote,
1334 &new_section,
1335 false,
1336 false,
1337 false,
1338 );
1339 assert_eq!(result.updated, 1);
1340
1341 let entries = config.host_entries();
1342 assert_eq!(entries[0].alias, "ocean-web-1");
1343 assert_eq!(entries[0].hostname, "9.9.9.9");
1344 }
1345
1346 #[test]
1347 fn test_sync_rename_dry_run_no_mutation() {
1348 let mut config = empty_config();
1349 let section = make_section();
1350
1351 let remote = vec![ProviderHost::new(
1352 "123".to_string(),
1353 "web-1".to_string(),
1354 "1.2.3.4".to_string(),
1355 Vec::new(),
1356 )];
1357 sync_provider(
1358 &mut config,
1359 &MockProvider,
1360 &remote,
1361 §ion,
1362 false,
1363 false,
1364 false,
1365 );
1366
1367 let new_section = ProviderSection {
1368 alias_prefix: "ocean".to_string(),
1369 ..section
1370 };
1371 let result = sync_provider(
1372 &mut config,
1373 &MockProvider,
1374 &remote,
1375 &new_section,
1376 false,
1377 false,
1378 true,
1379 );
1380 assert_eq!(result.updated, 1);
1381
1382 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1384 }
1385
1386 #[test]
1387 fn test_sync_no_rename_when_prefix_unchanged() {
1388 let mut config = empty_config();
1389 let section = make_section();
1390
1391 let remote = vec![ProviderHost::new(
1392 "123".to_string(),
1393 "web-1".to_string(),
1394 "1.2.3.4".to_string(),
1395 Vec::new(),
1396 )];
1397 sync_provider(
1398 &mut config,
1399 &MockProvider,
1400 &remote,
1401 §ion,
1402 false,
1403 false,
1404 false,
1405 );
1406
1407 let result = sync_provider(
1409 &mut config,
1410 &MockProvider,
1411 &remote,
1412 §ion,
1413 false,
1414 false,
1415 false,
1416 );
1417 assert_eq!(result.unchanged, 1);
1418 assert_eq!(result.updated, 0);
1419 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1420 }
1421
1422 #[test]
1423 fn test_sync_manual_comment_survives_cleanup() {
1424 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
1427 let mut config = SshConfigFile {
1428 elements: SshConfigFile::parse_content(content),
1429 path: PathBuf::from("/tmp/test_config"),
1430 crlf: false,
1431 bom: false,
1432 };
1433 let section = make_section();
1434
1435 sync_provider(
1437 &mut config,
1438 &MockProvider,
1439 &[],
1440 §ion,
1441 true,
1442 false,
1443 false,
1444 );
1445
1446 let has_manual = config
1448 .elements
1449 .iter()
1450 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
1451 assert!(
1452 has_manual,
1453 "Manual comment without purple:group prefix should survive cleanup"
1454 );
1455 }
1456
1457 #[test]
1458 fn test_sync_rename_skips_included_host() {
1459 let mut config = config_with_include_provider_host();
1460
1461 let new_section = ProviderSection {
1462 provider: "digitalocean".to_string(),
1463 token: "test".to_string(),
1464 alias_prefix: "ocean".to_string(), user: "root".to_string(),
1466 identity_file: String::new(),
1467 url: String::new(),
1468 verify_tls: true,
1469 auto_sync: true,
1470 profile: String::new(),
1471 regions: String::new(),
1472 project: String::new(),
1473 compartment: String::new(),
1474 };
1475
1476 let remote = vec![ProviderHost::new(
1478 "inc1".to_string(),
1479 "included".to_string(),
1480 "1.2.3.4".to_string(),
1481 Vec::new(),
1482 )];
1483 let result = sync_provider(
1484 &mut config,
1485 &MockProvider,
1486 &remote,
1487 &new_section,
1488 false,
1489 false,
1490 false,
1491 );
1492 assert_eq!(result.unchanged, 1);
1493 assert_eq!(result.updated, 0);
1494
1495 assert_eq!(config.host_entries()[0].alias, "do-included");
1497 }
1498
1499 #[test]
1500 fn test_sync_rename_stable_with_manual_collision() {
1501 let mut config = empty_config();
1502 let section = make_section(); let remote = vec![ProviderHost::new(
1506 "123".to_string(),
1507 "web-1".to_string(),
1508 "1.2.3.4".to_string(),
1509 Vec::new(),
1510 )];
1511 sync_provider(
1512 &mut config,
1513 &MockProvider,
1514 &remote,
1515 §ion,
1516 false,
1517 false,
1518 false,
1519 );
1520 assert_eq!(config.host_entries()[0].alias, "do-web-1");
1521
1522 let manual = HostEntry {
1524 alias: "ocean-web-1".to_string(),
1525 hostname: "5.5.5.5".to_string(),
1526 ..Default::default()
1527 };
1528 config.add_host(&manual);
1529
1530 let new_section = ProviderSection {
1532 alias_prefix: "ocean".to_string(),
1533 ..section.clone()
1534 };
1535 let result = sync_provider(
1536 &mut config,
1537 &MockProvider,
1538 &remote,
1539 &new_section,
1540 false,
1541 false,
1542 false,
1543 );
1544 assert_eq!(result.updated, 1);
1545
1546 let entries = config.host_entries();
1547 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1548 assert_eq!(provider_host.alias, "ocean-web-1-2");
1549
1550 let result = sync_provider(
1552 &mut config,
1553 &MockProvider,
1554 &remote,
1555 &new_section,
1556 false,
1557 false,
1558 false,
1559 );
1560 assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
1561
1562 let entries = config.host_entries();
1563 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
1564 assert_eq!(
1565 provider_host.alias, "ocean-web-1-2",
1566 "Alias should be stable across syncs"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_sync_preserves_user_tags() {
1572 let mut config = empty_config();
1573 let section = make_section();
1574
1575 let remote = vec![ProviderHost::new(
1577 "123".to_string(),
1578 "web-1".to_string(),
1579 "1.2.3.4".to_string(),
1580 vec!["nyc1".to_string()],
1581 )];
1582 sync_provider(
1583 &mut config,
1584 &MockProvider,
1585 &remote,
1586 §ion,
1587 false,
1588 false,
1589 false,
1590 );
1591 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1592
1593 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1595 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1596
1597 let result = sync_provider(
1599 &mut config,
1600 &MockProvider,
1601 &remote,
1602 §ion,
1603 false,
1604 false,
1605 false,
1606 );
1607 assert_eq!(result.updated, 1);
1608 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1609 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1611 }
1612
1613 #[test]
1614 fn test_sync_merges_new_provider_tag_with_user_tags() {
1615 let mut config = empty_config();
1616 let section = make_section();
1617
1618 let remote = vec![ProviderHost::new(
1620 "123".to_string(),
1621 "web-1".to_string(),
1622 "1.2.3.4".to_string(),
1623 vec!["nyc1".to_string()],
1624 )];
1625 sync_provider(
1626 &mut config,
1627 &MockProvider,
1628 &remote,
1629 §ion,
1630 false,
1631 false,
1632 false,
1633 );
1634
1635 config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
1637
1638 let remote = vec![ProviderHost::new(
1640 "123".to_string(),
1641 "web-1".to_string(),
1642 "1.2.3.4".to_string(),
1643 vec!["nyc1".to_string(), "v2".to_string()],
1644 )];
1645 let result = sync_provider(
1646 &mut config,
1647 &MockProvider,
1648 &remote,
1649 §ion,
1650 false,
1651 false,
1652 false,
1653 );
1654 assert_eq!(result.updated, 1);
1655 let ptags = &config.host_entries()[0].provider_tags;
1657 assert!(ptags.contains(&"nyc1".to_string()));
1658 assert!(ptags.contains(&"v2".to_string()));
1659 let tags = &config.host_entries()[0].tags;
1661 assert!(tags.contains(&"critical".to_string()));
1662 assert!(!tags.contains(&"nyc1".to_string()));
1663 }
1664
1665 #[test]
1666 fn test_sync_migration_cleans_overlapping_user_tags() {
1667 let mut config = empty_config();
1668 let section = make_section();
1669
1670 let remote = vec![ProviderHost::new(
1672 "123".to_string(),
1673 "web-1".to_string(),
1674 "1.2.3.4".to_string(),
1675 vec!["nyc1".to_string()],
1676 )];
1677 sync_provider(
1678 &mut config,
1679 &MockProvider,
1680 &remote,
1681 §ion,
1682 false,
1683 false,
1684 false,
1685 );
1686
1687 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1689 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1690
1691 let result = sync_provider(
1693 &mut config,
1694 &MockProvider,
1695 &remote,
1696 §ion,
1697 false,
1698 false,
1699 false,
1700 );
1701 assert_eq!(result.updated, 1);
1702 assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
1703 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1705 }
1706
1707 #[test]
1708 fn test_sync_provider_tags_cleared_remotely() {
1709 let mut config = empty_config();
1710 let section = make_section();
1711
1712 let remote = vec![ProviderHost::new(
1714 "123".to_string(),
1715 "web-1".to_string(),
1716 "1.2.3.4".to_string(),
1717 vec!["staging".to_string()],
1718 )];
1719 sync_provider(
1720 &mut config,
1721 &MockProvider,
1722 &remote,
1723 §ion,
1724 false,
1725 false,
1726 false,
1727 );
1728
1729 let remote = vec![ProviderHost::new(
1731 "123".to_string(),
1732 "web-1".to_string(),
1733 "1.2.3.4".to_string(),
1734 Vec::new(),
1735 )];
1736 let result = sync_provider(
1737 &mut config,
1738 &MockProvider,
1739 &remote,
1740 §ion,
1741 false,
1742 false,
1743 false,
1744 );
1745 assert_eq!(result.updated, 1);
1746 assert!(config.host_entries()[0].tags.is_empty());
1747 }
1748
1749 #[test]
1750 fn test_sync_provider_tags_cleared_user_tags_survive() {
1751 let mut config = empty_config();
1752 let section = make_section();
1753
1754 let remote = vec![ProviderHost::new(
1756 "123".to_string(),
1757 "web-1".to_string(),
1758 "1.2.3.4".to_string(),
1759 vec!["staging".to_string()],
1760 )];
1761 sync_provider(
1762 &mut config,
1763 &MockProvider,
1764 &remote,
1765 §ion,
1766 false,
1767 false,
1768 false,
1769 );
1770
1771 config.set_host_tags("do-web-1", &["my-custom".to_string()]);
1773
1774 let remote = vec![ProviderHost::new(
1776 "123".to_string(),
1777 "web-1".to_string(),
1778 "1.2.3.4".to_string(),
1779 Vec::new(),
1780 )];
1781 let result = sync_provider(
1782 &mut config,
1783 &MockProvider,
1784 &remote,
1785 §ion,
1786 false,
1787 false,
1788 false,
1789 );
1790 assert_eq!(result.updated, 1);
1791 assert!(config.host_entries()[0].provider_tags.is_empty());
1792 assert_eq!(config.host_entries()[0].tags, vec!["my-custom"]);
1794 }
1795
1796 #[test]
1797 fn test_sync_provider_tags_exact_match_unchanged() {
1798 let mut config = empty_config();
1799 let section = make_section();
1800
1801 let remote = vec![ProviderHost::new(
1803 "123".to_string(),
1804 "web-1".to_string(),
1805 "1.2.3.4".to_string(),
1806 vec!["prod".to_string(), "nyc1".to_string()],
1807 )];
1808 sync_provider(
1809 &mut config,
1810 &MockProvider,
1811 &remote,
1812 §ion,
1813 false,
1814 false,
1815 false,
1816 );
1817
1818 let remote = vec![ProviderHost::new(
1820 "123".to_string(),
1821 "web-1".to_string(),
1822 "1.2.3.4".to_string(),
1823 vec!["nyc1".to_string(), "prod".to_string()],
1824 )];
1825 let result = sync_provider(
1826 &mut config,
1827 &MockProvider,
1828 &remote,
1829 §ion,
1830 false,
1831 false,
1832 false,
1833 );
1834 assert_eq!(result.unchanged, 1);
1835 }
1836
1837 #[test]
1838 fn test_sync_merge_case_insensitive() {
1839 let mut config = empty_config();
1840 let section = make_section();
1841
1842 let remote = vec![ProviderHost::new(
1844 "123".to_string(),
1845 "web-1".to_string(),
1846 "1.2.3.4".to_string(),
1847 vec!["prod".to_string()],
1848 )];
1849 sync_provider(
1850 &mut config,
1851 &MockProvider,
1852 &remote,
1853 §ion,
1854 false,
1855 false,
1856 false,
1857 );
1858 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1859
1860 let remote = vec![ProviderHost::new(
1862 "123".to_string(),
1863 "web-1".to_string(),
1864 "1.2.3.4".to_string(),
1865 vec!["Prod".to_string()],
1866 )];
1867 let result = sync_provider(
1868 &mut config,
1869 &MockProvider,
1870 &remote,
1871 §ion,
1872 false,
1873 false,
1874 false,
1875 );
1876 assert_eq!(result.unchanged, 1);
1877 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
1878 }
1879
1880 #[test]
1881 fn test_sync_provider_tags_case_insensitive_unchanged() {
1882 let mut config = empty_config();
1883 let section = make_section();
1884
1885 let remote = vec![ProviderHost::new(
1887 "123".to_string(),
1888 "web-1".to_string(),
1889 "1.2.3.4".to_string(),
1890 vec!["prod".to_string()],
1891 )];
1892 sync_provider(
1893 &mut config,
1894 &MockProvider,
1895 &remote,
1896 §ion,
1897 false,
1898 false,
1899 false,
1900 );
1901
1902 let remote = vec![ProviderHost::new(
1904 "123".to_string(),
1905 "web-1".to_string(),
1906 "1.2.3.4".to_string(),
1907 vec!["Prod".to_string()],
1908 )];
1909 let result = sync_provider(
1910 &mut config,
1911 &MockProvider,
1912 &remote,
1913 §ion,
1914 false,
1915 false,
1916 false,
1917 );
1918 assert_eq!(result.unchanged, 1);
1919 }
1920
1921 #[test]
1924 fn test_sync_empty_ip_not_added() {
1925 let mut config = empty_config();
1926 let section = make_section();
1927 let remote = vec![ProviderHost::new(
1928 "100".to_string(),
1929 "stopped-vm".to_string(),
1930 String::new(),
1931 Vec::new(),
1932 )];
1933 let result = sync_provider(
1934 &mut config,
1935 &MockProvider,
1936 &remote,
1937 §ion,
1938 false,
1939 false,
1940 false,
1941 );
1942 assert_eq!(result.added, 0);
1943 assert_eq!(config.host_entries().len(), 0);
1944 }
1945
1946 #[test]
1947 fn test_sync_empty_ip_existing_host_unchanged() {
1948 let mut config = empty_config();
1949 let section = make_section();
1950
1951 let remote = vec![ProviderHost::new(
1953 "100".to_string(),
1954 "web".to_string(),
1955 "1.2.3.4".to_string(),
1956 Vec::new(),
1957 )];
1958 sync_provider(
1959 &mut config,
1960 &MockProvider,
1961 &remote,
1962 §ion,
1963 false,
1964 false,
1965 false,
1966 );
1967 assert_eq!(config.host_entries().len(), 1);
1968 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1969
1970 let remote = vec![ProviderHost::new(
1972 "100".to_string(),
1973 "web".to_string(),
1974 String::new(),
1975 Vec::new(),
1976 )];
1977 let result = sync_provider(
1978 &mut config,
1979 &MockProvider,
1980 &remote,
1981 §ion,
1982 false,
1983 false,
1984 false,
1985 );
1986 assert_eq!(result.unchanged, 1);
1987 assert_eq!(result.updated, 0);
1988 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1989 }
1990
1991 #[test]
1992 fn test_sync_remove_skips_empty_ip_hosts() {
1993 let mut config = empty_config();
1994 let section = make_section();
1995
1996 let remote = vec![
1998 ProviderHost::new(
1999 "100".to_string(),
2000 "web".to_string(),
2001 "1.2.3.4".to_string(),
2002 Vec::new(),
2003 ),
2004 ProviderHost::new(
2005 "200".to_string(),
2006 "db".to_string(),
2007 "5.6.7.8".to_string(),
2008 Vec::new(),
2009 ),
2010 ];
2011 sync_provider(
2012 &mut config,
2013 &MockProvider,
2014 &remote,
2015 §ion,
2016 false,
2017 false,
2018 false,
2019 );
2020 assert_eq!(config.host_entries().len(), 2);
2021
2022 let remote = vec![
2025 ProviderHost::new(
2026 "100".to_string(),
2027 "web".to_string(),
2028 "1.2.3.4".to_string(),
2029 Vec::new(),
2030 ),
2031 ProviderHost::new(
2032 "200".to_string(),
2033 "db".to_string(),
2034 String::new(),
2035 Vec::new(),
2036 ),
2037 ];
2038 let result = sync_provider(
2039 &mut config,
2040 &MockProvider,
2041 &remote,
2042 §ion,
2043 true,
2044 false,
2045 false,
2046 );
2047 assert_eq!(result.removed, 0);
2048 assert_eq!(result.unchanged, 2);
2049 assert_eq!(config.host_entries().len(), 2);
2050 }
2051
2052 #[test]
2053 fn test_sync_remove_deletes_truly_gone_hosts() {
2054 let mut config = empty_config();
2055 let section = make_section();
2056
2057 let remote = vec![
2059 ProviderHost::new(
2060 "100".to_string(),
2061 "web".to_string(),
2062 "1.2.3.4".to_string(),
2063 Vec::new(),
2064 ),
2065 ProviderHost::new(
2066 "200".to_string(),
2067 "db".to_string(),
2068 "5.6.7.8".to_string(),
2069 Vec::new(),
2070 ),
2071 ];
2072 sync_provider(
2073 &mut config,
2074 &MockProvider,
2075 &remote,
2076 §ion,
2077 false,
2078 false,
2079 false,
2080 );
2081 assert_eq!(config.host_entries().len(), 2);
2082
2083 let remote = vec![ProviderHost::new(
2085 "100".to_string(),
2086 "web".to_string(),
2087 "1.2.3.4".to_string(),
2088 Vec::new(),
2089 )];
2090 let result = sync_provider(
2091 &mut config,
2092 &MockProvider,
2093 &remote,
2094 §ion,
2095 true,
2096 false,
2097 false,
2098 );
2099 assert_eq!(result.removed, 1);
2100 assert_eq!(config.host_entries().len(), 1);
2101 assert_eq!(config.host_entries()[0].alias, "do-web");
2102 }
2103
2104 #[test]
2105 fn test_sync_mixed_resolved_empty_and_missing() {
2106 let mut config = empty_config();
2107 let section = make_section();
2108
2109 let remote = vec![
2111 ProviderHost::new(
2112 "1".to_string(),
2113 "running".to_string(),
2114 "1.1.1.1".to_string(),
2115 Vec::new(),
2116 ),
2117 ProviderHost::new(
2118 "2".to_string(),
2119 "stopped".to_string(),
2120 "2.2.2.2".to_string(),
2121 Vec::new(),
2122 ),
2123 ProviderHost::new(
2124 "3".to_string(),
2125 "deleted".to_string(),
2126 "3.3.3.3".to_string(),
2127 Vec::new(),
2128 ),
2129 ];
2130 sync_provider(
2131 &mut config,
2132 &MockProvider,
2133 &remote,
2134 §ion,
2135 false,
2136 false,
2137 false,
2138 );
2139 assert_eq!(config.host_entries().len(), 3);
2140
2141 let remote = vec![
2146 ProviderHost::new(
2147 "1".to_string(),
2148 "running".to_string(),
2149 "9.9.9.9".to_string(),
2150 Vec::new(),
2151 ),
2152 ProviderHost::new(
2153 "2".to_string(),
2154 "stopped".to_string(),
2155 String::new(),
2156 Vec::new(),
2157 ),
2158 ];
2159 let result = sync_provider(
2160 &mut config,
2161 &MockProvider,
2162 &remote,
2163 §ion,
2164 true,
2165 false,
2166 false,
2167 );
2168 assert_eq!(result.updated, 1);
2169 assert_eq!(result.unchanged, 1);
2170 assert_eq!(result.removed, 1);
2171
2172 let entries = config.host_entries();
2173 assert_eq!(entries.len(), 2);
2174 let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
2176 assert_eq!(running.hostname, "9.9.9.9");
2177 let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
2179 assert_eq!(stopped.hostname, "2.2.2.2");
2180 }
2181
2182 #[test]
2187 fn test_sanitize_name_unicode() {
2188 assert_eq!(sanitize_name("서버-1"), "1");
2190 }
2191
2192 #[test]
2193 fn test_sanitize_name_numbers_only() {
2194 assert_eq!(sanitize_name("12345"), "12345");
2195 }
2196
2197 #[test]
2198 fn test_sanitize_name_mixed_special_chars() {
2199 assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
2200 }
2201
2202 #[test]
2203 fn test_sanitize_name_tabs_and_newlines() {
2204 assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
2205 }
2206
2207 #[test]
2208 fn test_sanitize_name_consecutive_specials() {
2209 assert_eq!(sanitize_name("a!!!b"), "a-b");
2210 }
2211
2212 #[test]
2213 fn test_sanitize_name_trailing_special() {
2214 assert_eq!(sanitize_name("web-"), "web");
2215 }
2216
2217 #[test]
2218 fn test_sanitize_name_leading_special() {
2219 assert_eq!(sanitize_name("-web"), "web");
2220 }
2221
2222 #[test]
2227 fn test_build_alias_prefix_with_hyphen() {
2228 assert_eq!(build_alias("do-", "web-1"), "do--web-1");
2231 }
2232
2233 #[test]
2234 fn test_build_alias_long_names() {
2235 assert_eq!(
2236 build_alias("my-provider", "my-very-long-server-name"),
2237 "my-provider-my-very-long-server-name"
2238 );
2239 }
2240
2241 #[test]
2246 fn test_sync_applies_user_from_section() {
2247 let mut config = empty_config();
2248 let mut section = make_section();
2249 section.user = "admin".to_string();
2250 let remote = vec![ProviderHost::new(
2251 "1".to_string(),
2252 "web".to_string(),
2253 "1.2.3.4".to_string(),
2254 Vec::new(),
2255 )];
2256 sync_provider(
2257 &mut config,
2258 &MockProvider,
2259 &remote,
2260 §ion,
2261 false,
2262 false,
2263 false,
2264 );
2265 let entries = config.host_entries();
2266 assert_eq!(entries[0].user, "admin");
2267 }
2268
2269 #[test]
2270 fn test_sync_applies_identity_file_from_section() {
2271 let mut config = empty_config();
2272 let mut section = make_section();
2273 section.identity_file = "~/.ssh/id_rsa".to_string();
2274 let remote = vec![ProviderHost::new(
2275 "1".to_string(),
2276 "web".to_string(),
2277 "1.2.3.4".to_string(),
2278 Vec::new(),
2279 )];
2280 sync_provider(
2281 &mut config,
2282 &MockProvider,
2283 &remote,
2284 §ion,
2285 false,
2286 false,
2287 false,
2288 );
2289 let entries = config.host_entries();
2290 assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
2291 }
2292
2293 #[test]
2294 fn test_sync_empty_user_not_set() {
2295 let mut config = empty_config();
2296 let mut section = make_section();
2297 section.user = String::new(); let remote = vec![ProviderHost::new(
2299 "1".to_string(),
2300 "web".to_string(),
2301 "1.2.3.4".to_string(),
2302 Vec::new(),
2303 )];
2304 sync_provider(
2305 &mut config,
2306 &MockProvider,
2307 &remote,
2308 §ion,
2309 false,
2310 false,
2311 false,
2312 );
2313 let entries = config.host_entries();
2314 assert!(entries[0].user.is_empty());
2315 }
2316
2317 #[test]
2322 fn test_sync_result_default() {
2323 let result = SyncResult::default();
2324 assert_eq!(result.added, 0);
2325 assert_eq!(result.updated, 0);
2326 assert_eq!(result.removed, 0);
2327 assert_eq!(result.unchanged, 0);
2328 assert!(result.renames.is_empty());
2329 }
2330
2331 #[test]
2336 fn test_sync_server_name_change_updates_alias() {
2337 let mut config = empty_config();
2338 let section = make_section();
2339 let remote = vec![ProviderHost::new(
2341 "1".to_string(),
2342 "old-name".to_string(),
2343 "1.2.3.4".to_string(),
2344 Vec::new(),
2345 )];
2346 sync_provider(
2347 &mut config,
2348 &MockProvider,
2349 &remote,
2350 §ion,
2351 false,
2352 false,
2353 false,
2354 );
2355 assert_eq!(config.host_entries()[0].alias, "do-old-name");
2356
2357 let remote_renamed = vec![ProviderHost::new(
2359 "1".to_string(),
2360 "new-name".to_string(),
2361 "1.2.3.4".to_string(),
2362 Vec::new(),
2363 )];
2364 let result = sync_provider(
2365 &mut config,
2366 &MockProvider,
2367 &remote_renamed,
2368 §ion,
2369 false,
2370 false,
2371 false,
2372 );
2373 assert!(!result.renames.is_empty() || result.updated > 0);
2375 }
2376
2377 #[test]
2378 fn test_sync_idempotent_same_data() {
2379 let mut config = empty_config();
2380 let section = make_section();
2381 let remote = vec![ProviderHost::new(
2382 "1".to_string(),
2383 "web".to_string(),
2384 "1.2.3.4".to_string(),
2385 vec!["prod".to_string()],
2386 )];
2387 sync_provider(
2388 &mut config,
2389 &MockProvider,
2390 &remote,
2391 §ion,
2392 false,
2393 false,
2394 false,
2395 );
2396 let result = sync_provider(
2397 &mut config,
2398 &MockProvider,
2399 &remote,
2400 §ion,
2401 false,
2402 false,
2403 false,
2404 );
2405 assert_eq!(result.added, 0);
2406 assert_eq!(result.updated, 0);
2407 assert_eq!(result.unchanged, 1);
2408 }
2409
2410 #[test]
2415 fn test_sync_tag_merge_case_insensitive_no_duplicate() {
2416 let mut config = empty_config();
2417 let section = make_section();
2418 let remote = vec![ProviderHost::new(
2420 "1".to_string(),
2421 "web".to_string(),
2422 "1.2.3.4".to_string(),
2423 vec!["Prod".to_string()],
2424 )];
2425 sync_provider(
2426 &mut config,
2427 &MockProvider,
2428 &remote,
2429 §ion,
2430 false,
2431 false,
2432 false,
2433 );
2434
2435 let remote2 = vec![ProviderHost::new(
2437 "1".to_string(),
2438 "web".to_string(),
2439 "1.2.3.4".to_string(),
2440 vec!["prod".to_string()],
2441 )];
2442 let result = sync_provider(
2443 &mut config,
2444 &MockProvider,
2445 &remote2,
2446 §ion,
2447 false,
2448 false,
2449 false,
2450 );
2451 assert_eq!(result.unchanged, 1);
2452 assert_eq!(result.updated, 0);
2453 }
2454
2455 #[test]
2456 fn test_sync_tag_merge_adds_new_remote_tag() {
2457 let mut config = empty_config();
2458 let section = make_section();
2459 let remote = vec![ProviderHost::new(
2460 "1".to_string(),
2461 "web".to_string(),
2462 "1.2.3.4".to_string(),
2463 vec!["prod".to_string()],
2464 )];
2465 sync_provider(
2466 &mut config,
2467 &MockProvider,
2468 &remote,
2469 §ion,
2470 false,
2471 false,
2472 false,
2473 );
2474
2475 let remote2 = vec![ProviderHost::new(
2477 "1".to_string(),
2478 "web".to_string(),
2479 "1.2.3.4".to_string(),
2480 vec!["prod".to_string(), "us-east".to_string()],
2481 )];
2482 let result = sync_provider(
2483 &mut config,
2484 &MockProvider,
2485 &remote2,
2486 §ion,
2487 false,
2488 false,
2489 false,
2490 );
2491 assert_eq!(result.updated, 1);
2492
2493 let entries = config.host_entries();
2495 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2496 assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2497 assert!(entry.provider_tags.iter().any(|t| t == "us-east"));
2498 }
2499
2500 #[test]
2501 fn test_sync_tag_merge_preserves_local_tags() {
2502 let mut config = empty_config();
2503 let section = make_section();
2504 let remote = vec![ProviderHost::new(
2505 "1".to_string(),
2506 "web".to_string(),
2507 "1.2.3.4".to_string(),
2508 vec!["prod".to_string()],
2509 )];
2510 sync_provider(
2511 &mut config,
2512 &MockProvider,
2513 &remote,
2514 §ion,
2515 false,
2516 false,
2517 false,
2518 );
2519
2520 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2522
2523 let result = sync_provider(
2525 &mut config,
2526 &MockProvider,
2527 &remote,
2528 §ion,
2529 false,
2530 false,
2531 false,
2532 );
2533 assert_eq!(result.updated, 1);
2534 let entries = config.host_entries();
2535 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2536 assert!(entry.tags.iter().any(|t| t == "my-custom"));
2537 assert!(!entry.tags.iter().any(|t| t == "prod")); }
2539
2540 #[test]
2541 fn test_sync_provider_tags_replaces_with_migration() {
2542 let mut config = empty_config();
2543 let section = make_section();
2544 let remote = vec![ProviderHost::new(
2545 "1".to_string(),
2546 "web".to_string(),
2547 "1.2.3.4".to_string(),
2548 vec!["prod".to_string()],
2549 )];
2550 sync_provider(
2551 &mut config,
2552 &MockProvider,
2553 &remote,
2554 §ion,
2555 false,
2556 false,
2557 false,
2558 );
2559
2560 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
2562
2563 let remote2 = vec![ProviderHost::new(
2565 "1".to_string(),
2566 "web".to_string(),
2567 "1.2.3.4".to_string(),
2568 vec!["prod".to_string(), "new-tag".to_string()],
2569 )];
2570 let result = sync_provider(
2571 &mut config,
2572 &MockProvider,
2573 &remote2,
2574 §ion,
2575 false,
2576 false,
2577 false,
2578 );
2579 assert_eq!(result.updated, 1);
2580
2581 let entries = config.host_entries();
2582 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
2583 assert!(entry.provider_tags.iter().any(|t| t == "prod"));
2585 assert!(entry.provider_tags.iter().any(|t| t == "new-tag"));
2586 assert!(!entry.tags.iter().any(|t| t == "prod"));
2588 assert!(entry.tags.iter().any(|t| t == "my-custom"));
2589 }
2590
2591 #[test]
2596 fn test_sync_rename_and_ip_change_simultaneously() {
2597 let mut config = empty_config();
2598 let section = make_section();
2599 let remote = vec![ProviderHost::new(
2600 "1".to_string(),
2601 "old-name".to_string(),
2602 "1.2.3.4".to_string(),
2603 Vec::new(),
2604 )];
2605 sync_provider(
2606 &mut config,
2607 &MockProvider,
2608 &remote,
2609 §ion,
2610 false,
2611 false,
2612 false,
2613 );
2614
2615 let remote2 = vec![ProviderHost::new(
2617 "1".to_string(),
2618 "new-name".to_string(),
2619 "9.8.7.6".to_string(),
2620 Vec::new(),
2621 )];
2622 let result = sync_provider(
2623 &mut config,
2624 &MockProvider,
2625 &remote2,
2626 §ion,
2627 false,
2628 false,
2629 false,
2630 );
2631 assert_eq!(result.updated, 1);
2632 assert_eq!(result.renames.len(), 1);
2633 assert_eq!(result.renames[0].0, "do-old-name");
2634 assert_eq!(result.renames[0].1, "do-new-name");
2635
2636 let entries = config.host_entries();
2637 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
2638 assert_eq!(entry.hostname, "9.8.7.6");
2639 }
2640
2641 #[test]
2646 fn test_sync_duplicate_server_id_deduped() {
2647 let mut config = empty_config();
2648 let section = make_section();
2649 let remote = vec![
2650 ProviderHost::new(
2651 "1".to_string(),
2652 "web".to_string(),
2653 "1.2.3.4".to_string(),
2654 Vec::new(),
2655 ),
2656 ProviderHost::new(
2657 "1".to_string(),
2658 "web-copy".to_string(),
2659 "5.6.7.8".to_string(),
2660 Vec::new(),
2661 ), ];
2663 let result = sync_provider(
2664 &mut config,
2665 &MockProvider,
2666 &remote,
2667 §ion,
2668 false,
2669 false,
2670 false,
2671 );
2672 assert_eq!(result.added, 1); assert_eq!(config.host_entries().len(), 1);
2674 }
2675
2676 #[test]
2681 fn test_sync_remove_all_when_remote_empty() {
2682 let mut config = empty_config();
2683 let section = make_section();
2684 let remote = vec![
2685 ProviderHost::new(
2686 "1".to_string(),
2687 "web".to_string(),
2688 "1.2.3.4".to_string(),
2689 Vec::new(),
2690 ),
2691 ProviderHost::new(
2692 "2".to_string(),
2693 "db".to_string(),
2694 "5.6.7.8".to_string(),
2695 Vec::new(),
2696 ),
2697 ];
2698 sync_provider(
2699 &mut config,
2700 &MockProvider,
2701 &remote,
2702 §ion,
2703 false,
2704 false,
2705 false,
2706 );
2707 assert_eq!(config.host_entries().len(), 2);
2708
2709 let result = sync_provider(
2711 &mut config,
2712 &MockProvider,
2713 &[],
2714 §ion,
2715 true,
2716 false,
2717 false,
2718 );
2719 assert_eq!(result.removed, 2);
2720 assert_eq!(config.host_entries().len(), 0);
2721 }
2722
2723 #[test]
2728 fn test_sync_adds_group_header_on_first_host() {
2729 let mut config = empty_config();
2730 let section = make_section();
2731 let remote = vec![ProviderHost::new(
2732 "1".to_string(),
2733 "web".to_string(),
2734 "1.2.3.4".to_string(),
2735 Vec::new(),
2736 )];
2737 sync_provider(
2738 &mut config,
2739 &MockProvider,
2740 &remote,
2741 §ion,
2742 false,
2743 false,
2744 false,
2745 );
2746
2747 let has_header = config.elements.iter().any(|e| {
2749 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2750 });
2751 assert!(has_header);
2752 }
2753
2754 #[test]
2755 fn test_sync_removes_header_when_all_hosts_deleted() {
2756 let mut config = empty_config();
2757 let section = make_section();
2758 let remote = vec![ProviderHost::new(
2759 "1".to_string(),
2760 "web".to_string(),
2761 "1.2.3.4".to_string(),
2762 Vec::new(),
2763 )];
2764 sync_provider(
2765 &mut config,
2766 &MockProvider,
2767 &remote,
2768 §ion,
2769 false,
2770 false,
2771 false,
2772 );
2773
2774 let result = sync_provider(
2776 &mut config,
2777 &MockProvider,
2778 &[],
2779 §ion,
2780 true,
2781 false,
2782 false,
2783 );
2784 assert_eq!(result.removed, 1);
2785
2786 let has_header = config.elements.iter().any(|e| {
2788 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
2789 });
2790 assert!(!has_header);
2791 }
2792
2793 #[test]
2798 fn test_sync_identity_file_set_on_new_host() {
2799 let mut config = empty_config();
2800 let mut section = make_section();
2801 section.identity_file = "~/.ssh/do_key".to_string();
2802 let remote = vec![ProviderHost::new(
2803 "1".to_string(),
2804 "web".to_string(),
2805 "1.2.3.4".to_string(),
2806 Vec::new(),
2807 )];
2808 sync_provider(
2809 &mut config,
2810 &MockProvider,
2811 &remote,
2812 §ion,
2813 false,
2814 false,
2815 false,
2816 );
2817 let entries = config.host_entries();
2818 assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
2819 }
2820
2821 #[test]
2826 fn test_sync_alias_collision_dedup() {
2827 let mut config = empty_config();
2828 let section = make_section();
2829 let remote = vec![
2831 ProviderHost::new(
2832 "1".to_string(),
2833 "web".to_string(),
2834 "1.2.3.4".to_string(),
2835 Vec::new(),
2836 ),
2837 ProviderHost::new(
2838 "2".to_string(),
2839 "web".to_string(),
2840 "5.6.7.8".to_string(),
2841 Vec::new(),
2842 ), ];
2844 let result = sync_provider(
2845 &mut config,
2846 &MockProvider,
2847 &remote,
2848 §ion,
2849 false,
2850 false,
2851 false,
2852 );
2853 assert_eq!(result.added, 2);
2854
2855 let entries = config.host_entries();
2856 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
2857 assert!(aliases.contains(&"do-web"));
2858 assert!(aliases.contains(&"do-web-2")); }
2860
2861 #[test]
2866 fn test_sync_empty_alias_prefix() {
2867 let mut config = empty_config();
2868 let mut section = make_section();
2869 section.alias_prefix = String::new();
2870 let remote = vec![ProviderHost::new(
2871 "1".to_string(),
2872 "web-1".to_string(),
2873 "1.2.3.4".to_string(),
2874 Vec::new(),
2875 )];
2876 sync_provider(
2877 &mut config,
2878 &MockProvider,
2879 &remote,
2880 §ion,
2881 false,
2882 false,
2883 false,
2884 );
2885 let entries = config.host_entries();
2886 assert_eq!(entries[0].alias, "web-1"); }
2888
2889 #[test]
2894 fn test_sync_dry_run_add_count() {
2895 let mut config = empty_config();
2896 let section = make_section();
2897 let remote = vec![
2898 ProviderHost::new(
2899 "1".to_string(),
2900 "web".to_string(),
2901 "1.2.3.4".to_string(),
2902 Vec::new(),
2903 ),
2904 ProviderHost::new(
2905 "2".to_string(),
2906 "db".to_string(),
2907 "5.6.7.8".to_string(),
2908 Vec::new(),
2909 ),
2910 ];
2911 let result = sync_provider(
2912 &mut config,
2913 &MockProvider,
2914 &remote,
2915 §ion,
2916 false,
2917 false,
2918 true,
2919 );
2920 assert_eq!(result.added, 2);
2921 assert_eq!(config.host_entries().len(), 0);
2923 }
2924
2925 #[test]
2926 fn test_sync_dry_run_remove_count_preserves_config() {
2927 let mut config = empty_config();
2928 let section = make_section();
2929 let remote = vec![ProviderHost::new(
2930 "1".to_string(),
2931 "web".to_string(),
2932 "1.2.3.4".to_string(),
2933 Vec::new(),
2934 )];
2935 sync_provider(
2936 &mut config,
2937 &MockProvider,
2938 &remote,
2939 §ion,
2940 false,
2941 false,
2942 false,
2943 );
2944 assert_eq!(config.host_entries().len(), 1);
2945
2946 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
2948 assert_eq!(result.removed, 1);
2949 assert_eq!(config.host_entries().len(), 1);
2951 }
2952
2953 #[test]
2958 fn test_sync_result_counts_add_up() {
2959 let mut config = empty_config();
2960 let section = make_section();
2961 let remote = vec![
2963 ProviderHost::new(
2964 "1".to_string(),
2965 "a".to_string(),
2966 "1.1.1.1".to_string(),
2967 Vec::new(),
2968 ),
2969 ProviderHost::new(
2970 "2".to_string(),
2971 "b".to_string(),
2972 "2.2.2.2".to_string(),
2973 Vec::new(),
2974 ),
2975 ProviderHost::new(
2976 "3".to_string(),
2977 "c".to_string(),
2978 "3.3.3.3".to_string(),
2979 Vec::new(),
2980 ),
2981 ];
2982 sync_provider(
2983 &mut config,
2984 &MockProvider,
2985 &remote,
2986 §ion,
2987 false,
2988 false,
2989 false,
2990 );
2991
2992 let remote2 = vec![
2994 ProviderHost::new(
2995 "1".to_string(),
2996 "a".to_string(),
2997 "1.1.1.1".to_string(),
2998 Vec::new(),
2999 ), ProviderHost::new(
3001 "2".to_string(),
3002 "b".to_string(),
3003 "9.9.9.9".to_string(),
3004 Vec::new(),
3005 ), ];
3008 let result = sync_provider(
3009 &mut config,
3010 &MockProvider,
3011 &remote2,
3012 §ion,
3013 true,
3014 false,
3015 false,
3016 );
3017 assert_eq!(result.unchanged, 1);
3018 assert_eq!(result.updated, 1);
3019 assert_eq!(result.removed, 1);
3020 assert_eq!(result.added, 0);
3021 }
3022
3023 #[test]
3028 fn test_sync_multiple_renames() {
3029 let mut config = empty_config();
3030 let section = make_section();
3031 let remote = vec![
3032 ProviderHost::new(
3033 "1".to_string(),
3034 "old-a".to_string(),
3035 "1.1.1.1".to_string(),
3036 Vec::new(),
3037 ),
3038 ProviderHost::new(
3039 "2".to_string(),
3040 "old-b".to_string(),
3041 "2.2.2.2".to_string(),
3042 Vec::new(),
3043 ),
3044 ];
3045 sync_provider(
3046 &mut config,
3047 &MockProvider,
3048 &remote,
3049 §ion,
3050 false,
3051 false,
3052 false,
3053 );
3054
3055 let remote2 = vec![
3056 ProviderHost::new(
3057 "1".to_string(),
3058 "new-a".to_string(),
3059 "1.1.1.1".to_string(),
3060 Vec::new(),
3061 ),
3062 ProviderHost::new(
3063 "2".to_string(),
3064 "new-b".to_string(),
3065 "2.2.2.2".to_string(),
3066 Vec::new(),
3067 ),
3068 ];
3069 let result = sync_provider(
3070 &mut config,
3071 &MockProvider,
3072 &remote2,
3073 §ion,
3074 false,
3075 false,
3076 false,
3077 );
3078 assert_eq!(result.renames.len(), 2);
3079 assert_eq!(result.updated, 2);
3080 }
3081
3082 #[test]
3087 fn test_sync_tag_whitespace_trimmed_on_store() {
3088 let mut config = empty_config();
3089 let section = make_section();
3090 let remote = vec![ProviderHost::new(
3092 "1".to_string(),
3093 "web".to_string(),
3094 "1.2.3.4".to_string(),
3095 vec![" production ".to_string(), " us-east ".to_string()],
3096 )];
3097 sync_provider(
3098 &mut config,
3099 &MockProvider,
3100 &remote,
3101 §ion,
3102 false,
3103 false,
3104 false,
3105 );
3106 let entries = config.host_entries();
3107 assert_eq!(entries[0].provider_tags, vec!["production", "us-east"]);
3109 }
3110
3111 #[test]
3112 fn test_sync_tag_trimmed_remote_triggers_merge() {
3113 let mut config = empty_config();
3114 let section = make_section();
3115 let remote = vec![ProviderHost::new(
3117 "1".to_string(),
3118 "web".to_string(),
3119 "1.2.3.4".to_string(),
3120 vec!["production".to_string()],
3121 )];
3122 sync_provider(
3123 &mut config,
3124 &MockProvider,
3125 &remote,
3126 §ion,
3127 false,
3128 false,
3129 false,
3130 );
3131
3132 let remote2 = vec![ProviderHost::new(
3134 "1".to_string(),
3135 "web".to_string(),
3136 "1.2.3.4".to_string(),
3137 vec![" production ".to_string()],
3138 )]; let result = sync_provider(
3140 &mut config,
3141 &MockProvider,
3142 &remote2,
3143 §ion,
3144 false,
3145 false,
3146 false,
3147 );
3148 assert_eq!(result.unchanged, 1);
3150 }
3151
3152 struct MockProvider2;
3157 impl Provider for MockProvider2 {
3158 fn name(&self) -> &str {
3159 "vultr"
3160 }
3161 fn short_label(&self) -> &str {
3162 "vultr"
3163 }
3164 fn fetch_hosts_cancellable(
3165 &self,
3166 _token: &str,
3167 _cancel: &std::sync::atomic::AtomicBool,
3168 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
3169 Ok(Vec::new())
3170 }
3171 }
3172
3173 #[test]
3174 fn test_sync_two_providers_independent() {
3175 let mut config = empty_config();
3176
3177 let do_section = make_section(); let vultr_section = ProviderSection {
3179 provider: "vultr".to_string(),
3180 token: "test".to_string(),
3181 alias_prefix: "vultr".to_string(),
3182 user: String::new(),
3183 identity_file: String::new(),
3184 url: String::new(),
3185 verify_tls: true,
3186 auto_sync: true,
3187 profile: String::new(),
3188 regions: String::new(),
3189 project: String::new(),
3190 compartment: String::new(),
3191 };
3192
3193 let do_remote = vec![ProviderHost::new(
3195 "1".to_string(),
3196 "web".to_string(),
3197 "1.2.3.4".to_string(),
3198 Vec::new(),
3199 )];
3200 sync_provider(
3201 &mut config,
3202 &MockProvider,
3203 &do_remote,
3204 &do_section,
3205 false,
3206 false,
3207 false,
3208 );
3209
3210 let vultr_remote = vec![ProviderHost::new(
3212 "abc".to_string(),
3213 "web".to_string(),
3214 "5.6.7.8".to_string(),
3215 Vec::new(),
3216 )];
3217 sync_provider(
3218 &mut config,
3219 &MockProvider2,
3220 &vultr_remote,
3221 &vultr_section,
3222 false,
3223 false,
3224 false,
3225 );
3226
3227 let entries = config.host_entries();
3228 assert_eq!(entries.len(), 2);
3229 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
3230 assert!(aliases.contains(&"do-web"));
3231 assert!(aliases.contains(&"vultr-web"));
3232 }
3233
3234 #[test]
3235 fn test_sync_remove_only_affects_own_provider() {
3236 let mut config = empty_config();
3237 let do_section = make_section();
3238 let vultr_section = ProviderSection {
3239 provider: "vultr".to_string(),
3240 token: "test".to_string(),
3241 alias_prefix: "vultr".to_string(),
3242 user: String::new(),
3243 identity_file: String::new(),
3244 url: String::new(),
3245 verify_tls: true,
3246 auto_sync: true,
3247 profile: String::new(),
3248 regions: String::new(),
3249 project: String::new(),
3250 compartment: String::new(),
3251 };
3252
3253 let do_remote = vec![ProviderHost::new(
3255 "1".to_string(),
3256 "web".to_string(),
3257 "1.2.3.4".to_string(),
3258 Vec::new(),
3259 )];
3260 sync_provider(
3261 &mut config,
3262 &MockProvider,
3263 &do_remote,
3264 &do_section,
3265 false,
3266 false,
3267 false,
3268 );
3269
3270 let vultr_remote = vec![ProviderHost::new(
3271 "abc".to_string(),
3272 "db".to_string(),
3273 "5.6.7.8".to_string(),
3274 Vec::new(),
3275 )];
3276 sync_provider(
3277 &mut config,
3278 &MockProvider2,
3279 &vultr_remote,
3280 &vultr_section,
3281 false,
3282 false,
3283 false,
3284 );
3285 assert_eq!(config.host_entries().len(), 2);
3286
3287 let result = sync_provider(
3289 &mut config,
3290 &MockProvider,
3291 &[],
3292 &do_section,
3293 true,
3294 false,
3295 false,
3296 );
3297 assert_eq!(result.removed, 1);
3298 let entries = config.host_entries();
3299 assert_eq!(entries.len(), 1);
3300 assert_eq!(entries[0].alias, "vultr-db");
3301 }
3302
3303 #[test]
3308 fn test_sync_rename_and_tag_change_simultaneously() {
3309 let mut config = empty_config();
3310 let section = make_section();
3311 let remote = vec![ProviderHost::new(
3312 "1".to_string(),
3313 "old-name".to_string(),
3314 "1.2.3.4".to_string(),
3315 vec!["staging".to_string()],
3316 )];
3317 sync_provider(
3318 &mut config,
3319 &MockProvider,
3320 &remote,
3321 §ion,
3322 false,
3323 false,
3324 false,
3325 );
3326 assert_eq!(config.host_entries()[0].alias, "do-old-name");
3327 assert_eq!(config.host_entries()[0].provider_tags, vec!["staging"]);
3328
3329 let remote2 = vec![ProviderHost::new(
3331 "1".to_string(),
3332 "new-name".to_string(),
3333 "1.2.3.4".to_string(),
3334 vec!["staging".to_string(), "prod".to_string()],
3335 )];
3336 let result = sync_provider(
3337 &mut config,
3338 &MockProvider,
3339 &remote2,
3340 §ion,
3341 false,
3342 false,
3343 false,
3344 );
3345 assert_eq!(result.updated, 1);
3346 assert_eq!(result.renames.len(), 1);
3347
3348 let entries = config.host_entries();
3349 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3350 assert!(entry.provider_tags.contains(&"staging".to_string()));
3351 assert!(entry.provider_tags.contains(&"prod".to_string()));
3352 }
3353
3354 #[test]
3359 fn test_sync_all_symbol_name_uses_server_fallback() {
3360 let mut config = empty_config();
3361 let section = make_section();
3362 let remote = vec![ProviderHost::new(
3363 "1".to_string(),
3364 "!!!".to_string(),
3365 "1.2.3.4".to_string(),
3366 Vec::new(),
3367 )];
3368 sync_provider(
3369 &mut config,
3370 &MockProvider,
3371 &remote,
3372 §ion,
3373 false,
3374 false,
3375 false,
3376 );
3377 let entries = config.host_entries();
3378 assert_eq!(entries[0].alias, "do-server");
3379 }
3380
3381 #[test]
3382 fn test_sync_unicode_name_uses_ascii_fallback() {
3383 let mut config = empty_config();
3384 let section = make_section();
3385 let remote = vec![ProviderHost::new(
3386 "1".to_string(),
3387 "서버".to_string(),
3388 "1.2.3.4".to_string(),
3389 Vec::new(),
3390 )];
3391 sync_provider(
3392 &mut config,
3393 &MockProvider,
3394 &remote,
3395 §ion,
3396 false,
3397 false,
3398 false,
3399 );
3400 let entries = config.host_entries();
3401 assert_eq!(entries[0].alias, "do-server");
3403 }
3404
3405 #[test]
3410 fn test_sync_dry_run_update_preserves_config() {
3411 let mut config = empty_config();
3412 let section = make_section();
3413 let remote = vec![ProviderHost::new(
3414 "1".to_string(),
3415 "web".to_string(),
3416 "1.2.3.4".to_string(),
3417 Vec::new(),
3418 )];
3419 sync_provider(
3420 &mut config,
3421 &MockProvider,
3422 &remote,
3423 §ion,
3424 false,
3425 false,
3426 false,
3427 );
3428
3429 let remote2 = vec![ProviderHost::new(
3431 "1".to_string(),
3432 "web".to_string(),
3433 "9.9.9.9".to_string(),
3434 Vec::new(),
3435 )];
3436 let result = sync_provider(
3437 &mut config,
3438 &MockProvider,
3439 &remote2,
3440 §ion,
3441 false,
3442 false,
3443 true,
3444 );
3445 assert_eq!(result.updated, 1);
3446 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
3448 }
3449
3450 #[test]
3455 fn test_sync_empty_remote_empty_config_noop() {
3456 let mut config = empty_config();
3457 let section = make_section();
3458 let result = sync_provider(
3459 &mut config,
3460 &MockProvider,
3461 &[],
3462 §ion,
3463 true,
3464 false,
3465 false,
3466 );
3467 assert_eq!(result.added, 0);
3468 assert_eq!(result.updated, 0);
3469 assert_eq!(result.removed, 0);
3470 assert_eq!(result.unchanged, 0);
3471 assert!(config.host_entries().is_empty());
3472 }
3473
3474 #[test]
3479 fn test_sync_large_batch() {
3480 let mut config = empty_config();
3481 let section = make_section();
3482 let remote: Vec<ProviderHost> = (0..100)
3483 .map(|i| {
3484 ProviderHost::new(
3485 format!("{}", i),
3486 format!("server-{}", i),
3487 format!("10.0.0.{}", i % 256),
3488 vec!["batch".to_string()],
3489 )
3490 })
3491 .collect();
3492 let result = sync_provider(
3493 &mut config,
3494 &MockProvider,
3495 &remote,
3496 §ion,
3497 false,
3498 false,
3499 false,
3500 );
3501 assert_eq!(result.added, 100);
3502 assert_eq!(config.host_entries().len(), 100);
3503
3504 let result2 = sync_provider(
3506 &mut config,
3507 &MockProvider,
3508 &remote,
3509 §ion,
3510 false,
3511 false,
3512 false,
3513 );
3514 assert_eq!(result2.unchanged, 100);
3515 assert_eq!(result2.added, 0);
3516 }
3517
3518 #[test]
3523 fn test_sync_rename_self_exclusion_no_collision() {
3524 let mut config = empty_config();
3527 let section = make_section();
3528 let remote = vec![ProviderHost::new(
3529 "1".to_string(),
3530 "web".to_string(),
3531 "1.2.3.4".to_string(),
3532 Vec::new(),
3533 )];
3534 sync_provider(
3535 &mut config,
3536 &MockProvider,
3537 &remote,
3538 §ion,
3539 false,
3540 false,
3541 false,
3542 );
3543 assert_eq!(config.host_entries()[0].alias, "do-web");
3544
3545 let remote2 = vec![ProviderHost::new(
3547 "1".to_string(),
3548 "web".to_string(),
3549 "9.9.9.9".to_string(),
3550 Vec::new(),
3551 )];
3552 let result = sync_provider(
3553 &mut config,
3554 &MockProvider,
3555 &remote2,
3556 §ion,
3557 false,
3558 false,
3559 false,
3560 );
3561 assert_eq!(result.updated, 1);
3562 assert!(result.renames.is_empty());
3563 assert_eq!(config.host_entries()[0].alias, "do-web"); }
3565
3566 #[test]
3571 fn test_sync_provider_tags_with_rename() {
3572 let mut config = empty_config();
3573 let section = make_section();
3574 let remote = vec![ProviderHost::new(
3575 "1".to_string(),
3576 "old-name".to_string(),
3577 "1.2.3.4".to_string(),
3578 vec!["staging".to_string()],
3579 )];
3580 sync_provider(
3581 &mut config,
3582 &MockProvider,
3583 &remote,
3584 §ion,
3585 false,
3586 false,
3587 false,
3588 );
3589 config.set_host_tags(
3590 "do-old-name",
3591 &["staging".to_string(), "custom".to_string()],
3592 );
3593
3594 let remote2 = vec![ProviderHost::new(
3596 "1".to_string(),
3597 "new-name".to_string(),
3598 "1.2.3.4".to_string(),
3599 vec!["production".to_string()],
3600 )];
3601 let result = sync_provider(
3602 &mut config,
3603 &MockProvider,
3604 &remote2,
3605 §ion,
3606 false,
3607 false,
3608 false,
3609 );
3610 assert_eq!(result.updated, 1);
3611 assert_eq!(result.renames.len(), 1);
3612
3613 let entries = config.host_entries();
3614 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
3615 assert_eq!(entry.provider_tags, vec!["production"]);
3617 assert!(entry.tags.contains(&"custom".to_string()));
3619 assert!(entry.tags.contains(&"staging".to_string()));
3620 }
3621
3622 #[test]
3627 fn test_sync_empty_ip_with_tags_not_added() {
3628 let mut config = empty_config();
3629 let section = make_section();
3630 let remote = vec![ProviderHost::new(
3631 "1".to_string(),
3632 "stopped".to_string(),
3633 String::new(),
3634 vec!["prod".to_string()],
3635 )];
3636 let result = sync_provider(
3637 &mut config,
3638 &MockProvider,
3639 &remote,
3640 §ion,
3641 false,
3642 false,
3643 false,
3644 );
3645 assert_eq!(result.added, 0);
3646 assert!(config.host_entries().is_empty());
3647 }
3648
3649 #[test]
3654 fn test_sync_orphaned_provider_marker_counts_unchanged() {
3655 let content = "\
3660Host do-web
3661 HostName 1.2.3.4
3662 # purple:provider digitalocean:123
3663";
3664 let mut config = SshConfigFile {
3665 elements: SshConfigFile::parse_content(content),
3666 path: PathBuf::from("/tmp/test_config"),
3667 crlf: false,
3668 bom: false,
3669 };
3670 let section = make_section();
3671 let remote = vec![ProviderHost::new(
3672 "123".to_string(),
3673 "web".to_string(),
3674 "1.2.3.4".to_string(),
3675 Vec::new(),
3676 )];
3677 let result = sync_provider(
3678 &mut config,
3679 &MockProvider,
3680 &remote,
3681 §ion,
3682 false,
3683 false,
3684 false,
3685 );
3686 assert_eq!(result.unchanged, 1);
3687 }
3688
3689 #[test]
3694 fn test_sync_no_double_blank_between_hosts() {
3695 let mut config = empty_config();
3696 let section = make_section();
3697 let remote = vec![
3698 ProviderHost::new(
3699 "1".to_string(),
3700 "web".to_string(),
3701 "1.2.3.4".to_string(),
3702 Vec::new(),
3703 ),
3704 ProviderHost::new(
3705 "2".to_string(),
3706 "db".to_string(),
3707 "5.6.7.8".to_string(),
3708 Vec::new(),
3709 ),
3710 ];
3711 sync_provider(
3712 &mut config,
3713 &MockProvider,
3714 &remote,
3715 §ion,
3716 false,
3717 false,
3718 false,
3719 );
3720
3721 let mut prev_blank = false;
3723 for elem in &config.elements {
3724 if let ConfigElement::GlobalLine(line) = elem {
3725 let is_blank = line.trim().is_empty();
3726 assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
3727 prev_blank = is_blank;
3728 } else {
3729 prev_blank = false;
3730 }
3731 }
3732 }
3733
3734 #[test]
3739 fn test_sync_without_remove_flag_keeps_deleted() {
3740 let mut config = empty_config();
3741 let section = make_section();
3742 let remote = vec![ProviderHost::new(
3743 "1".to_string(),
3744 "web".to_string(),
3745 "1.2.3.4".to_string(),
3746 Vec::new(),
3747 )];
3748 sync_provider(
3749 &mut config,
3750 &MockProvider,
3751 &remote,
3752 §ion,
3753 false,
3754 false,
3755 false,
3756 );
3757
3758 let result = sync_provider(
3760 &mut config,
3761 &MockProvider,
3762 &[],
3763 §ion,
3764 false,
3765 false,
3766 false,
3767 );
3768 assert_eq!(result.removed, 0);
3769 assert_eq!(config.host_entries().len(), 1); }
3771
3772 #[test]
3777 fn test_sync_dry_run_rename_no_renames_tracked() {
3778 let mut config = empty_config();
3779 let section = make_section();
3780 let remote = vec![ProviderHost::new(
3781 "1".to_string(),
3782 "old".to_string(),
3783 "1.2.3.4".to_string(),
3784 Vec::new(),
3785 )];
3786 sync_provider(
3787 &mut config,
3788 &MockProvider,
3789 &remote,
3790 §ion,
3791 false,
3792 false,
3793 false,
3794 );
3795
3796 let new_section = ProviderSection {
3797 alias_prefix: "ocean".to_string(),
3798 ..section
3799 };
3800 let result = sync_provider(
3801 &mut config,
3802 &MockProvider,
3803 &remote,
3804 &new_section,
3805 false,
3806 false,
3807 true,
3808 );
3809 assert_eq!(result.updated, 1);
3810 assert!(result.renames.is_empty());
3812 }
3813
3814 #[test]
3819 fn test_sanitize_name_whitespace_only() {
3820 assert_eq!(sanitize_name(" "), "server");
3821 }
3822
3823 #[test]
3824 fn test_sanitize_name_single_char() {
3825 assert_eq!(sanitize_name("a"), "a");
3826 assert_eq!(sanitize_name("Z"), "z");
3827 assert_eq!(sanitize_name("5"), "5");
3828 }
3829
3830 #[test]
3831 fn test_sanitize_name_single_special_char() {
3832 assert_eq!(sanitize_name("!"), "server");
3833 assert_eq!(sanitize_name("-"), "server");
3834 assert_eq!(sanitize_name("."), "server");
3835 }
3836
3837 #[test]
3838 fn test_sanitize_name_emoji() {
3839 assert_eq!(sanitize_name("server🚀"), "server");
3840 assert_eq!(sanitize_name("🔥hot🔥"), "hot");
3841 }
3842
3843 #[test]
3844 fn test_sanitize_name_long_mixed_separators() {
3845 assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
3846 }
3847
3848 #[test]
3849 fn test_sanitize_name_dots_and_underscores() {
3850 assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
3851 }
3852
3853 #[test]
3858 fn test_find_hosts_by_provider_in_includes() {
3859 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3860
3861 let include_content =
3862 "Host do-included\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n";
3863 let included_elements = SshConfigFile::parse_content(include_content);
3864
3865 let config = SshConfigFile {
3866 elements: vec![ConfigElement::Include(IncludeDirective {
3867 raw_line: "Include conf.d/*".to_string(),
3868 pattern: "conf.d/*".to_string(),
3869 resolved_files: vec![IncludedFile {
3870 path: PathBuf::from("/tmp/included.conf"),
3871 elements: included_elements,
3872 }],
3873 })],
3874 path: PathBuf::from("/tmp/test_config"),
3875 crlf: false,
3876 bom: false,
3877 };
3878
3879 let hosts = config.find_hosts_by_provider("digitalocean");
3880 assert_eq!(hosts.len(), 1);
3881 assert_eq!(hosts[0].0, "do-included");
3882 assert_eq!(hosts[0].1, "inc1");
3883 }
3884
3885 #[test]
3886 fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
3887 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3888
3889 let top_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
3891 let top_elements = SshConfigFile::parse_content(top_content);
3892
3893 let inc_content = "Host do-db\n HostName 5.6.7.8\n # purple:provider digitalocean:2\n";
3895 let inc_elements = SshConfigFile::parse_content(inc_content);
3896
3897 let mut elements = top_elements;
3898 elements.push(ConfigElement::Include(IncludeDirective {
3899 raw_line: "Include conf.d/*".to_string(),
3900 pattern: "conf.d/*".to_string(),
3901 resolved_files: vec![IncludedFile {
3902 path: PathBuf::from("/tmp/included.conf"),
3903 elements: inc_elements,
3904 }],
3905 }));
3906
3907 let config = SshConfigFile {
3908 elements,
3909 path: PathBuf::from("/tmp/test_config"),
3910 crlf: false,
3911 bom: false,
3912 };
3913
3914 let hosts = config.find_hosts_by_provider("digitalocean");
3915 assert_eq!(hosts.len(), 2);
3916 }
3917
3918 #[test]
3919 fn test_find_hosts_by_provider_empty_includes() {
3920 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
3921
3922 let config = SshConfigFile {
3923 elements: vec![ConfigElement::Include(IncludeDirective {
3924 raw_line: "Include conf.d/*".to_string(),
3925 pattern: "conf.d/*".to_string(),
3926 resolved_files: vec![IncludedFile {
3927 path: PathBuf::from("/tmp/empty.conf"),
3928 elements: vec![],
3929 }],
3930 })],
3931 path: PathBuf::from("/tmp/test_config"),
3932 crlf: false,
3933 bom: false,
3934 };
3935
3936 let hosts = config.find_hosts_by_provider("digitalocean");
3937 assert!(hosts.is_empty());
3938 }
3939
3940 #[test]
3941 fn test_find_hosts_by_provider_wrong_provider_name() {
3942 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
3943 let config = SshConfigFile {
3944 elements: SshConfigFile::parse_content(content),
3945 path: PathBuf::from("/tmp/test_config"),
3946 crlf: false,
3947 bom: false,
3948 };
3949
3950 let hosts = config.find_hosts_by_provider("vultr");
3951 assert!(hosts.is_empty());
3952 }
3953
3954 #[test]
3959 fn test_deduplicate_alias_excluding_self() {
3960 let content = "Host do-web\n HostName 1.2.3.4\n";
3962 let config = SshConfigFile {
3963 elements: SshConfigFile::parse_content(content),
3964 path: PathBuf::from("/tmp/test_config"),
3965 crlf: false,
3966 bom: false,
3967 };
3968
3969 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
3970 assert_eq!(alias, "do-web"); }
3972
3973 #[test]
3974 fn test_deduplicate_alias_excluding_other() {
3975 let content = "Host do-web\n HostName 1.2.3.4\n";
3977 let config = SshConfigFile {
3978 elements: SshConfigFile::parse_content(content),
3979 path: PathBuf::from("/tmp/test_config"),
3980 crlf: false,
3981 bom: false,
3982 };
3983
3984 let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
3985 assert_eq!(alias, "do-web-2"); }
3987
3988 #[test]
3989 fn test_deduplicate_alias_excluding_chain() {
3990 let content = "Host do-web\n HostName 1.1.1.1\n\nHost do-web-2\n HostName 2.2.2.2\n";
3992 let config = SshConfigFile {
3993 elements: SshConfigFile::parse_content(content),
3994 path: PathBuf::from("/tmp/test_config"),
3995 crlf: false,
3996 bom: false,
3997 };
3998
3999 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
4000 assert_eq!(alias, "do-web");
4002 }
4003
4004 #[test]
4005 fn test_deduplicate_alias_excluding_none() {
4006 let content = "Host do-web\n HostName 1.2.3.4\n";
4007 let config = SshConfigFile {
4008 elements: SshConfigFile::parse_content(content),
4009 path: PathBuf::from("/tmp/test_config"),
4010 crlf: false,
4011 bom: false,
4012 };
4013
4014 let alias = config.deduplicate_alias_excluding("do-web", None);
4016 assert_eq!(alias, "do-web-2");
4017 }
4018
4019 #[test]
4024 fn test_set_host_tags_empty_clears_tags() {
4025 let content = "Host do-web\n HostName 1.2.3.4\n # purple:tags prod,staging\n";
4026 let mut config = SshConfigFile {
4027 elements: SshConfigFile::parse_content(content),
4028 path: PathBuf::from("/tmp/test_config"),
4029 crlf: false,
4030 bom: false,
4031 };
4032
4033 config.set_host_tags("do-web", &[]);
4034 let entries = config.host_entries();
4035 assert!(entries[0].tags.is_empty());
4036 }
4037
4038 #[test]
4039 fn test_set_host_provider_updates_existing() {
4040 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:old-id\n";
4041 let mut config = SshConfigFile {
4042 elements: SshConfigFile::parse_content(content),
4043 path: PathBuf::from("/tmp/test_config"),
4044 crlf: false,
4045 bom: false,
4046 };
4047
4048 config.set_host_provider("do-web", "digitalocean", "new-id");
4049 let hosts = config.find_hosts_by_provider("digitalocean");
4050 assert_eq!(hosts.len(), 1);
4051 assert_eq!(hosts[0].1, "new-id");
4052 }
4053
4054 #[test]
4059 fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
4060 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4061
4062 let include_content =
4063 "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n";
4064 let included_elements = SshConfigFile::parse_content(include_content);
4065
4066 let mut config = SshConfigFile {
4067 elements: vec![ConfigElement::Include(IncludeDirective {
4068 raw_line: "Include conf.d/*".to_string(),
4069 pattern: "conf.d/*".to_string(),
4070 resolved_files: vec![IncludedFile {
4071 path: PathBuf::from("/tmp/included.conf"),
4072 elements: included_elements,
4073 }],
4074 })],
4075 path: PathBuf::from("/tmp/test_config"),
4076 crlf: false,
4077 bom: false,
4078 };
4079
4080 let section = make_section();
4081 let remote = vec![ProviderHost::new(
4082 "123".to_string(),
4083 "web".to_string(),
4084 "1.2.3.4".to_string(),
4085 Vec::new(),
4086 )];
4087
4088 let result = sync_provider(
4089 &mut config,
4090 &MockProvider,
4091 &remote,
4092 §ion,
4093 false,
4094 false,
4095 false,
4096 );
4097 assert_eq!(result.unchanged, 1);
4098 assert_eq!(result.added, 0);
4099 let top_hosts = config
4101 .elements
4102 .iter()
4103 .filter(|e| matches!(e, ConfigElement::HostBlock(_)))
4104 .count();
4105 assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
4106 }
4107
4108 #[test]
4113 fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
4114 let mut config = empty_config();
4115 let section = make_section();
4116
4117 let remote = vec![ProviderHost::new(
4119 "1".to_string(),
4120 "web".to_string(),
4121 "1.2.3.4".to_string(),
4122 Vec::new(),
4123 )];
4124 sync_provider(
4125 &mut config,
4126 &MockProvider,
4127 &remote,
4128 §ion,
4129 false,
4130 false,
4131 false,
4132 );
4133 assert_eq!(config.host_entries()[0].alias, "do-web");
4134
4135 let other = vec![ProviderHost::new(
4137 "2".to_string(),
4138 "new-web".to_string(),
4139 "5.5.5.5".to_string(),
4140 Vec::new(),
4141 )];
4142 sync_provider(
4143 &mut config,
4144 &MockProvider,
4145 &other,
4146 §ion,
4147 false,
4148 false,
4149 false,
4150 );
4151
4152 let remote_same = vec![
4160 ProviderHost::new(
4161 "1".to_string(),
4162 "web".to_string(),
4163 "1.2.3.4".to_string(),
4164 Vec::new(),
4165 ),
4166 ProviderHost::new(
4167 "2".to_string(),
4168 "new-web".to_string(),
4169 "5.5.5.5".to_string(),
4170 Vec::new(),
4171 ),
4172 ];
4173 let result = sync_provider(
4174 &mut config,
4175 &MockProvider,
4176 &remote_same,
4177 §ion,
4178 false,
4179 false,
4180 false,
4181 );
4182 assert_eq!(result.unchanged, 1);
4185 assert_eq!(result.updated, 1);
4186 assert!(result.renames.is_empty());
4187 }
4188
4189 #[test]
4194 fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
4195 let mut config = empty_config();
4198 let section = make_section();
4199
4200 let remote = vec![
4201 ProviderHost::new(
4202 "1".to_string(),
4203 "web".to_string(),
4204 "1.1.1.1".to_string(),
4205 Vec::new(),
4206 ),
4207 ProviderHost::new(
4208 "2".to_string(),
4209 "web".to_string(),
4210 "2.2.2.2".to_string(),
4211 Vec::new(),
4212 ),
4213 ];
4214 let result = sync_provider(
4215 &mut config,
4216 &MockProvider,
4217 &remote,
4218 §ion,
4219 false,
4220 false,
4221 false,
4222 );
4223 assert_eq!(result.added, 2);
4224
4225 let entries = config.host_entries();
4226 assert_eq!(entries[0].alias, "do-web");
4227 assert_eq!(entries[1].alias, "do-web-2");
4228
4229 let result = sync_provider(
4231 &mut config,
4232 &MockProvider,
4233 &remote,
4234 §ion,
4235 false,
4236 false,
4237 false,
4238 );
4239 assert_eq!(result.unchanged, 2);
4240 }
4241
4242 #[test]
4247 fn test_sync_dry_run_remove_excludes_included_hosts() {
4248 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
4249
4250 let include_content =
4251 "Host do-included\n HostName 1.1.1.1\n # purple:provider digitalocean:inc1\n";
4252 let included_elements = SshConfigFile::parse_content(include_content);
4253
4254 let mut config = SshConfigFile {
4256 elements: vec![ConfigElement::Include(IncludeDirective {
4257 raw_line: "Include conf.d/*".to_string(),
4258 pattern: "conf.d/*".to_string(),
4259 resolved_files: vec![IncludedFile {
4260 path: PathBuf::from("/tmp/included.conf"),
4261 elements: included_elements,
4262 }],
4263 })],
4264 path: PathBuf::from("/tmp/test_config"),
4265 crlf: false,
4266 bom: false,
4267 };
4268
4269 let section = make_section();
4271 let remote = vec![ProviderHost::new(
4272 "top1".to_string(),
4273 "toplevel".to_string(),
4274 "2.2.2.2".to_string(),
4275 Vec::new(),
4276 )];
4277 sync_provider(
4278 &mut config,
4279 &MockProvider,
4280 &remote,
4281 §ion,
4282 false,
4283 false,
4284 false,
4285 );
4286
4287 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, true);
4290 assert_eq!(
4291 result.removed, 1,
4292 "Only top-level host counted in dry-run remove"
4293 );
4294 }
4295
4296 #[test]
4301 fn test_sync_group_header_with_existing_trailing_blank() {
4302 let mut config = empty_config();
4303 config
4305 .elements
4306 .push(ConfigElement::GlobalLine("# some comment".to_string()));
4307 config
4308 .elements
4309 .push(ConfigElement::GlobalLine(String::new()));
4310
4311 let section = make_section();
4312 let remote = vec![ProviderHost::new(
4313 "1".to_string(),
4314 "web".to_string(),
4315 "1.2.3.4".to_string(),
4316 Vec::new(),
4317 )];
4318 let result = sync_provider(
4319 &mut config,
4320 &MockProvider,
4321 &remote,
4322 §ion,
4323 false,
4324 false,
4325 false,
4326 );
4327 assert_eq!(result.added, 1);
4328
4329 let blank_count = config
4332 .elements
4333 .iter()
4334 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
4335 .count();
4336 assert_eq!(
4337 blank_count, 1,
4338 "No extra blank line when one already exists"
4339 );
4340 }
4341
4342 #[test]
4347 fn test_sync_no_group_header_for_second_host() {
4348 let mut config = empty_config();
4349 let section = make_section();
4350
4351 let remote = vec![ProviderHost::new(
4353 "1".to_string(),
4354 "web".to_string(),
4355 "1.2.3.4".to_string(),
4356 Vec::new(),
4357 )];
4358 sync_provider(
4359 &mut config,
4360 &MockProvider,
4361 &remote,
4362 §ion,
4363 false,
4364 false,
4365 false,
4366 );
4367
4368 let header_count_before = config
4369 .elements
4370 .iter()
4371 .filter(
4372 |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4373 )
4374 .count();
4375 assert_eq!(header_count_before, 1);
4376
4377 let remote2 = vec![
4379 ProviderHost::new(
4380 "1".to_string(),
4381 "web".to_string(),
4382 "1.2.3.4".to_string(),
4383 Vec::new(),
4384 ),
4385 ProviderHost::new(
4386 "2".to_string(),
4387 "db".to_string(),
4388 "5.5.5.5".to_string(),
4389 Vec::new(),
4390 ),
4391 ];
4392 sync_provider(
4393 &mut config,
4394 &MockProvider,
4395 &remote2,
4396 §ion,
4397 false,
4398 false,
4399 false,
4400 );
4401
4402 let header_count_after = config
4404 .elements
4405 .iter()
4406 .filter(
4407 |e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")),
4408 )
4409 .count();
4410 assert_eq!(header_count_after, 1, "No duplicate group header");
4411 }
4412
4413 #[test]
4418 fn test_sync_duplicate_server_id_in_remote_skipped() {
4419 let mut config = empty_config();
4420 let section = make_section();
4421
4422 let remote = vec![
4424 ProviderHost::new(
4425 "dup".to_string(),
4426 "first".to_string(),
4427 "1.1.1.1".to_string(),
4428 Vec::new(),
4429 ),
4430 ProviderHost::new(
4431 "dup".to_string(),
4432 "second".to_string(),
4433 "2.2.2.2".to_string(),
4434 Vec::new(),
4435 ),
4436 ];
4437 let result = sync_provider(
4438 &mut config,
4439 &MockProvider,
4440 &remote,
4441 §ion,
4442 false,
4443 false,
4444 false,
4445 );
4446 assert_eq!(result.added, 1, "Only the first instance is added");
4447 assert_eq!(config.host_entries()[0].alias, "do-first");
4448 }
4449
4450 #[test]
4455 fn test_sync_empty_ip_existing_host_counted_unchanged() {
4456 let mut config = empty_config();
4457 let section = make_section();
4458
4459 let remote = vec![ProviderHost::new(
4461 "1".to_string(),
4462 "web".to_string(),
4463 "1.2.3.4".to_string(),
4464 Vec::new(),
4465 )];
4466 sync_provider(
4467 &mut config,
4468 &MockProvider,
4469 &remote,
4470 §ion,
4471 false,
4472 false,
4473 false,
4474 );
4475
4476 let remote2 = vec![ProviderHost::new(
4478 "1".to_string(),
4479 "web".to_string(),
4480 String::new(),
4481 Vec::new(),
4482 )];
4483 let result = sync_provider(
4484 &mut config,
4485 &MockProvider,
4486 &remote2,
4487 §ion,
4488 false,
4489 false,
4490 true,
4491 );
4492 assert_eq!(result.unchanged, 1);
4493 assert_eq!(result.removed, 0, "Host with empty IP not removed");
4494 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
4495 }
4496
4497 #[test]
4502 fn test_sync_provider_tags_case_insensitive_no_update() {
4503 let mut config = empty_config();
4504 let section = make_section();
4505
4506 let remote = vec![ProviderHost::new(
4507 "1".to_string(),
4508 "web".to_string(),
4509 "1.2.3.4".to_string(),
4510 vec!["Production".to_string()],
4511 )];
4512 sync_provider(
4513 &mut config,
4514 &MockProvider,
4515 &remote,
4516 §ion,
4517 false,
4518 false,
4519 false,
4520 );
4521
4522 let remote2 = vec![ProviderHost::new(
4524 "1".to_string(),
4525 "web".to_string(),
4526 "1.2.3.4".to_string(),
4527 vec!["production".to_string()],
4528 )];
4529 let result = sync_provider(
4530 &mut config,
4531 &MockProvider,
4532 &remote2,
4533 §ion,
4534 false,
4535 false,
4536 false,
4537 );
4538 assert_eq!(
4539 result.unchanged, 1,
4540 "Case-insensitive tag match = unchanged"
4541 );
4542 }
4543
4544 #[test]
4549 fn test_sync_remove_cleans_up_group_header() {
4550 let mut config = empty_config();
4551 let section = make_section();
4552
4553 let remote = vec![ProviderHost::new(
4554 "1".to_string(),
4555 "web".to_string(),
4556 "1.2.3.4".to_string(),
4557 Vec::new(),
4558 )];
4559 sync_provider(
4560 &mut config,
4561 &MockProvider,
4562 &remote,
4563 §ion,
4564 false,
4565 false,
4566 false,
4567 );
4568
4569 let has_header = config
4571 .elements
4572 .iter()
4573 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4574 assert!(has_header, "Group header present after add");
4575
4576 let result = sync_provider(
4578 &mut config,
4579 &MockProvider,
4580 &[],
4581 §ion,
4582 true,
4583 false,
4584 false,
4585 );
4586 assert_eq!(result.removed, 1);
4587
4588 let has_header_after = config
4590 .elements
4591 .iter()
4592 .any(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")));
4593 assert!(
4594 !has_header_after,
4595 "Group header removed when all hosts gone"
4596 );
4597 }
4598
4599 #[test]
4604 fn test_sync_adds_host_with_metadata() {
4605 let mut config = empty_config();
4606 let section = make_section();
4607 let remote = vec![ProviderHost {
4608 server_id: "1".to_string(),
4609 name: "web".to_string(),
4610 ip: "1.2.3.4".to_string(),
4611 tags: Vec::new(),
4612 metadata: vec![
4613 ("region".to_string(), "nyc3".to_string()),
4614 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4615 ],
4616 }];
4617 let result = sync_provider(
4618 &mut config,
4619 &MockProvider,
4620 &remote,
4621 §ion,
4622 false,
4623 false,
4624 false,
4625 );
4626 assert_eq!(result.added, 1);
4627 let entries = config.host_entries();
4628 assert_eq!(entries[0].provider_meta.len(), 2);
4629 assert_eq!(
4630 entries[0].provider_meta[0],
4631 ("region".to_string(), "nyc3".to_string())
4632 );
4633 assert_eq!(
4634 entries[0].provider_meta[1],
4635 ("plan".to_string(), "s-1vcpu-1gb".to_string())
4636 );
4637 }
4638
4639 #[test]
4640 fn test_sync_updates_changed_metadata() {
4641 let mut config = empty_config();
4642 let section = make_section();
4643 let remote = vec![ProviderHost {
4644 server_id: "1".to_string(),
4645 name: "web".to_string(),
4646 ip: "1.2.3.4".to_string(),
4647 tags: Vec::new(),
4648 metadata: vec![("region".to_string(), "nyc3".to_string())],
4649 }];
4650 sync_provider(
4651 &mut config,
4652 &MockProvider,
4653 &remote,
4654 §ion,
4655 false,
4656 false,
4657 false,
4658 );
4659
4660 let remote2 = vec![ProviderHost {
4662 server_id: "1".to_string(),
4663 name: "web".to_string(),
4664 ip: "1.2.3.4".to_string(),
4665 tags: Vec::new(),
4666 metadata: vec![
4667 ("region".to_string(), "sfo3".to_string()),
4668 ("plan".to_string(), "s-2vcpu-2gb".to_string()),
4669 ],
4670 }];
4671 let result = sync_provider(
4672 &mut config,
4673 &MockProvider,
4674 &remote2,
4675 §ion,
4676 false,
4677 false,
4678 false,
4679 );
4680 assert_eq!(result.updated, 1);
4681 let entries = config.host_entries();
4682 assert_eq!(entries[0].provider_meta.len(), 2);
4683 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4684 assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
4685 }
4686
4687 #[test]
4688 fn test_sync_metadata_unchanged_no_update() {
4689 let mut config = empty_config();
4690 let section = make_section();
4691 let remote = vec![ProviderHost {
4692 server_id: "1".to_string(),
4693 name: "web".to_string(),
4694 ip: "1.2.3.4".to_string(),
4695 tags: Vec::new(),
4696 metadata: vec![("region".to_string(), "nyc3".to_string())],
4697 }];
4698 sync_provider(
4699 &mut config,
4700 &MockProvider,
4701 &remote,
4702 §ion,
4703 false,
4704 false,
4705 false,
4706 );
4707
4708 let result = sync_provider(
4710 &mut config,
4711 &MockProvider,
4712 &remote,
4713 §ion,
4714 false,
4715 false,
4716 false,
4717 );
4718 assert_eq!(result.unchanged, 1);
4719 assert_eq!(result.updated, 0);
4720 }
4721
4722 #[test]
4723 fn test_sync_metadata_order_insensitive() {
4724 let mut config = empty_config();
4725 let section = make_section();
4726 let remote = vec![ProviderHost {
4727 server_id: "1".to_string(),
4728 name: "web".to_string(),
4729 ip: "1.2.3.4".to_string(),
4730 tags: Vec::new(),
4731 metadata: vec![
4732 ("region".to_string(), "nyc3".to_string()),
4733 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4734 ],
4735 }];
4736 sync_provider(
4737 &mut config,
4738 &MockProvider,
4739 &remote,
4740 §ion,
4741 false,
4742 false,
4743 false,
4744 );
4745
4746 let remote2 = vec![ProviderHost {
4748 server_id: "1".to_string(),
4749 name: "web".to_string(),
4750 ip: "1.2.3.4".to_string(),
4751 tags: Vec::new(),
4752 metadata: vec![
4753 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4754 ("region".to_string(), "nyc3".to_string()),
4755 ],
4756 }];
4757 let result = sync_provider(
4758 &mut config,
4759 &MockProvider,
4760 &remote2,
4761 §ion,
4762 false,
4763 false,
4764 false,
4765 );
4766 assert_eq!(result.unchanged, 1);
4767 assert_eq!(result.updated, 0);
4768 }
4769
4770 #[test]
4771 fn test_sync_metadata_with_rename() {
4772 let mut config = empty_config();
4773 let section = make_section();
4774 let remote = vec![ProviderHost {
4775 server_id: "1".to_string(),
4776 name: "old-name".to_string(),
4777 ip: "1.2.3.4".to_string(),
4778 tags: Vec::new(),
4779 metadata: vec![("region".to_string(), "nyc3".to_string())],
4780 }];
4781 sync_provider(
4782 &mut config,
4783 &MockProvider,
4784 &remote,
4785 §ion,
4786 false,
4787 false,
4788 false,
4789 );
4790 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4791
4792 let remote2 = vec![ProviderHost {
4794 server_id: "1".to_string(),
4795 name: "new-name".to_string(),
4796 ip: "1.2.3.4".to_string(),
4797 tags: Vec::new(),
4798 metadata: vec![("region".to_string(), "sfo3".to_string())],
4799 }];
4800 let result = sync_provider(
4801 &mut config,
4802 &MockProvider,
4803 &remote2,
4804 §ion,
4805 false,
4806 false,
4807 false,
4808 );
4809 assert_eq!(result.updated, 1);
4810 assert!(!result.renames.is_empty());
4811 let entries = config.host_entries();
4812 assert_eq!(entries[0].alias, "do-new-name");
4813 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
4814 }
4815
4816 #[test]
4817 fn test_sync_metadata_dry_run_no_mutation() {
4818 let mut config = empty_config();
4819 let section = make_section();
4820 let remote = vec![ProviderHost {
4821 server_id: "1".to_string(),
4822 name: "web".to_string(),
4823 ip: "1.2.3.4".to_string(),
4824 tags: Vec::new(),
4825 metadata: vec![("region".to_string(), "nyc3".to_string())],
4826 }];
4827 sync_provider(
4828 &mut config,
4829 &MockProvider,
4830 &remote,
4831 §ion,
4832 false,
4833 false,
4834 false,
4835 );
4836
4837 let remote2 = vec![ProviderHost {
4839 server_id: "1".to_string(),
4840 name: "web".to_string(),
4841 ip: "1.2.3.4".to_string(),
4842 tags: Vec::new(),
4843 metadata: vec![("region".to_string(), "sfo3".to_string())],
4844 }];
4845 let result = sync_provider(
4846 &mut config,
4847 &MockProvider,
4848 &remote2,
4849 §ion,
4850 false,
4851 false,
4852 true,
4853 );
4854 assert_eq!(result.updated, 1);
4855 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
4857 }
4858
4859 #[test]
4860 fn test_sync_metadata_only_change_triggers_update() {
4861 let mut config = empty_config();
4862 let section = make_section();
4863 let remote = vec![ProviderHost {
4864 server_id: "1".to_string(),
4865 name: "web".to_string(),
4866 ip: "1.2.3.4".to_string(),
4867 tags: vec!["prod".to_string()],
4868 metadata: vec![("region".to_string(), "nyc3".to_string())],
4869 }];
4870 sync_provider(
4871 &mut config,
4872 &MockProvider,
4873 &remote,
4874 §ion,
4875 false,
4876 false,
4877 false,
4878 );
4879
4880 let remote2 = vec![ProviderHost {
4882 server_id: "1".to_string(),
4883 name: "web".to_string(),
4884 ip: "1.2.3.4".to_string(),
4885 tags: vec!["prod".to_string()],
4886 metadata: vec![
4887 ("region".to_string(), "nyc3".to_string()),
4888 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
4889 ],
4890 }];
4891 let result = sync_provider(
4892 &mut config,
4893 &MockProvider,
4894 &remote2,
4895 §ion,
4896 false,
4897 false,
4898 false,
4899 );
4900 assert_eq!(result.updated, 1);
4901 assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
4902 }
4903
4904 #[test]
4909 fn test_sync_upgrade_migration() {
4910 let content = "\
4914Host do-web-1
4915 HostName 1.2.3.4
4916 User root
4917 # purple:tags prod,us-east,my-custom
4918 # purple:provider digitalocean:123
4919";
4920 let mut config = SshConfigFile {
4921 elements: SshConfigFile::parse_content(content),
4922 path: PathBuf::from("/tmp/test_config"),
4923 crlf: false,
4924 bom: false,
4925 };
4926 let section = make_section();
4927
4928 let remote = vec![ProviderHost::new(
4930 "123".to_string(),
4931 "web-1".to_string(),
4932 "1.2.3.4".to_string(),
4933 vec!["prod".to_string(), "us-east".to_string()],
4934 )];
4935
4936 let result = sync_provider(
4937 &mut config,
4938 &MockProvider,
4939 &remote,
4940 §ion,
4941 false,
4942 false,
4943 false,
4944 );
4945 assert_eq!(result.updated, 1);
4947
4948 let entry = &config.host_entries()[0];
4949 let mut ptags = entry.provider_tags.clone();
4951 ptags.sort();
4952 assert_eq!(ptags, vec!["prod", "us-east"]);
4953
4954 assert_eq!(entry.tags, vec!["my-custom"]);
4956 }
4957
4958 #[test]
4959 fn test_sync_duplicate_user_provider_tag() {
4960 let mut config = empty_config();
4965 let section = make_section();
4966
4967 let remote = vec![ProviderHost::new(
4969 "123".to_string(),
4970 "web-1".to_string(),
4971 "1.2.3.4".to_string(),
4972 vec!["prod".to_string()],
4973 )];
4974 sync_provider(
4975 &mut config,
4976 &MockProvider,
4977 &remote,
4978 §ion,
4979 false,
4980 false,
4981 false,
4982 );
4983 assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
4984
4985 config.set_host_tags("do-web-1", &["prod".to_string(), "custom".to_string()]);
4987 assert_eq!(config.host_entries()[0].tags, vec!["prod", "custom"]);
4988
4989 sync_provider(
4991 &mut config,
4992 &MockProvider,
4993 &remote,
4994 §ion,
4995 false,
4996 false,
4997 false,
4998 );
4999
5000 let entry = &config.host_entries()[0];
5002 assert!(
5003 !entry.tags.contains(&"prod".to_string()),
5004 "User tag 'prod' should be cleaned since it duplicates a provider tag"
5005 );
5006 assert!(
5007 entry.tags.contains(&"custom".to_string()),
5008 "User tag 'custom' should be preserved"
5009 );
5010 assert_eq!(entry.provider_tags, vec!["prod"]);
5012 }
5013
5014 #[test]
5015 fn test_sync_set_provider_tags_empty_writes_sentinel() {
5016 let content = "\
5018Host do-web-1
5019 HostName 1.2.3.4
5020 # purple:provider_tags prod
5021 # purple:provider digitalocean:123
5022";
5023 let mut config = SshConfigFile {
5024 elements: SshConfigFile::parse_content(content),
5025 path: PathBuf::from("/tmp/test_config"),
5026 crlf: false,
5027 bom: false,
5028 };
5029
5030 config.set_host_provider_tags("do-web-1", &[]);
5032
5033 let serialized = config.serialize();
5034 assert!(
5035 serialized.contains("# purple:provider_tags"),
5036 "empty sentinel should exist. Got:\n{}",
5037 serialized
5038 );
5039 assert!(
5040 !serialized.contains("# purple:provider_tags "),
5041 "sentinel should have no trailing content. Got:\n{}",
5042 serialized
5043 );
5044 assert!(serialized.contains("Host do-web-1"));
5046 assert!(serialized.contains("purple:provider digitalocean:123"));
5047 }
5048
5049 #[test]
5050 fn test_sync_set_provider_does_not_clobber_provider_tags() {
5051 let content = "\
5053Host do-web-1
5054 HostName 1.2.3.4
5055 # purple:provider digitalocean:123
5056 # purple:provider_tags prod
5057";
5058 let mut config = SshConfigFile {
5059 elements: SshConfigFile::parse_content(content),
5060 path: PathBuf::from("/tmp/test_config"),
5061 crlf: false,
5062 bom: false,
5063 };
5064
5065 config.set_host_provider("do-web-1", "digitalocean", "456");
5067
5068 let serialized = config.serialize();
5069 assert!(
5070 serialized.contains("# purple:provider_tags prod"),
5071 "provider_tags should survive set_provider. Got:\n{}",
5072 serialized
5073 );
5074 assert!(
5075 serialized.contains("# purple:provider digitalocean:456"),
5076 "provider marker should be updated. Got:\n{}",
5077 serialized
5078 );
5079 }
5080
5081 #[test]
5082 fn test_sync_provider_tags_roundtrip() {
5083 let content = "\
5085Host do-web-1
5086 HostName 1.2.3.4
5087 User root
5088 # purple:provider_tags prod,us-east
5089 # purple:provider digitalocean:123
5090";
5091 let config = SshConfigFile {
5092 elements: SshConfigFile::parse_content(content),
5093 path: PathBuf::from("/tmp/test_config"),
5094 crlf: false,
5095 bom: false,
5096 };
5097
5098 let entries = config.host_entries();
5100 assert_eq!(entries.len(), 1);
5101 let mut ptags = entries[0].provider_tags.clone();
5102 ptags.sort();
5103 assert_eq!(ptags, vec!["prod", "us-east"]);
5104
5105 let serialized = config.serialize();
5107 let config2 = SshConfigFile {
5108 elements: SshConfigFile::parse_content(&serialized),
5109 path: PathBuf::from("/tmp/test_config"),
5110 crlf: false,
5111 bom: false,
5112 };
5113
5114 let entries2 = config2.host_entries();
5115 assert_eq!(entries2.len(), 1);
5116 let mut ptags2 = entries2[0].provider_tags.clone();
5117 ptags2.sort();
5118 assert_eq!(ptags2, vec!["prod", "us-east"]);
5119 }
5120
5121 #[test]
5122 fn test_sync_first_migration_empty_remote_writes_sentinel() {
5123 let mut config = SshConfigFile {
5125 elements: SshConfigFile::parse_content(
5126 "Host do-web-1\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n # purple:tags prod\n",
5127 ),
5128 path: PathBuf::from("/tmp/test_config"),
5129 crlf: false,
5130 bom: false,
5131 };
5132 let section = make_section();
5133
5134 let entries = config.host_entries();
5136 assert!(!entries[0].has_provider_tags);
5137 assert_eq!(entries[0].tags, vec!["prod"]);
5138
5139 let remote = vec![ProviderHost::new(
5141 "123".to_string(),
5142 "web-1".to_string(),
5143 "1.2.3.4".to_string(),
5144 Vec::new(),
5145 )];
5146 let result = sync_provider(
5147 &mut config,
5148 &MockProvider,
5149 &remote,
5150 §ion,
5151 false,
5152 false,
5153 false,
5154 );
5155 assert_eq!(result.updated, 1);
5156
5157 let entries = config.host_entries();
5159 assert!(entries[0].has_provider_tags);
5160 assert!(entries[0].provider_tags.is_empty());
5161 assert_eq!(entries[0].tags, vec!["prod"]);
5163
5164 let result2 = sync_provider(
5167 &mut config,
5168 &MockProvider,
5169 &remote,
5170 §ion,
5171 false,
5172 false,
5173 false,
5174 );
5175 assert_eq!(result2.unchanged, 1);
5176 }
5177
5178 #[test]
5183 fn test_sync_marks_stale_when_host_disappears() {
5184 let mut config = empty_config();
5185 let section = make_section();
5186 let remote = vec![ProviderHost::new(
5187 "1".to_string(),
5188 "web".to_string(),
5189 "1.2.3.4".to_string(),
5190 Vec::new(),
5191 )];
5192 sync_provider(
5193 &mut config,
5194 &MockProvider,
5195 &remote,
5196 §ion,
5197 false,
5198 false,
5199 false,
5200 );
5201 assert_eq!(config.host_entries().len(), 1);
5202
5203 let result = sync_provider(
5205 &mut config,
5206 &MockProvider,
5207 &[],
5208 §ion,
5209 false,
5210 false,
5211 false,
5212 );
5213 assert_eq!(result.stale, 1);
5214 assert_eq!(result.removed, 0);
5215 let entries = config.host_entries();
5216 assert_eq!(entries.len(), 1);
5217 assert!(entries[0].stale.is_some());
5218 }
5219
5220 #[test]
5221 fn test_sync_clears_stale_when_host_returns() {
5222 let mut config = empty_config();
5223 let section = make_section();
5224 let remote = vec![ProviderHost::new(
5225 "1".to_string(),
5226 "web".to_string(),
5227 "1.2.3.4".to_string(),
5228 Vec::new(),
5229 )];
5230 sync_provider(
5231 &mut config,
5232 &MockProvider,
5233 &remote,
5234 §ion,
5235 false,
5236 false,
5237 false,
5238 );
5239
5240 sync_provider(
5242 &mut config,
5243 &MockProvider,
5244 &[],
5245 §ion,
5246 false,
5247 false,
5248 false,
5249 );
5250 assert!(config.host_entries()[0].stale.is_some());
5251
5252 let result = sync_provider(
5254 &mut config,
5255 &MockProvider,
5256 &remote,
5257 §ion,
5258 false,
5259 false,
5260 false,
5261 );
5262 assert_eq!(result.updated, 1);
5263 assert!(config.host_entries()[0].stale.is_none());
5264 }
5265
5266 #[test]
5267 fn test_sync_stale_timestamp_preserved_not_refreshed() {
5268 let mut config = empty_config();
5269 let section = make_section();
5270 let remote = vec![ProviderHost::new(
5271 "1".to_string(),
5272 "web".to_string(),
5273 "1.2.3.4".to_string(),
5274 Vec::new(),
5275 )];
5276 sync_provider(
5277 &mut config,
5278 &MockProvider,
5279 &remote,
5280 §ion,
5281 false,
5282 false,
5283 false,
5284 );
5285
5286 sync_provider(
5288 &mut config,
5289 &MockProvider,
5290 &[],
5291 §ion,
5292 false,
5293 false,
5294 false,
5295 );
5296 let ts1 = config.host_entries()[0].stale.unwrap();
5297
5298 sync_provider(
5300 &mut config,
5301 &MockProvider,
5302 &[],
5303 §ion,
5304 false,
5305 false,
5306 false,
5307 );
5308 let ts2 = config.host_entries()[0].stale.unwrap();
5309 assert_eq!(ts1, ts2);
5310 }
5311
5312 #[test]
5313 fn test_sync_stale_host_returns_with_new_ip() {
5314 let mut config = empty_config();
5315 let section = make_section();
5316 let remote = vec![ProviderHost::new(
5317 "1".to_string(),
5318 "web".to_string(),
5319 "1.2.3.4".to_string(),
5320 Vec::new(),
5321 )];
5322 sync_provider(
5323 &mut config,
5324 &MockProvider,
5325 &remote,
5326 §ion,
5327 false,
5328 false,
5329 false,
5330 );
5331
5332 sync_provider(
5334 &mut config,
5335 &MockProvider,
5336 &[],
5337 §ion,
5338 false,
5339 false,
5340 false,
5341 );
5342
5343 let remote_new = vec![ProviderHost::new(
5345 "1".to_string(),
5346 "web".to_string(),
5347 "9.9.9.9".to_string(),
5348 Vec::new(),
5349 )];
5350 let result = sync_provider(
5351 &mut config,
5352 &MockProvider,
5353 &remote_new,
5354 §ion,
5355 false,
5356 false,
5357 false,
5358 );
5359 assert_eq!(result.updated, 1);
5360 let entries = config.host_entries();
5361 assert!(entries[0].stale.is_none());
5362 assert_eq!(entries[0].hostname, "9.9.9.9");
5363 }
5364
5365 #[test]
5366 fn test_sync_remove_deleted_still_hard_deletes() {
5367 let mut config = empty_config();
5368 let section = make_section();
5369 let remote = vec![ProviderHost::new(
5370 "1".to_string(),
5371 "web".to_string(),
5372 "1.2.3.4".to_string(),
5373 Vec::new(),
5374 )];
5375 sync_provider(
5376 &mut config,
5377 &MockProvider,
5378 &remote,
5379 §ion,
5380 false,
5381 false,
5382 false,
5383 );
5384
5385 let result = sync_provider(
5387 &mut config,
5388 &MockProvider,
5389 &[],
5390 §ion,
5391 true,
5392 false,
5393 false,
5394 );
5395 assert_eq!(result.removed, 1);
5396 assert_eq!(result.stale, 0);
5397 assert!(config.host_entries().is_empty());
5398 }
5399
5400 #[test]
5401 fn test_sync_partial_failure_no_stale_marking() {
5402 let mut config = empty_config();
5403 let section = make_section();
5404 let remote = vec![ProviderHost::new(
5405 "1".to_string(),
5406 "web".to_string(),
5407 "1.2.3.4".to_string(),
5408 Vec::new(),
5409 )];
5410 sync_provider(
5411 &mut config,
5412 &MockProvider,
5413 &remote,
5414 §ion,
5415 false,
5416 false,
5417 false,
5418 );
5419
5420 let result = sync_provider(
5422 &mut config,
5423 &MockProvider,
5424 &[],
5425 §ion,
5426 false,
5427 true,
5428 false,
5429 );
5430 assert_eq!(result.stale, 0);
5431 assert!(config.host_entries()[0].stale.is_none());
5432 }
5433
5434 #[test]
5435 fn test_sync_dry_run_reports_stale_count() {
5436 let mut config = empty_config();
5437 let section = make_section();
5438 let remote = vec![ProviderHost::new(
5439 "1".to_string(),
5440 "web".to_string(),
5441 "1.2.3.4".to_string(),
5442 Vec::new(),
5443 )];
5444 sync_provider(
5445 &mut config,
5446 &MockProvider,
5447 &remote,
5448 §ion,
5449 false,
5450 false,
5451 false,
5452 );
5453
5454 let result = sync_provider(
5456 &mut config,
5457 &MockProvider,
5458 &[],
5459 §ion,
5460 false,
5461 false,
5462 true,
5463 );
5464 assert_eq!(result.stale, 1);
5465 assert!(config.host_entries()[0].stale.is_none()); }
5467
5468 #[test]
5469 fn test_sync_top_level_host_marked_stale() {
5470 let config_str = "\
5472Host do-web
5473 HostName 1.2.3.4
5474 # purple:provider digitalocean:1
5475";
5476 let mut config = SshConfigFile {
5477 elements: SshConfigFile::parse_content(config_str),
5478 path: PathBuf::from("/tmp/test_config"),
5479 crlf: false,
5480 bom: false,
5481 };
5482 let section = make_section();
5483 let result = sync_provider(
5484 &mut config,
5485 &MockProvider,
5486 &[],
5487 §ion,
5488 false,
5489 false,
5490 false,
5491 );
5492 assert_eq!(result.stale, 1);
5493 }
5494
5495 #[test]
5496 fn test_sync_multiple_hosts_disappear() {
5497 let mut config = empty_config();
5498 let section = make_section();
5499 let remote = vec![
5500 ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5501 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5502 ProviderHost::new("3".into(), "app".into(), "3.3.3.3".into(), Vec::new()),
5503 ];
5504 sync_provider(
5505 &mut config,
5506 &MockProvider,
5507 &remote,
5508 §ion,
5509 false,
5510 false,
5511 false,
5512 );
5513 assert_eq!(config.host_entries().len(), 3);
5514
5515 let remaining = vec![ProviderHost::new(
5517 "2".into(),
5518 "db".into(),
5519 "2.2.2.2".into(),
5520 Vec::new(),
5521 )];
5522 let result = sync_provider(
5523 &mut config,
5524 &MockProvider,
5525 &remaining,
5526 §ion,
5527 false,
5528 false,
5529 false,
5530 );
5531 assert_eq!(result.stale, 2);
5532 assert_eq!(result.unchanged, 1);
5533 let entries = config.host_entries();
5534 assert!(
5535 entries
5536 .iter()
5537 .find(|e| e.alias == "do-web")
5538 .unwrap()
5539 .stale
5540 .is_some()
5541 );
5542 assert!(
5543 entries
5544 .iter()
5545 .find(|e| e.alias == "do-db")
5546 .unwrap()
5547 .stale
5548 .is_none()
5549 );
5550 assert!(
5551 entries
5552 .iter()
5553 .find(|e| e.alias == "do-app")
5554 .unwrap()
5555 .stale
5556 .is_some()
5557 );
5558 }
5559
5560 #[test]
5561 fn test_sync_already_stale_then_remove_deleted() {
5562 let mut config = empty_config();
5563 let section = make_section();
5564 let remote = vec![ProviderHost::new(
5565 "1".into(),
5566 "web".into(),
5567 "1.1.1.1".into(),
5568 Vec::new(),
5569 )];
5570 sync_provider(
5571 &mut config,
5572 &MockProvider,
5573 &remote,
5574 §ion,
5575 false,
5576 false,
5577 false,
5578 );
5579
5580 sync_provider(
5582 &mut config,
5583 &MockProvider,
5584 &[],
5585 §ion,
5586 false,
5587 false,
5588 false,
5589 );
5590 assert!(config.host_entries()[0].stale.is_some());
5591
5592 let result = sync_provider(
5594 &mut config,
5595 &MockProvider,
5596 &[],
5597 §ion,
5598 true,
5599 false,
5600 false,
5601 );
5602 assert_eq!(result.removed, 1);
5603 assert!(config.host_entries().is_empty());
5604 }
5605
5606 #[test]
5607 fn test_sync_stale_cross_provider_isolation() {
5608 let mut config = empty_config();
5609 let do_section = make_section();
5610 let vultr_section = ProviderSection {
5611 alias_prefix: "vultr".to_string(),
5612 ..make_section()
5613 };
5614
5615 let do_remote = vec![ProviderHost::new(
5617 "1".into(),
5618 "web".into(),
5619 "1.1.1.1".into(),
5620 Vec::new(),
5621 )];
5622 sync_provider(
5623 &mut config,
5624 &MockProvider,
5625 &do_remote,
5626 &do_section,
5627 false,
5628 false,
5629 false,
5630 );
5631
5632 let vultr_remote = vec![ProviderHost::new(
5634 "1".into(),
5635 "db".into(),
5636 "2.2.2.2".into(),
5637 Vec::new(),
5638 )];
5639 sync_provider(
5640 &mut config,
5641 &MockProvider2,
5642 &vultr_remote,
5643 &vultr_section,
5644 false,
5645 false,
5646 false,
5647 );
5648
5649 let result = sync_provider(
5651 &mut config,
5652 &MockProvider,
5653 &[],
5654 &do_section,
5655 false,
5656 false,
5657 false,
5658 );
5659 assert_eq!(result.stale, 1);
5660 let entries = config.host_entries();
5661 assert!(
5662 entries
5663 .iter()
5664 .find(|e| e.alias == "do-web")
5665 .unwrap()
5666 .stale
5667 .is_some()
5668 );
5669 assert!(
5670 entries
5671 .iter()
5672 .find(|e| e.alias == "vultr-db")
5673 .unwrap()
5674 .stale
5675 .is_none()
5676 );
5677 }
5678
5679 #[test]
5680 fn test_sync_stale_host_returns_with_tag_changes() {
5681 let mut config = empty_config();
5682 let section = make_section();
5683 let remote = vec![ProviderHost::new(
5684 "1".into(),
5685 "web".into(),
5686 "1.1.1.1".into(),
5687 vec!["prod".into()],
5688 )];
5689 sync_provider(
5690 &mut config,
5691 &MockProvider,
5692 &remote,
5693 §ion,
5694 false,
5695 false,
5696 false,
5697 );
5698
5699 sync_provider(
5701 &mut config,
5702 &MockProvider,
5703 &[],
5704 §ion,
5705 false,
5706 false,
5707 false,
5708 );
5709 assert!(config.host_entries()[0].stale.is_some());
5710
5711 let remote_new = vec![ProviderHost::new(
5713 "1".into(),
5714 "web".into(),
5715 "1.1.1.1".into(),
5716 vec!["staging".into()],
5717 )];
5718 let result = sync_provider(
5719 &mut config,
5720 &MockProvider,
5721 &remote_new,
5722 §ion,
5723 false,
5724 false,
5725 false,
5726 );
5727 assert_eq!(result.updated, 1);
5728 let entries = config.host_entries();
5729 assert!(entries[0].stale.is_none());
5730 assert!(entries[0].provider_tags.contains(&"staging".to_string()));
5731 }
5732
5733 #[test]
5734 fn test_sync_stale_result_count_includes_already_stale() {
5735 let mut config = empty_config();
5736 let section = make_section();
5737 let remote = vec![ProviderHost::new(
5738 "1".into(),
5739 "web".into(),
5740 "1.2.3.4".into(),
5741 Vec::new(),
5742 )];
5743 sync_provider(
5744 &mut config,
5745 &MockProvider,
5746 &remote,
5747 §ion,
5748 false,
5749 false,
5750 false,
5751 );
5752
5753 let r1 = sync_provider(
5755 &mut config,
5756 &MockProvider,
5757 &[],
5758 §ion,
5759 false,
5760 false,
5761 false,
5762 );
5763 assert_eq!(r1.stale, 1);
5764
5765 let r2 = sync_provider(
5767 &mut config,
5768 &MockProvider,
5769 &[],
5770 §ion,
5771 false,
5772 false,
5773 false,
5774 );
5775 assert_eq!(r2.stale, 1);
5776 }
5777
5778 #[test]
5783 fn test_sync_stale_config_byte_identical_after_clear() {
5784 let mut config = empty_config();
5785 let section = make_section();
5786 let remote = vec![
5787 ProviderHost::new(
5788 "1".into(),
5789 "web".into(),
5790 "1.1.1.1".into(),
5791 vec!["prod".into()],
5792 ),
5793 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5794 ];
5795 sync_provider(
5797 &mut config,
5798 &MockProvider,
5799 &remote,
5800 §ion,
5801 false,
5802 false,
5803 false,
5804 );
5805 let config_after_add = config.serialize();
5806
5807 sync_provider(
5809 &mut config,
5810 &MockProvider,
5811 &[],
5812 §ion,
5813 false,
5814 false,
5815 false,
5816 );
5817 let config_after_stale = config.serialize();
5818 assert_ne!(config_after_stale, config_after_add);
5819
5820 sync_provider(
5822 &mut config,
5823 &MockProvider,
5824 &remote,
5825 §ion,
5826 false,
5827 false,
5828 false,
5829 );
5830 let config_after_return = config.serialize();
5831 assert_eq!(
5832 config_after_return, config_after_add,
5833 "config must be byte-identical after stale->return cycle"
5834 );
5835 }
5836
5837 #[test]
5838 fn test_sync_stale_preserves_neighboring_hosts() {
5839 let config_str = "\
5840Host manual
5841 HostName 10.0.0.1
5842 User admin
5843
5844";
5845 let mut config = SshConfigFile {
5846 elements: SshConfigFile::parse_content(config_str),
5847 path: PathBuf::from("/tmp/test_config"),
5848 crlf: false,
5849 bom: false,
5850 };
5851 let section = make_section();
5852 let remote = vec![ProviderHost::new(
5853 "1".into(),
5854 "web".into(),
5855 "1.1.1.1".into(),
5856 Vec::new(),
5857 )];
5858 sync_provider(
5859 &mut config,
5860 &MockProvider,
5861 &remote,
5862 §ion,
5863 false,
5864 false,
5865 false,
5866 );
5867
5868 sync_provider(
5870 &mut config,
5871 &MockProvider,
5872 &[],
5873 §ion,
5874 false,
5875 false,
5876 false,
5877 );
5878 let output = config.serialize();
5879 assert!(
5880 output.contains("Host manual"),
5881 "manual host lost after stale marking"
5882 );
5883 assert!(
5884 output.contains("HostName 10.0.0.1"),
5885 "manual host directives lost after stale marking"
5886 );
5887 assert!(
5888 output.contains("User admin"),
5889 "manual host user lost after stale marking"
5890 );
5891 }
5892
5893 #[test]
5894 fn test_sync_stale_then_purge_leaves_clean_config() {
5895 let config_str = "\
5896Host manual
5897 HostName 10.0.0.1
5898 User admin
5899
5900";
5901 let mut config = SshConfigFile {
5902 elements: SshConfigFile::parse_content(config_str),
5903 path: PathBuf::from("/tmp/test_config"),
5904 crlf: false,
5905 bom: false,
5906 };
5907 let section = make_section();
5908 let remote = vec![
5909 ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
5910 ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
5911 ];
5912 sync_provider(
5913 &mut config,
5914 &MockProvider,
5915 &remote,
5916 §ion,
5917 false,
5918 false,
5919 false,
5920 );
5921
5922 sync_provider(
5924 &mut config,
5925 &MockProvider,
5926 &[],
5927 §ion,
5928 false,
5929 false,
5930 false,
5931 );
5932
5933 let stale = config.stale_hosts();
5935 for (alias, _) in &stale {
5936 config.delete_host(alias);
5937 }
5938
5939 let output = config.serialize();
5940 assert!(output.contains("Host manual"));
5942 assert!(output.contains("HostName 10.0.0.1"));
5943 assert!(!output.contains("purple:stale"));
5945 assert!(!output.contains("purple:group"));
5947 assert!(
5949 !output.contains("\n\n\n"),
5950 "excessive blank lines after purge:\n{}",
5951 output
5952 );
5953 }
5954
5955 #[test]
5956 fn test_sync_stale_empty_ip_return_preserves_hostname() {
5957 let mut config = empty_config();
5958 let section = make_section();
5959 let remote = vec![ProviderHost::new(
5960 "1".into(),
5961 "web".into(),
5962 "1.1.1.1".into(),
5963 Vec::new(),
5964 )];
5965 sync_provider(
5966 &mut config,
5967 &MockProvider,
5968 &remote,
5969 §ion,
5970 false,
5971 false,
5972 false,
5973 );
5974
5975 sync_provider(
5977 &mut config,
5978 &MockProvider,
5979 &[],
5980 §ion,
5981 false,
5982 false,
5983 false,
5984 );
5985 assert!(config.host_entries()[0].stale.is_some());
5986
5987 let remote_empty_ip = vec![ProviderHost::new(
5989 "1".into(),
5990 "web".into(),
5991 "".into(),
5992 Vec::new(),
5993 )];
5994 let result = sync_provider(
5995 &mut config,
5996 &MockProvider,
5997 &remote_empty_ip,
5998 §ion,
5999 false,
6000 false,
6001 false,
6002 );
6003 assert_eq!(result.updated, 1);
6004 assert!(config.host_entries()[0].stale.is_none());
6006 assert_eq!(config.host_entries()[0].hostname, "1.1.1.1");
6008 }
6009
6010 #[test]
6011 fn test_sync_insert_adds_blank_line_before_next_group() {
6012 let config_str = "\
6015# purple:group DigitalOcean
6016
6017Host do-web
6018 HostName 1.1.1.1
6019 User root
6020 # purple:provider digitalocean:111
6021
6022# purple:group Hetzner
6023
6024Host hz-build
6025 HostName 2.2.2.2
6026 User ci
6027 # purple:provider hetzner:222
6028";
6029 let mut config = SshConfigFile {
6030 elements: SshConfigFile::parse_content(config_str),
6031 path: PathBuf::from("/tmp/test_config"),
6032 crlf: false,
6033 bom: false,
6034 };
6035 let section = make_section();
6036 let remote = vec![
6037 ProviderHost::new("111".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
6038 ProviderHost::new("333".into(), "db".into(), "3.3.3.3".into(), Vec::new()),
6039 ];
6040 sync_provider(
6041 &mut config,
6042 &MockProvider,
6043 &remote,
6044 §ion,
6045 false,
6046 false,
6047 false,
6048 );
6049 let output = config.serialize();
6050 assert!(
6052 output.contains("\n\n# purple:group Hetzner"),
6053 "missing blank line before next group header:\n{}",
6054 output
6055 );
6056 assert!(
6058 !output.contains("\n\n\n"),
6059 "triple blank lines found:\n{}",
6060 output
6061 );
6062 }
6063
6064 #[test]
6065 fn test_sync_insert_blank_line_real_world_scenario() {
6066 let config_str = "\
6070# purple:group DigitalOcean
6071
6072Host do-signalproxy
6073 HostName 128.199.41.235
6074 User root
6075 IdentityFile ~/.ssh/id_ed25519
6076 # purple:provider digitalocean:517532225
6077 # purple:meta region=ams3,size=s-1vcpu-512mb-10gb,status=active
6078 Port 60022
6079 # purple:provider_tags
6080 # purple:tags signal
6081# purple:group Proxmox VE
6082
6083Host pve-testvm
6084 HostName 192.168.1.100
6085 User root
6086 # purple:provider proxmox:100
6087";
6088 let mut config = SshConfigFile {
6089 elements: SshConfigFile::parse_content(config_str),
6090 path: PathBuf::from("/tmp/test_config"),
6091 crlf: false,
6092 bom: false,
6093 };
6094 let section = make_section();
6095 let remote = vec![
6097 ProviderHost::new(
6098 "517532225".into(),
6099 "signalproxy-nl".into(),
6100 "128.199.41.235".into(),
6101 Vec::new(),
6102 ),
6103 ProviderHost::new(
6104 "560734563".into(),
6105 "ubuntu-nyc1".into(),
6106 "167.172.128.123".into(),
6107 Vec::new(),
6108 ),
6109 ];
6110 sync_provider(
6111 &mut config,
6112 &MockProvider,
6113 &remote,
6114 §ion,
6115 false,
6116 false,
6117 false,
6118 );
6119 let output = config.serialize();
6120
6121 assert!(
6123 output.contains("\n\n# purple:group Proxmox VE"),
6124 "missing blank line before Proxmox group:\n{}",
6125 output
6126 );
6127 assert!(
6129 output.contains("Host do-signalproxy") || output.contains("Host do-signalproxy-nl")
6130 );
6131 assert!(output.contains("Host do-ubuntu-nyc1"));
6132 assert!(output.contains("Host pve-testvm"));
6134 assert!(
6136 !output.contains("\n\n\n"),
6137 "triple blank lines:\n{}",
6138 output
6139 );
6140 }
6141}