1use std::collections::HashMap;
2
3use crate::ssh_config::model::{ConfigElement, HostEntry, SshConfigFile};
4
5use super::config::ProviderSection;
6use super::{Provider, ProviderHost};
7
8#[derive(Debug, Default)]
10pub struct SyncResult {
11 pub added: usize,
12 pub updated: usize,
13 pub removed: usize,
14 pub unchanged: usize,
15 pub renames: Vec<(String, String)>,
17}
18
19fn sanitize_name(name: &str) -> String {
23 let mut result = String::new();
24 for c in name.chars() {
25 if c.is_ascii_alphanumeric() {
26 result.push(c.to_ascii_lowercase());
27 } else if !result.ends_with('-') {
28 result.push('-');
29 }
30 }
31 let trimmed = result.trim_matches('-').to_string();
32 if trimmed.is_empty() {
33 "server".to_string()
34 } else {
35 trimmed
36 }
37}
38
39fn build_alias(prefix: &str, sanitized: &str) -> String {
42 if prefix.is_empty() {
43 sanitized.to_string()
44 } else {
45 format!("{}-{}", prefix, sanitized)
46 }
47}
48
49fn is_volatile_meta(key: &str) -> bool {
54 key == "status"
55}
56
57pub fn sync_provider(
59 config: &mut SshConfigFile,
60 provider: &dyn Provider,
61 remote_hosts: &[ProviderHost],
62 section: &ProviderSection,
63 remove_deleted: bool,
64 dry_run: bool,
65) -> SyncResult {
66 sync_provider_with_options(
67 config,
68 provider,
69 remote_hosts,
70 section,
71 remove_deleted,
72 dry_run,
73 false,
74 )
75}
76
77pub fn sync_provider_with_options(
81 config: &mut SshConfigFile,
82 provider: &dyn Provider,
83 remote_hosts: &[ProviderHost],
84 section: &ProviderSection,
85 remove_deleted: bool,
86 dry_run: bool,
87 reset_tags: bool,
88) -> SyncResult {
89 let mut result = SyncResult::default();
90
91 let existing = config.find_hosts_by_provider(provider.name());
94 let mut existing_map: HashMap<String, String> = HashMap::new();
95 for (alias, server_id) in &existing {
96 existing_map
97 .entry(server_id.clone())
98 .or_insert_with(|| alias.clone());
99 }
100
101 let entries_map: HashMap<String, HostEntry> = config
103 .host_entries()
104 .into_iter()
105 .map(|e| (e.alias.clone(), e))
106 .collect();
107
108 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
110
111 let mut needs_header = !dry_run && existing_map.is_empty();
113
114 for remote in remote_hosts {
115 if !remote_ids.insert(remote.server_id.clone()) {
116 continue; }
118
119 if remote.ip.is_empty() {
123 if existing_map.contains_key(&remote.server_id) {
124 result.unchanged += 1;
125 }
126 continue;
127 }
128
129 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
130 if let Some(entry) = entries_map.get(existing_alias) {
132 if entry.source_file.is_some() {
134 result.unchanged += 1;
135 continue;
136 }
137
138 let sanitized = sanitize_name(&remote.name);
140 let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
141 let alias_changed = *existing_alias != expected_alias;
142
143 let ip_changed = entry.hostname != remote.ip;
144 let meta_changed = {
145 let mut local: Vec<(&str, &str)> = entry
146 .provider_meta
147 .iter()
148 .filter(|(k, _)| !is_volatile_meta(k))
149 .map(|(k, v)| (k.as_str(), v.as_str()))
150 .collect();
151 local.sort();
152 let mut remote_m: Vec<(&str, &str)> = remote
153 .metadata
154 .iter()
155 .filter(|(k, _)| !is_volatile_meta(k))
156 .map(|(k, v)| (k.as_str(), v.as_str()))
157 .collect();
158 remote_m.sort();
159 local != remote_m
160 };
161 let trimmed_remote: Vec<String> =
162 remote.tags.iter().map(|t| t.trim().to_string()).collect();
163 let tags_changed = if reset_tags {
164 let mut sorted_local: Vec<String> =
166 entry.tags.iter().map(|t| t.to_lowercase()).collect();
167 sorted_local.sort();
168 let mut sorted_remote: Vec<String> =
169 trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
170 sorted_remote.sort();
171 sorted_local != sorted_remote
172 } else {
173 trimmed_remote.iter().any(|rt| {
175 !entry
176 .tags
177 .iter()
178 .any(|lt| lt.eq_ignore_ascii_case(rt))
179 })
180 };
181 if alias_changed || ip_changed || tags_changed || meta_changed {
182 if dry_run {
183 result.updated += 1;
184 } else {
185 let new_alias = if alias_changed {
188 config.deduplicate_alias_excluding(
189 &expected_alias,
190 Some(existing_alias),
191 )
192 } else {
193 existing_alias.clone()
194 };
195 let alias_changed = new_alias != *existing_alias;
197
198 if alias_changed || ip_changed || tags_changed || meta_changed {
199 if alias_changed || ip_changed {
200 let updated = HostEntry {
201 alias: new_alias.clone(),
202 hostname: remote.ip.clone(),
203 ..entry.clone()
204 };
205 config.update_host(existing_alias, &updated);
206 }
207 let tags_alias =
209 if alias_changed { &new_alias } else { existing_alias };
210 if tags_changed {
211 if reset_tags {
212 config.set_host_tags(tags_alias, &trimmed_remote);
213 } else {
214 let mut merged = entry.tags.clone();
216 for rt in &trimmed_remote {
217 if !merged.iter().any(|t| t.eq_ignore_ascii_case(rt)) {
218 merged.push(rt.clone());
219 }
220 }
221 config.set_host_tags(tags_alias, &merged);
222 }
223 }
224 if alias_changed {
226 config.set_host_provider(
227 &new_alias,
228 provider.name(),
229 &remote.server_id,
230 );
231 result.renames.push((existing_alias.clone(), new_alias.clone()));
232 }
233 if meta_changed {
235 config.set_host_meta(tags_alias, &remote.metadata);
236 }
237 result.updated += 1;
238 } else {
239 result.unchanged += 1;
240 }
241 }
242 } else {
243 result.unchanged += 1;
244 }
245 } else {
246 result.unchanged += 1;
247 }
248 } else {
249 let sanitized = sanitize_name(&remote.name);
251 let base_alias = build_alias(§ion.alias_prefix, &sanitized);
252 let alias = if dry_run {
253 base_alias
254 } else {
255 config.deduplicate_alias(&base_alias)
256 };
257
258 if !dry_run {
259 let wrote_header = needs_header;
261 if needs_header {
262 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
263 config
264 .elements
265 .push(ConfigElement::GlobalLine(String::new()));
266 }
267 config
268 .elements
269 .push(ConfigElement::GlobalLine(format!(
270 "# purple:group {}",
271 super::provider_display_name(provider.name())
272 )));
273 needs_header = false;
274 }
275
276 let entry = HostEntry {
277 alias: alias.clone(),
278 hostname: remote.ip.clone(),
279 user: section.user.clone(),
280 identity_file: section.identity_file.clone(),
281 tags: remote.tags.clone(),
282 provider: Some(provider.name().to_string()),
283 ..Default::default()
284 };
285
286 if !wrote_header
289 && !config.elements.is_empty()
290 && !config.last_element_has_trailing_blank()
291 {
292 config
293 .elements
294 .push(ConfigElement::GlobalLine(String::new()));
295 }
296
297 let block = SshConfigFile::entry_to_block(&entry);
298 config.elements.push(ConfigElement::HostBlock(block));
299 config.set_host_provider(&alias, provider.name(), &remote.server_id);
300 if !remote.tags.is_empty() {
301 config.set_host_tags(&alias, &remote.tags);
302 }
303 if !remote.metadata.is_empty() {
304 config.set_host_meta(&alias, &remote.metadata);
305 }
306 }
307
308 result.added += 1;
309 }
310 }
311
312 if remove_deleted && !dry_run {
314 let to_remove: Vec<String> = existing_map
315 .iter()
316 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
317 .filter(|(_, alias)| {
318 entries_map
319 .get(alias.as_str())
320 .is_none_or(|e| e.source_file.is_none())
321 })
322 .map(|(_, alias)| alias.clone())
323 .collect();
324 for alias in &to_remove {
325 config.delete_host(alias);
326 }
327 result.removed = to_remove.len();
328
329 if config.find_hosts_by_provider(provider.name()).is_empty() {
331 let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
332 config
333 .elements
334 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
335 }
336 } else if remove_deleted {
337 result.removed = existing_map
338 .iter()
339 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
340 .filter(|(_, alias)| {
341 entries_map
342 .get(alias.as_str())
343 .is_none_or(|e| e.source_file.is_none())
344 })
345 .count();
346 }
347
348 result
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use std::path::PathBuf;
355
356 fn empty_config() -> SshConfigFile {
357 SshConfigFile {
358 elements: Vec::new(),
359 path: PathBuf::from("/tmp/test_config"),
360 crlf: false,
361 }
362 }
363
364 fn make_section() -> ProviderSection {
365 ProviderSection {
366 provider: "digitalocean".to_string(),
367 token: "test".to_string(),
368 alias_prefix: "do".to_string(),
369 user: "root".to_string(),
370 identity_file: String::new(),
371 url: String::new(),
372 verify_tls: true,
373 auto_sync: true,
374 profile: String::new(),
375 regions: String::new(),
376 project: String::new(),
377 }
378 }
379
380 struct MockProvider;
381 impl Provider for MockProvider {
382 fn name(&self) -> &str {
383 "digitalocean"
384 }
385 fn short_label(&self) -> &str {
386 "do"
387 }
388 fn fetch_hosts_cancellable(
389 &self,
390 _token: &str,
391 _cancel: &std::sync::atomic::AtomicBool,
392 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
393 Ok(Vec::new())
394 }
395 }
396
397 #[test]
398 fn test_build_alias() {
399 assert_eq!(build_alias("do", "web-1"), "do-web-1");
400 assert_eq!(build_alias("", "web-1"), "web-1");
401 assert_eq!(build_alias("ocean", "db"), "ocean-db");
402 }
403
404 #[test]
405 fn test_sanitize_name() {
406 assert_eq!(sanitize_name("web-1"), "web-1");
407 assert_eq!(sanitize_name("My Server"), "my-server");
408 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
409 assert_eq!(sanitize_name("--weird--"), "weird");
410 assert_eq!(sanitize_name("UPPER"), "upper");
411 assert_eq!(sanitize_name("a--b"), "a-b");
412 assert_eq!(sanitize_name(""), "server");
413 assert_eq!(sanitize_name("..."), "server");
414 }
415
416 #[test]
417 fn test_sync_adds_new_hosts() {
418 let mut config = empty_config();
419 let section = make_section();
420 let remote = vec![
421 ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
422 ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
423 ];
424
425 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
426 assert_eq!(result.added, 2);
427 assert_eq!(result.updated, 0);
428 assert_eq!(result.unchanged, 0);
429
430 let entries = config.host_entries();
431 assert_eq!(entries.len(), 2);
432 assert_eq!(entries[0].alias, "do-web-1");
433 assert_eq!(entries[0].hostname, "1.2.3.4");
434 assert_eq!(entries[1].alias, "do-db-1");
435 }
436
437 #[test]
438 fn test_sync_updates_changed_ip() {
439 let mut config = empty_config();
440 let section = make_section();
441
442 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
444 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
445
446 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.8.7.6".to_string(), Vec::new())];
448 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
449 assert_eq!(result.updated, 1);
450 assert_eq!(result.added, 0);
451
452 let entries = config.host_entries();
453 assert_eq!(entries[0].hostname, "9.8.7.6");
454 }
455
456 #[test]
457 fn test_sync_unchanged() {
458 let mut config = empty_config();
459 let section = make_section();
460
461 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
462 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
463
464 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
466 assert_eq!(result.unchanged, 1);
467 assert_eq!(result.added, 0);
468 assert_eq!(result.updated, 0);
469 }
470
471 #[test]
472 fn test_sync_removes_deleted() {
473 let mut config = empty_config();
474 let section = make_section();
475
476 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
477 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
478 assert_eq!(config.host_entries().len(), 1);
479
480 let result =
482 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
483 assert_eq!(result.removed, 1);
484 assert_eq!(config.host_entries().len(), 0);
485 }
486
487 #[test]
488 fn test_sync_dry_run_no_mutations() {
489 let mut config = empty_config();
490 let section = make_section();
491
492 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
493
494 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
495 assert_eq!(result.added, 1);
496 assert_eq!(config.host_entries().len(), 0); }
498
499 #[test]
500 fn test_sync_dedup_server_id_in_response() {
501 let mut config = empty_config();
502 let section = make_section();
503 let remote = vec![
504 ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
505 ProviderHost::new("123".to_string(), "web-1-dup".to_string(), "5.6.7.8".to_string(), Vec::new()),
506 ];
507
508 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
509 assert_eq!(result.added, 1);
510 assert_eq!(config.host_entries().len(), 1);
511 assert_eq!(config.host_entries()[0].alias, "do-web-1");
512 }
513
514 #[test]
515 fn test_sync_duplicate_local_server_id_keeps_first() {
516 let content = "\
518Host do-web-1
519 HostName 1.2.3.4
520 # purple:provider digitalocean:123
521
522Host do-web-1-copy
523 HostName 1.2.3.4
524 # purple:provider digitalocean:123
525";
526 let mut config = SshConfigFile {
527 elements: SshConfigFile::parse_content(content),
528 path: PathBuf::from("/tmp/test_config"),
529 crlf: false,
530 };
531 let section = make_section();
532
533 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "5.6.7.8".to_string(), Vec::new())];
535
536 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
537 assert_eq!(result.updated, 1);
539 assert_eq!(result.added, 0);
540 let entries = config.host_entries();
541 let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
542 assert_eq!(first.hostname, "5.6.7.8");
543 let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
545 assert_eq!(copy.hostname, "1.2.3.4");
546 }
547
548 #[test]
549 fn test_sync_no_duplicate_header_on_repeated_sync() {
550 let mut config = empty_config();
551 let section = make_section();
552
553 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
555 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
556
557 let remote = vec![
559 ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
560 ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
561 ];
562 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
563
564 let header_count = config
566 .elements
567 .iter()
568 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
569 .count();
570 assert_eq!(header_count, 1);
571 assert_eq!(config.host_entries().len(), 2);
572 }
573
574 #[test]
575 fn test_sync_removes_orphan_header() {
576 let mut config = empty_config();
577 let section = make_section();
578
579 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
581 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
582
583 let has_header = config
585 .elements
586 .iter()
587 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
588 assert!(has_header);
589
590 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
592 assert_eq!(result.removed, 1);
593
594 let has_header = config
596 .elements
597 .iter()
598 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
599 assert!(!has_header);
600 }
601
602 #[test]
603 fn test_sync_writes_provider_tags() {
604 let mut config = empty_config();
605 let section = make_section();
606 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
607
608 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
609
610 let entries = config.host_entries();
611 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
612 }
613
614 #[test]
615 fn test_sync_updates_changed_tags() {
616 let mut config = empty_config();
617 let section = make_section();
618
619 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
621 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
622 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
623
624 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
626 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
627 assert_eq!(result.updated, 1);
628 assert_eq!(
629 config.host_entries()[0].tags,
630 vec!["staging", "production", "us-east"]
631 );
632 }
633
634 #[test]
635 fn test_sync_combined_add_update_remove() {
636 let mut config = empty_config();
637 let section = make_section();
638
639 let remote = vec![
641 ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
642 ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
643 ];
644 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
645 assert_eq!(config.host_entries().len(), 2);
646
647 let remote = vec![
649 ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new()),
650 ProviderHost::new("3".to_string(), "cache".to_string(), "3.3.3.3".to_string(), Vec::new()),
651 ];
652 let result =
653 sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
654 assert_eq!(result.updated, 1);
655 assert_eq!(result.added, 1);
656 assert_eq!(result.removed, 1);
657
658 let entries = config.host_entries();
659 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
661 assert_eq!(entries[0].hostname, "9.9.9.9");
662 assert_eq!(entries[1].alias, "do-cache");
663 }
664
665 #[test]
666 fn test_sync_tag_order_insensitive() {
667 let mut config = empty_config();
668 let section = make_section();
669
670 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["beta".to_string(), "alpha".to_string()])];
672 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
673
674 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["alpha".to_string(), "beta".to_string()])];
676 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
677 assert_eq!(result.unchanged, 1);
678 assert_eq!(result.updated, 0);
679 }
680
681 fn config_with_include_provider_host() -> SshConfigFile {
682 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
683
684 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
686 let included_elements = SshConfigFile::parse_content(content);
687
688 SshConfigFile {
689 elements: vec![ConfigElement::Include(IncludeDirective {
690 raw_line: "Include conf.d/*".to_string(),
691 pattern: "conf.d/*".to_string(),
692 resolved_files: vec![IncludedFile {
693 path: PathBuf::from("/tmp/included.conf"),
694 elements: included_elements,
695 }],
696 })],
697 path: PathBuf::from("/tmp/test_config"),
698 crlf: false,
699 }
700 }
701
702 #[test]
703 fn test_sync_include_host_skips_update() {
704 let mut config = config_with_include_provider_host();
705 let section = make_section();
706
707 let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "9.9.9.9".to_string(), Vec::new())];
709 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
710 assert_eq!(result.unchanged, 1);
711 assert_eq!(result.updated, 0);
712 assert_eq!(result.added, 0);
713
714 let entries = config.host_entries();
716 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
717 assert_eq!(included.hostname, "1.2.3.4");
718 }
719
720 #[test]
721 fn test_sync_include_host_skips_remove() {
722 let mut config = config_with_include_provider_host();
723 let section = make_section();
724
725 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
727 assert_eq!(result.removed, 0);
728 assert_eq!(config.host_entries().len(), 1);
729 }
730
731 #[test]
732 fn test_sync_dry_run_remove_count() {
733 let mut config = empty_config();
734 let section = make_section();
735
736 let remote = vec![
738 ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
739 ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
740 ];
741 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
742 assert_eq!(config.host_entries().len(), 2);
743
744 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
746 assert_eq!(result.removed, 2);
747 assert_eq!(config.host_entries().len(), 2); }
749
750 #[test]
751 fn test_sync_tags_cleared_remotely_preserved_locally() {
752 let mut config = empty_config();
753 let section = make_section();
754
755 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
757 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
758 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
759
760 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
762 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
763 assert_eq!(result.unchanged, 1);
764 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
765 }
766
767 #[test]
768 fn test_sync_deduplicates_alias() {
769 let content = "Host do-web-1\n HostName 10.0.0.1\n";
770 let mut config = SshConfigFile {
771 elements: SshConfigFile::parse_content(content),
772 path: PathBuf::from("/tmp/test_config"),
773 crlf: false,
774 };
775 let section = make_section();
776
777 let remote = vec![ProviderHost::new("999".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
778
779 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
780
781 let entries = config.host_entries();
782 assert_eq!(entries.len(), 2);
784 assert_eq!(entries[0].alias, "do-web-1");
785 assert_eq!(entries[1].alias, "do-web-1-2");
786 }
787
788 #[test]
789 fn test_sync_renames_on_prefix_change() {
790 let mut config = empty_config();
791 let section = make_section(); let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
795 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
796 assert_eq!(config.host_entries()[0].alias, "do-web-1");
797
798 let new_section = ProviderSection {
800 alias_prefix: "ocean".to_string(),
801 ..section
802 };
803 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
804 assert_eq!(result.updated, 1);
805 assert_eq!(result.unchanged, 0);
806
807 let entries = config.host_entries();
808 assert_eq!(entries.len(), 1);
809 assert_eq!(entries[0].alias, "ocean-web-1");
810 assert_eq!(entries[0].hostname, "1.2.3.4");
811 }
812
813 #[test]
814 fn test_sync_rename_and_ip_change() {
815 let mut config = empty_config();
816 let section = make_section();
817
818 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
819 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
820
821 let new_section = ProviderSection {
823 alias_prefix: "ocean".to_string(),
824 ..section
825 };
826 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.9.9.9".to_string(), Vec::new())];
827 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
828 assert_eq!(result.updated, 1);
829
830 let entries = config.host_entries();
831 assert_eq!(entries[0].alias, "ocean-web-1");
832 assert_eq!(entries[0].hostname, "9.9.9.9");
833 }
834
835 #[test]
836 fn test_sync_rename_dry_run_no_mutation() {
837 let mut config = empty_config();
838 let section = make_section();
839
840 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
841 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
842
843 let new_section = ProviderSection {
844 alias_prefix: "ocean".to_string(),
845 ..section
846 };
847 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
848 assert_eq!(result.updated, 1);
849
850 assert_eq!(config.host_entries()[0].alias, "do-web-1");
852 }
853
854 #[test]
855 fn test_sync_no_rename_when_prefix_unchanged() {
856 let mut config = empty_config();
857 let section = make_section();
858
859 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
860 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
861
862 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
864 assert_eq!(result.unchanged, 1);
865 assert_eq!(result.updated, 0);
866 assert_eq!(config.host_entries()[0].alias, "do-web-1");
867 }
868
869 #[test]
870 fn test_sync_manual_comment_survives_cleanup() {
871 let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
874 let mut config = SshConfigFile {
875 elements: SshConfigFile::parse_content(content),
876 path: PathBuf::from("/tmp/test_config"),
877 crlf: false,
878 };
879 let section = make_section();
880
881 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
883
884 let has_manual = config
886 .elements
887 .iter()
888 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
889 assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
890 }
891
892 #[test]
893 fn test_sync_rename_skips_included_host() {
894 let mut config = config_with_include_provider_host();
895
896 let new_section = ProviderSection {
897 provider: "digitalocean".to_string(),
898 token: "test".to_string(),
899 alias_prefix: "ocean".to_string(), user: "root".to_string(),
901 identity_file: String::new(),
902 url: String::new(),
903 verify_tls: true,
904 auto_sync: true,
905 profile: String::new(),
906 regions: String::new(),
907 project: String::new(),
908 };
909
910 let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "1.2.3.4".to_string(), Vec::new())];
912 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
913 assert_eq!(result.unchanged, 1);
914 assert_eq!(result.updated, 0);
915
916 assert_eq!(config.host_entries()[0].alias, "do-included");
918 }
919
920 #[test]
921 fn test_sync_rename_stable_with_manual_collision() {
922 let mut config = empty_config();
923 let section = make_section(); let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
927 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
928 assert_eq!(config.host_entries()[0].alias, "do-web-1");
929
930 let manual = HostEntry {
932 alias: "ocean-web-1".to_string(),
933 hostname: "5.5.5.5".to_string(),
934 ..Default::default()
935 };
936 config.add_host(&manual);
937
938 let new_section = ProviderSection {
940 alias_prefix: "ocean".to_string(),
941 ..section.clone()
942 };
943 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
944 assert_eq!(result.updated, 1);
945
946 let entries = config.host_entries();
947 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
948 assert_eq!(provider_host.alias, "ocean-web-1-2");
949
950 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
952 assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
953
954 let entries = config.host_entries();
955 let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
956 assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
957 }
958
959 #[test]
960 fn test_sync_preserves_user_tags() {
961 let mut config = empty_config();
962 let section = make_section();
963
964 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
966 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
967 assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
968
969 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
971 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
972
973 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
975 assert_eq!(result.unchanged, 1);
976 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
977 }
978
979 #[test]
980 fn test_sync_merges_new_provider_tag_with_user_tags() {
981 let mut config = empty_config();
982 let section = make_section();
983
984 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
986 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
987
988 config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
990
991 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "v2".to_string()])];
993 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
994 assert_eq!(result.updated, 1);
995 let tags = &config.host_entries()[0].tags;
996 assert!(tags.contains(&"nyc1".to_string()));
997 assert!(tags.contains(&"critical".to_string()));
998 assert!(tags.contains(&"v2".to_string()));
999 }
1000
1001 #[test]
1002 fn test_sync_reset_tags_replaces_local_tags() {
1003 let mut config = empty_config();
1004 let section = make_section();
1005
1006 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
1008 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1009
1010 config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
1012 assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
1013
1014 let result = sync_provider_with_options(
1016 &mut config, &MockProvider, &remote, §ion, false, false, true,
1017 );
1018 assert_eq!(result.updated, 1);
1019 assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
1020 }
1021
1022 #[test]
1023 fn test_sync_reset_tags_clears_stale_tags() {
1024 let mut config = empty_config();
1025 let section = make_section();
1026
1027 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1029 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1030
1031 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
1033 let result = sync_provider_with_options(
1034 &mut config, &MockProvider, &remote, §ion, false, false, true,
1035 );
1036 assert_eq!(result.updated, 1);
1037 assert!(config.host_entries()[0].tags.is_empty());
1038 }
1039
1040 #[test]
1041 fn test_sync_reset_tags_unchanged_when_matching() {
1042 let mut config = empty_config();
1043 let section = make_section();
1044
1045 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "nyc1".to_string()])];
1047 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1048
1049 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "prod".to_string()])];
1051 let result = sync_provider_with_options(
1052 &mut config, &MockProvider, &remote, §ion, false, false, true,
1053 );
1054 assert_eq!(result.unchanged, 1);
1055 }
1056
1057 #[test]
1058 fn test_sync_merge_case_insensitive() {
1059 let mut config = empty_config();
1060 let section = make_section();
1061
1062 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1064 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1065 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1066
1067 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1069 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1070 assert_eq!(result.unchanged, 1);
1071 assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
1072 }
1073
1074 #[test]
1075 fn test_sync_reset_tags_case_insensitive_unchanged() {
1076 let mut config = empty_config();
1077 let section = make_section();
1078
1079 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1081 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1082
1083 let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1085 let result = sync_provider_with_options(
1086 &mut config, &MockProvider, &remote, §ion, false, false, true,
1087 );
1088 assert_eq!(result.unchanged, 1);
1089 }
1090
1091 #[test]
1094 fn test_sync_empty_ip_not_added() {
1095 let mut config = empty_config();
1096 let section = make_section();
1097 let remote = vec![ProviderHost::new("100".to_string(), "stopped-vm".to_string(), String::new(), Vec::new())];
1098 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1099 assert_eq!(result.added, 0);
1100 assert_eq!(config.host_entries().len(), 0);
1101 }
1102
1103 #[test]
1104 fn test_sync_empty_ip_existing_host_unchanged() {
1105 let mut config = empty_config();
1106 let section = make_section();
1107
1108 let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1110 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1111 assert_eq!(config.host_entries().len(), 1);
1112 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1113
1114 let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), String::new(), Vec::new())];
1116 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1117 assert_eq!(result.unchanged, 1);
1118 assert_eq!(result.updated, 0);
1119 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1120 }
1121
1122 #[test]
1123 fn test_sync_remove_skips_empty_ip_hosts() {
1124 let mut config = empty_config();
1125 let section = make_section();
1126
1127 let remote = vec![
1129 ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1130 ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1131 ];
1132 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1133 assert_eq!(config.host_entries().len(), 2);
1134
1135 let remote = vec![
1138 ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1139 ProviderHost::new("200".to_string(), "db".to_string(), String::new(), Vec::new()),
1140 ];
1141 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1142 assert_eq!(result.removed, 0);
1143 assert_eq!(result.unchanged, 2);
1144 assert_eq!(config.host_entries().len(), 2);
1145 }
1146
1147 #[test]
1148 fn test_sync_remove_deletes_truly_gone_hosts() {
1149 let mut config = empty_config();
1150 let section = make_section();
1151
1152 let remote = vec![
1154 ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1155 ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1156 ];
1157 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1158 assert_eq!(config.host_entries().len(), 2);
1159
1160 let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1162 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1163 assert_eq!(result.removed, 1);
1164 assert_eq!(config.host_entries().len(), 1);
1165 assert_eq!(config.host_entries()[0].alias, "do-web");
1166 }
1167
1168 #[test]
1169 fn test_sync_mixed_resolved_empty_and_missing() {
1170 let mut config = empty_config();
1171 let section = make_section();
1172
1173 let remote = vec![
1175 ProviderHost::new("1".to_string(), "running".to_string(), "1.1.1.1".to_string(), Vec::new()),
1176 ProviderHost::new("2".to_string(), "stopped".to_string(), "2.2.2.2".to_string(), Vec::new()),
1177 ProviderHost::new("3".to_string(), "deleted".to_string(), "3.3.3.3".to_string(), Vec::new()),
1178 ];
1179 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1180 assert_eq!(config.host_entries().len(), 3);
1181
1182 let remote = vec![
1187 ProviderHost::new("1".to_string(), "running".to_string(), "9.9.9.9".to_string(), Vec::new()),
1188 ProviderHost::new("2".to_string(), "stopped".to_string(), String::new(), Vec::new()),
1189 ];
1190 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
1191 assert_eq!(result.updated, 1);
1192 assert_eq!(result.unchanged, 1);
1193 assert_eq!(result.removed, 1);
1194
1195 let entries = config.host_entries();
1196 assert_eq!(entries.len(), 2);
1197 let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
1199 assert_eq!(running.hostname, "9.9.9.9");
1200 let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
1202 assert_eq!(stopped.hostname, "2.2.2.2");
1203 }
1204
1205 #[test]
1210 fn test_sanitize_name_unicode() {
1211 assert_eq!(sanitize_name("서버-1"), "1");
1213 }
1214
1215 #[test]
1216 fn test_sanitize_name_numbers_only() {
1217 assert_eq!(sanitize_name("12345"), "12345");
1218 }
1219
1220 #[test]
1221 fn test_sanitize_name_mixed_special_chars() {
1222 assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
1223 }
1224
1225 #[test]
1226 fn test_sanitize_name_tabs_and_newlines() {
1227 assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
1228 }
1229
1230 #[test]
1231 fn test_sanitize_name_consecutive_specials() {
1232 assert_eq!(sanitize_name("a!!!b"), "a-b");
1233 }
1234
1235 #[test]
1236 fn test_sanitize_name_trailing_special() {
1237 assert_eq!(sanitize_name("web-"), "web");
1238 }
1239
1240 #[test]
1241 fn test_sanitize_name_leading_special() {
1242 assert_eq!(sanitize_name("-web"), "web");
1243 }
1244
1245 #[test]
1250 fn test_build_alias_prefix_with_hyphen() {
1251 assert_eq!(build_alias("do-", "web-1"), "do--web-1");
1254 }
1255
1256 #[test]
1257 fn test_build_alias_long_names() {
1258 assert_eq!(build_alias("my-provider", "my-very-long-server-name"), "my-provider-my-very-long-server-name");
1259 }
1260
1261 #[test]
1266 fn test_sync_applies_user_from_section() {
1267 let mut config = empty_config();
1268 let mut section = make_section();
1269 section.user = "admin".to_string();
1270 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1271 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1272 let entries = config.host_entries();
1273 assert_eq!(entries[0].user, "admin");
1274 }
1275
1276 #[test]
1277 fn test_sync_applies_identity_file_from_section() {
1278 let mut config = empty_config();
1279 let mut section = make_section();
1280 section.identity_file = "~/.ssh/id_rsa".to_string();
1281 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1282 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1283 let entries = config.host_entries();
1284 assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
1285 }
1286
1287 #[test]
1288 fn test_sync_empty_user_not_set() {
1289 let mut config = empty_config();
1290 let mut section = make_section();
1291 section.user = String::new(); let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1293 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1294 let entries = config.host_entries();
1295 assert!(entries[0].user.is_empty());
1296 }
1297
1298 #[test]
1303 fn test_sync_result_default() {
1304 let result = SyncResult::default();
1305 assert_eq!(result.added, 0);
1306 assert_eq!(result.updated, 0);
1307 assert_eq!(result.removed, 0);
1308 assert_eq!(result.unchanged, 0);
1309 assert!(result.renames.is_empty());
1310 }
1311
1312 #[test]
1317 fn test_sync_server_name_change_updates_alias() {
1318 let mut config = empty_config();
1319 let section = make_section();
1320 let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1322 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1323 assert_eq!(config.host_entries()[0].alias, "do-old-name");
1324
1325 let remote_renamed = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1327 let result = sync_provider(&mut config, &MockProvider, &remote_renamed, §ion, false, false);
1328 assert!(!result.renames.is_empty() || result.updated > 0);
1330 }
1331
1332 #[test]
1333 fn test_sync_idempotent_same_data() {
1334 let mut config = empty_config();
1335 let section = make_section();
1336 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1337 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1338 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1339 assert_eq!(result.added, 0);
1340 assert_eq!(result.updated, 0);
1341 assert_eq!(result.unchanged, 1);
1342 }
1343
1344 #[test]
1349 fn test_sync_tag_merge_case_insensitive_no_duplicate() {
1350 let mut config = empty_config();
1351 let section = make_section();
1352 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
1354 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1355
1356 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1358 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1359 assert_eq!(result.unchanged, 1);
1360 assert_eq!(result.updated, 0);
1361 }
1362
1363 #[test]
1364 fn test_sync_tag_merge_adds_new_remote_tag() {
1365 let mut config = empty_config();
1366 let section = make_section();
1367 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1368 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1369
1370 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "us-east".to_string()])];
1372 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1373 assert_eq!(result.updated, 1);
1374
1375 let entries = config.host_entries();
1377 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1378 assert!(entry.tags.iter().any(|t| t == "prod"));
1379 assert!(entry.tags.iter().any(|t| t == "us-east"));
1380 }
1381
1382 #[test]
1383 fn test_sync_tag_merge_preserves_local_tags() {
1384 let mut config = empty_config();
1385 let section = make_section();
1386 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1387 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1388
1389 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1391
1392 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1394 assert_eq!(result.unchanged, 1);
1395 let entries = config.host_entries();
1396 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1397 assert!(entry.tags.iter().any(|t| t == "my-custom"));
1398 }
1399
1400 #[test]
1401 fn test_sync_reset_tags_replaces_local() {
1402 let mut config = empty_config();
1403 let section = make_section();
1404 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
1405 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1406
1407 config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
1409
1410 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "new-tag".to_string()])];
1412 let result = sync_provider_with_options(&mut config, &MockProvider, &remote2, §ion, false, false, true);
1413 assert_eq!(result.updated, 1);
1414
1415 let entries = config.host_entries();
1416 let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
1417 assert!(entry.tags.iter().any(|t| t == "new-tag"));
1418 assert!(!entry.tags.iter().any(|t| t == "my-custom"));
1420 }
1421
1422 #[test]
1427 fn test_sync_rename_and_ip_change_simultaneously() {
1428 let mut config = empty_config();
1429 let section = make_section();
1430 let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
1431 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1432
1433 let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "9.8.7.6".to_string(), Vec::new())];
1435 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1436 assert_eq!(result.updated, 1);
1437 assert_eq!(result.renames.len(), 1);
1438 assert_eq!(result.renames[0].0, "do-old-name");
1439 assert_eq!(result.renames[0].1, "do-new-name");
1440
1441 let entries = config.host_entries();
1442 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1443 assert_eq!(entry.hostname, "9.8.7.6");
1444 }
1445
1446 #[test]
1451 fn test_sync_duplicate_server_id_deduped() {
1452 let mut config = empty_config();
1453 let section = make_section();
1454 let remote = vec![
1455 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1456 ProviderHost::new("1".to_string(), "web-copy".to_string(), "5.6.7.8".to_string(), Vec::new()), ];
1458 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1459 assert_eq!(result.added, 1); assert_eq!(config.host_entries().len(), 1);
1461 }
1462
1463 #[test]
1468 fn test_sync_remove_all_when_remote_empty() {
1469 let mut config = empty_config();
1470 let section = make_section();
1471 let remote = vec![
1472 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1473 ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1474 ];
1475 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1476 assert_eq!(config.host_entries().len(), 2);
1477
1478 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1480 assert_eq!(result.removed, 2);
1481 assert_eq!(config.host_entries().len(), 0);
1482 }
1483
1484 #[test]
1489 fn test_sync_adds_group_header_on_first_host() {
1490 let mut config = empty_config();
1491 let section = make_section();
1492 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1493 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1494
1495 let has_header = config.elements.iter().any(|e| {
1497 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1498 });
1499 assert!(has_header);
1500 }
1501
1502 #[test]
1503 fn test_sync_removes_header_when_all_hosts_deleted() {
1504 let mut config = empty_config();
1505 let section = make_section();
1506 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1507 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1508
1509 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1511 assert_eq!(result.removed, 1);
1512
1513 let has_header = config.elements.iter().any(|e| {
1515 matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
1516 });
1517 assert!(!has_header);
1518 }
1519
1520 #[test]
1525 fn test_sync_identity_file_set_on_new_host() {
1526 let mut config = empty_config();
1527 let mut section = make_section();
1528 section.identity_file = "~/.ssh/do_key".to_string();
1529 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1530 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1531 let entries = config.host_entries();
1532 assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
1533 }
1534
1535 #[test]
1540 fn test_sync_alias_collision_dedup() {
1541 let mut config = empty_config();
1542 let section = make_section();
1543 let remote = vec![
1545 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1546 ProviderHost::new("2".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new()), ];
1548 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1549 assert_eq!(result.added, 2);
1550
1551 let entries = config.host_entries();
1552 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
1553 assert!(aliases.contains(&"do-web"));
1554 assert!(aliases.contains(&"do-web-2")); }
1556
1557 #[test]
1562 fn test_sync_empty_alias_prefix() {
1563 let mut config = empty_config();
1564 let mut section = make_section();
1565 section.alias_prefix = String::new();
1566 let remote = vec![ProviderHost::new("1".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
1567 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1568 let entries = config.host_entries();
1569 assert_eq!(entries[0].alias, "web-1"); }
1571
1572 #[test]
1577 fn test_sync_dry_run_add_count() {
1578 let mut config = empty_config();
1579 let section = make_section();
1580 let remote = vec![
1581 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1582 ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1583 ];
1584 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
1585 assert_eq!(result.added, 2);
1586 assert_eq!(config.host_entries().len(), 0);
1588 }
1589
1590 #[test]
1591 fn test_sync_dry_run_remove_count_preserves_config() {
1592 let mut config = empty_config();
1593 let section = make_section();
1594 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1595 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1596 assert_eq!(config.host_entries().len(), 1);
1597
1598 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
1600 assert_eq!(result.removed, 1);
1601 assert_eq!(config.host_entries().len(), 1);
1603 }
1604
1605 #[test]
1610 fn test_sync_result_counts_add_up() {
1611 let mut config = empty_config();
1612 let section = make_section();
1613 let remote = vec![
1615 ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1616 ProviderHost::new("2".to_string(), "b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1617 ProviderHost::new("3".to_string(), "c".to_string(), "3.3.3.3".to_string(), Vec::new()),
1618 ];
1619 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1620
1621 let remote2 = vec![
1623 ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()), ProviderHost::new("2".to_string(), "b".to_string(), "9.9.9.9".to_string(), Vec::new()), ];
1627 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, true, false);
1628 assert_eq!(result.unchanged, 1);
1629 assert_eq!(result.updated, 1);
1630 assert_eq!(result.removed, 1);
1631 assert_eq!(result.added, 0);
1632 }
1633
1634 #[test]
1639 fn test_sync_multiple_renames() {
1640 let mut config = empty_config();
1641 let section = make_section();
1642 let remote = vec![
1643 ProviderHost::new("1".to_string(), "old-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1644 ProviderHost::new("2".to_string(), "old-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1645 ];
1646 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1647
1648 let remote2 = vec![
1649 ProviderHost::new("1".to_string(), "new-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
1650 ProviderHost::new("2".to_string(), "new-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
1651 ];
1652 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1653 assert_eq!(result.renames.len(), 2);
1654 assert_eq!(result.updated, 2);
1655 }
1656
1657 #[test]
1662 fn test_sync_tag_whitespace_trimmed_on_store() {
1663 let mut config = empty_config();
1664 let section = make_section();
1665 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec![" production ".to_string(), " us-east ".to_string()])];
1667 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1668 let entries = config.host_entries();
1669 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
1671 }
1672
1673 #[test]
1674 fn test_sync_tag_trimmed_remote_triggers_merge() {
1675 let mut config = empty_config();
1676 let section = make_section();
1677 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
1679 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1680
1681 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec![" production ".to_string()])]; let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1684 assert_eq!(result.unchanged, 1);
1686 }
1687
1688 struct MockProvider2;
1693 impl Provider for MockProvider2 {
1694 fn name(&self) -> &str {
1695 "vultr"
1696 }
1697 fn short_label(&self) -> &str {
1698 "vultr"
1699 }
1700 fn fetch_hosts_cancellable(
1701 &self,
1702 _token: &str,
1703 _cancel: &std::sync::atomic::AtomicBool,
1704 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
1705 Ok(Vec::new())
1706 }
1707 }
1708
1709 #[test]
1710 fn test_sync_two_providers_independent() {
1711 let mut config = empty_config();
1712
1713 let do_section = make_section(); let vultr_section = ProviderSection {
1715 provider: "vultr".to_string(),
1716 token: "test".to_string(),
1717 alias_prefix: "vultr".to_string(),
1718 user: String::new(),
1719 identity_file: String::new(),
1720 url: String::new(),
1721 verify_tls: true,
1722 auto_sync: true,
1723 profile: String::new(),
1724 regions: String::new(),
1725 project: String::new(),
1726 };
1727
1728 let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1730 sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
1731
1732 let vultr_remote = vec![ProviderHost::new("abc".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new())];
1734 sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
1735
1736 let entries = config.host_entries();
1737 assert_eq!(entries.len(), 2);
1738 let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
1739 assert!(aliases.contains(&"do-web"));
1740 assert!(aliases.contains(&"vultr-web"));
1741 }
1742
1743 #[test]
1744 fn test_sync_remove_only_affects_own_provider() {
1745 let mut config = empty_config();
1746 let do_section = make_section();
1747 let vultr_section = ProviderSection {
1748 provider: "vultr".to_string(),
1749 token: "test".to_string(),
1750 alias_prefix: "vultr".to_string(),
1751 user: String::new(),
1752 identity_file: String::new(),
1753 url: String::new(),
1754 verify_tls: true,
1755 auto_sync: true,
1756 profile: String::new(),
1757 regions: String::new(),
1758 project: String::new(),
1759 };
1760
1761 let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1763 sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
1764
1765 let vultr_remote = vec![ProviderHost::new("abc".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new())];
1766 sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
1767 assert_eq!(config.host_entries().len(), 2);
1768
1769 let result = sync_provider(&mut config, &MockProvider, &[], &do_section, true, false);
1771 assert_eq!(result.removed, 1);
1772 let entries = config.host_entries();
1773 assert_eq!(entries.len(), 1);
1774 assert_eq!(entries[0].alias, "vultr-db");
1775 }
1776
1777 #[test]
1782 fn test_sync_rename_and_tag_change_simultaneously() {
1783 let mut config = empty_config();
1784 let section = make_section();
1785 let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1786 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1787 assert_eq!(config.host_entries()[0].alias, "do-old-name");
1788 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
1789
1790 let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string(), "prod".to_string()])];
1792 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1793 assert_eq!(result.updated, 1);
1794 assert_eq!(result.renames.len(), 1);
1795
1796 let entries = config.host_entries();
1797 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1798 assert!(entry.tags.contains(&"staging".to_string()));
1799 assert!(entry.tags.contains(&"prod".to_string()));
1800 }
1801
1802 #[test]
1807 fn test_sync_all_symbol_name_uses_server_fallback() {
1808 let mut config = empty_config();
1809 let section = make_section();
1810 let remote = vec![ProviderHost::new("1".to_string(), "!!!".to_string(), "1.2.3.4".to_string(), Vec::new())];
1811 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1812 let entries = config.host_entries();
1813 assert_eq!(entries[0].alias, "do-server");
1814 }
1815
1816 #[test]
1817 fn test_sync_unicode_name_uses_ascii_fallback() {
1818 let mut config = empty_config();
1819 let section = make_section();
1820 let remote = vec![ProviderHost::new("1".to_string(), "서버".to_string(), "1.2.3.4".to_string(), Vec::new())];
1821 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1822 let entries = config.host_entries();
1823 assert_eq!(entries[0].alias, "do-server");
1825 }
1826
1827 #[test]
1832 fn test_sync_dry_run_update_preserves_config() {
1833 let mut config = empty_config();
1834 let section = make_section();
1835 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1836 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1837
1838 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
1840 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
1841 assert_eq!(result.updated, 1);
1842 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
1844 }
1845
1846 #[test]
1851 fn test_sync_empty_remote_empty_config_noop() {
1852 let mut config = empty_config();
1853 let section = make_section();
1854 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
1855 assert_eq!(result.added, 0);
1856 assert_eq!(result.updated, 0);
1857 assert_eq!(result.removed, 0);
1858 assert_eq!(result.unchanged, 0);
1859 assert!(config.host_entries().is_empty());
1860 }
1861
1862 #[test]
1867 fn test_sync_large_batch() {
1868 let mut config = empty_config();
1869 let section = make_section();
1870 let remote: Vec<ProviderHost> = (0..100)
1871 .map(|i| ProviderHost::new(format!("{}", i), format!("server-{}", i), format!("10.0.0.{}", i % 256), vec!["batch".to_string()]))
1872 .collect();
1873 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1874 assert_eq!(result.added, 100);
1875 assert_eq!(config.host_entries().len(), 100);
1876
1877 let result2 = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1879 assert_eq!(result2.unchanged, 100);
1880 assert_eq!(result2.added, 0);
1881 }
1882
1883 #[test]
1888 fn test_sync_rename_self_exclusion_no_collision() {
1889 let mut config = empty_config();
1892 let section = make_section();
1893 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1894 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1895 assert_eq!(config.host_entries()[0].alias, "do-web");
1896
1897 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
1899 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
1900 assert_eq!(result.updated, 1);
1901 assert!(result.renames.is_empty());
1902 assert_eq!(config.host_entries()[0].alias, "do-web"); }
1904
1905 #[test]
1910 fn test_sync_reset_tags_with_rename() {
1911 let mut config = empty_config();
1912 let section = make_section();
1913 let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
1914 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1915 config.set_host_tags("do-old-name", &["staging".to_string(), "custom".to_string()]);
1916
1917 let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
1919 let result = sync_provider_with_options(
1920 &mut config, &MockProvider, &remote2, §ion, false, false, true,
1921 );
1922 assert_eq!(result.updated, 1);
1923 assert_eq!(result.renames.len(), 1);
1924
1925 let entries = config.host_entries();
1926 let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
1927 assert_eq!(entry.tags, vec!["production"]);
1928 assert!(!entry.tags.contains(&"custom".to_string()));
1929 }
1930
1931 #[test]
1936 fn test_sync_empty_ip_with_tags_not_added() {
1937 let mut config = empty_config();
1938 let section = make_section();
1939 let remote = vec![ProviderHost::new("1".to_string(), "stopped".to_string(), String::new(), vec!["prod".to_string()])];
1940 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1941 assert_eq!(result.added, 0);
1942 assert!(config.host_entries().is_empty());
1943 }
1944
1945 #[test]
1950 fn test_sync_orphaned_provider_marker_counts_unchanged() {
1951 let content = "\
1956Host do-web
1957 HostName 1.2.3.4
1958 # purple:provider digitalocean:123
1959";
1960 let mut config = SshConfigFile {
1961 elements: SshConfigFile::parse_content(content),
1962 path: PathBuf::from("/tmp/test_config"),
1963 crlf: false,
1964 };
1965 let section = make_section();
1966 let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
1967 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1968 assert_eq!(result.unchanged, 1);
1969 }
1970
1971 #[test]
1976 fn test_sync_no_double_blank_between_hosts() {
1977 let mut config = empty_config();
1978 let section = make_section();
1979 let remote = vec![
1980 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
1981 ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
1982 ];
1983 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
1984
1985 let mut prev_blank = false;
1987 for elem in &config.elements {
1988 if let ConfigElement::GlobalLine(line) = elem {
1989 let is_blank = line.trim().is_empty();
1990 assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
1991 prev_blank = is_blank;
1992 } else {
1993 prev_blank = false;
1994 }
1995 }
1996 }
1997
1998 #[test]
2003 fn test_sync_without_remove_flag_keeps_deleted() {
2004 let mut config = empty_config();
2005 let section = make_section();
2006 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2007 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2008
2009 let result = sync_provider(&mut config, &MockProvider, &[], §ion, false, false);
2011 assert_eq!(result.removed, 0);
2012 assert_eq!(config.host_entries().len(), 1); }
2014
2015 #[test]
2020 fn test_sync_dry_run_rename_no_renames_tracked() {
2021 let mut config = empty_config();
2022 let section = make_section();
2023 let remote = vec![ProviderHost::new("1".to_string(), "old".to_string(), "1.2.3.4".to_string(), Vec::new())];
2024 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2025
2026 let new_section = ProviderSection {
2027 alias_prefix: "ocean".to_string(),
2028 ..section
2029 };
2030 let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
2031 assert_eq!(result.updated, 1);
2032 assert!(result.renames.is_empty());
2034 }
2035
2036 #[test]
2041 fn test_sanitize_name_whitespace_only() {
2042 assert_eq!(sanitize_name(" "), "server");
2043 }
2044
2045 #[test]
2046 fn test_sanitize_name_single_char() {
2047 assert_eq!(sanitize_name("a"), "a");
2048 assert_eq!(sanitize_name("Z"), "z");
2049 assert_eq!(sanitize_name("5"), "5");
2050 }
2051
2052 #[test]
2053 fn test_sanitize_name_single_special_char() {
2054 assert_eq!(sanitize_name("!"), "server");
2055 assert_eq!(sanitize_name("-"), "server");
2056 assert_eq!(sanitize_name("."), "server");
2057 }
2058
2059 #[test]
2060 fn test_sanitize_name_emoji() {
2061 assert_eq!(sanitize_name("server🚀"), "server");
2062 assert_eq!(sanitize_name("🔥hot🔥"), "hot");
2063 }
2064
2065 #[test]
2066 fn test_sanitize_name_long_mixed_separators() {
2067 assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
2068 }
2069
2070 #[test]
2071 fn test_sanitize_name_dots_and_underscores() {
2072 assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
2073 }
2074
2075 #[test]
2080 fn test_find_hosts_by_provider_in_includes() {
2081 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2082
2083 let include_content = "Host do-included\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n";
2084 let included_elements = SshConfigFile::parse_content(include_content);
2085
2086 let config = SshConfigFile {
2087 elements: vec![ConfigElement::Include(IncludeDirective {
2088 raw_line: "Include conf.d/*".to_string(),
2089 pattern: "conf.d/*".to_string(),
2090 resolved_files: vec![IncludedFile {
2091 path: PathBuf::from("/tmp/included.conf"),
2092 elements: included_elements,
2093 }],
2094 })],
2095 path: PathBuf::from("/tmp/test_config"),
2096 crlf: false,
2097 };
2098
2099 let hosts = config.find_hosts_by_provider("digitalocean");
2100 assert_eq!(hosts.len(), 1);
2101 assert_eq!(hosts[0].0, "do-included");
2102 assert_eq!(hosts[0].1, "inc1");
2103 }
2104
2105 #[test]
2106 fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
2107 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2108
2109 let top_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
2111 let top_elements = SshConfigFile::parse_content(top_content);
2112
2113 let inc_content = "Host do-db\n HostName 5.6.7.8\n # purple:provider digitalocean:2\n";
2115 let inc_elements = SshConfigFile::parse_content(inc_content);
2116
2117 let mut elements = top_elements;
2118 elements.push(ConfigElement::Include(IncludeDirective {
2119 raw_line: "Include conf.d/*".to_string(),
2120 pattern: "conf.d/*".to_string(),
2121 resolved_files: vec![IncludedFile {
2122 path: PathBuf::from("/tmp/included.conf"),
2123 elements: inc_elements,
2124 }],
2125 }));
2126
2127 let config = SshConfigFile {
2128 elements,
2129 path: PathBuf::from("/tmp/test_config"),
2130 crlf: false,
2131 };
2132
2133 let hosts = config.find_hosts_by_provider("digitalocean");
2134 assert_eq!(hosts.len(), 2);
2135 }
2136
2137 #[test]
2138 fn test_find_hosts_by_provider_empty_includes() {
2139 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2140
2141 let config = SshConfigFile {
2142 elements: vec![ConfigElement::Include(IncludeDirective {
2143 raw_line: "Include conf.d/*".to_string(),
2144 pattern: "conf.d/*".to_string(),
2145 resolved_files: vec![IncludedFile {
2146 path: PathBuf::from("/tmp/empty.conf"),
2147 elements: vec![],
2148 }],
2149 })],
2150 path: PathBuf::from("/tmp/test_config"),
2151 crlf: false,
2152 };
2153
2154 let hosts = config.find_hosts_by_provider("digitalocean");
2155 assert!(hosts.is_empty());
2156 }
2157
2158 #[test]
2159 fn test_find_hosts_by_provider_wrong_provider_name() {
2160 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
2161 let config = SshConfigFile {
2162 elements: SshConfigFile::parse_content(content),
2163 path: PathBuf::from("/tmp/test_config"),
2164 crlf: false,
2165 };
2166
2167 let hosts = config.find_hosts_by_provider("vultr");
2168 assert!(hosts.is_empty());
2169 }
2170
2171 #[test]
2176 fn test_deduplicate_alias_excluding_self() {
2177 let content = "Host do-web\n HostName 1.2.3.4\n";
2179 let config = SshConfigFile {
2180 elements: SshConfigFile::parse_content(content),
2181 path: PathBuf::from("/tmp/test_config"),
2182 crlf: false,
2183 };
2184
2185 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2186 assert_eq!(alias, "do-web"); }
2188
2189 #[test]
2190 fn test_deduplicate_alias_excluding_other() {
2191 let content = "Host do-web\n HostName 1.2.3.4\n";
2193 let config = SshConfigFile {
2194 elements: SshConfigFile::parse_content(content),
2195 path: PathBuf::from("/tmp/test_config"),
2196 crlf: false,
2197 };
2198
2199 let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
2200 assert_eq!(alias, "do-web-2"); }
2202
2203 #[test]
2204 fn test_deduplicate_alias_excluding_chain() {
2205 let content = "Host do-web\n HostName 1.1.1.1\n\nHost do-web-2\n HostName 2.2.2.2\n";
2207 let config = SshConfigFile {
2208 elements: SshConfigFile::parse_content(content),
2209 path: PathBuf::from("/tmp/test_config"),
2210 crlf: false,
2211 };
2212
2213 let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
2214 assert_eq!(alias, "do-web");
2216 }
2217
2218 #[test]
2219 fn test_deduplicate_alias_excluding_none() {
2220 let content = "Host do-web\n HostName 1.2.3.4\n";
2221 let config = SshConfigFile {
2222 elements: SshConfigFile::parse_content(content),
2223 path: PathBuf::from("/tmp/test_config"),
2224 crlf: false,
2225 };
2226
2227 let alias = config.deduplicate_alias_excluding("do-web", None);
2229 assert_eq!(alias, "do-web-2");
2230 }
2231
2232 #[test]
2237 fn test_set_host_tags_empty_clears_tags() {
2238 let content = "Host do-web\n HostName 1.2.3.4\n # purple:tags prod,staging\n";
2239 let mut config = SshConfigFile {
2240 elements: SshConfigFile::parse_content(content),
2241 path: PathBuf::from("/tmp/test_config"),
2242 crlf: false,
2243 };
2244
2245 config.set_host_tags("do-web", &[]);
2246 let entries = config.host_entries();
2247 assert!(entries[0].tags.is_empty());
2248 }
2249
2250 #[test]
2251 fn test_set_host_provider_updates_existing() {
2252 let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:old-id\n";
2253 let mut config = SshConfigFile {
2254 elements: SshConfigFile::parse_content(content),
2255 path: PathBuf::from("/tmp/test_config"),
2256 crlf: false,
2257 };
2258
2259 config.set_host_provider("do-web", "digitalocean", "new-id");
2260 let hosts = config.find_hosts_by_provider("digitalocean");
2261 assert_eq!(hosts.len(), 1);
2262 assert_eq!(hosts[0].1, "new-id");
2263 }
2264
2265 #[test]
2270 fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
2271 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2272
2273 let include_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n";
2274 let included_elements = SshConfigFile::parse_content(include_content);
2275
2276 let mut config = SshConfigFile {
2277 elements: vec![ConfigElement::Include(IncludeDirective {
2278 raw_line: "Include conf.d/*".to_string(),
2279 pattern: "conf.d/*".to_string(),
2280 resolved_files: vec![IncludedFile {
2281 path: PathBuf::from("/tmp/included.conf"),
2282 elements: included_elements,
2283 }],
2284 })],
2285 path: PathBuf::from("/tmp/test_config"),
2286 crlf: false,
2287 };
2288
2289 let section = make_section();
2290 let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2291
2292 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2293 assert_eq!(result.unchanged, 1);
2294 assert_eq!(result.added, 0);
2295 let top_hosts = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
2297 assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
2298 }
2299
2300 #[test]
2305 fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
2306 let mut config = empty_config();
2307 let section = make_section();
2308
2309 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2311 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2312 assert_eq!(config.host_entries()[0].alias, "do-web");
2313
2314 let other = vec![ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new())];
2316 sync_provider(&mut config, &MockProvider, &other, §ion, false, false);
2317
2318 let remote_same = vec![
2326 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
2327 ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new()),
2328 ];
2329 let result = sync_provider(&mut config, &MockProvider, &remote_same, §ion, false, false);
2330 assert_eq!(result.unchanged, 2);
2331 assert_eq!(result.updated, 0);
2332 assert!(result.renames.is_empty());
2333 }
2334
2335 #[test]
2340 fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
2341 let mut config = empty_config();
2344 let section = make_section();
2345
2346 let remote = vec![
2347 ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
2348 ProviderHost::new("2".to_string(), "web".to_string(), "2.2.2.2".to_string(), Vec::new()),
2349 ];
2350 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2351 assert_eq!(result.added, 2);
2352
2353 let entries = config.host_entries();
2354 assert_eq!(entries[0].alias, "do-web");
2355 assert_eq!(entries[1].alias, "do-web-2");
2356
2357 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2359 assert_eq!(result.unchanged, 2);
2360 }
2361
2362 #[test]
2367 fn test_sync_dry_run_remove_excludes_included_hosts() {
2368 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
2369
2370 let include_content =
2371 "Host do-included\n HostName 1.1.1.1\n # purple:provider digitalocean:inc1\n";
2372 let included_elements = SshConfigFile::parse_content(include_content);
2373
2374 let mut config = SshConfigFile {
2376 elements: vec![ConfigElement::Include(IncludeDirective {
2377 raw_line: "Include conf.d/*".to_string(),
2378 pattern: "conf.d/*".to_string(),
2379 resolved_files: vec![IncludedFile {
2380 path: PathBuf::from("/tmp/included.conf"),
2381 elements: included_elements,
2382 }],
2383 })],
2384 path: PathBuf::from("/tmp/test_config"),
2385 crlf: false,
2386 };
2387
2388 let section = make_section();
2390 let remote = vec![ProviderHost::new("top1".to_string(), "toplevel".to_string(), "2.2.2.2".to_string(), Vec::new())];
2391 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2392
2393 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
2396 assert_eq!(result.removed, 1, "Only top-level host counted in dry-run remove");
2397 }
2398
2399 #[test]
2404 fn test_sync_group_header_with_existing_trailing_blank() {
2405 let mut config = empty_config();
2406 config.elements.push(ConfigElement::GlobalLine("# some comment".to_string()));
2408 config.elements.push(ConfigElement::GlobalLine(String::new()));
2409
2410 let section = make_section();
2411 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2412 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2413 assert_eq!(result.added, 1);
2414
2415 let blank_count = config
2418 .elements
2419 .iter()
2420 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
2421 .count();
2422 assert_eq!(blank_count, 1, "No extra blank line when one already exists");
2423 }
2424
2425 #[test]
2430 fn test_sync_no_group_header_for_second_host() {
2431 let mut config = empty_config();
2432 let section = make_section();
2433
2434 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2436 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2437
2438 let header_count_before = config
2439 .elements
2440 .iter()
2441 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
2442 .count();
2443 assert_eq!(header_count_before, 1);
2444
2445 let remote2 = vec![
2447 ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
2448 ProviderHost::new("2".to_string(), "db".to_string(), "5.5.5.5".to_string(), Vec::new()),
2449 ];
2450 sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2451
2452 let header_count_after = config
2454 .elements
2455 .iter()
2456 .filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
2457 .count();
2458 assert_eq!(header_count_after, 1, "No duplicate group header");
2459 }
2460
2461 #[test]
2466 fn test_sync_duplicate_server_id_in_remote_skipped() {
2467 let mut config = empty_config();
2468 let section = make_section();
2469
2470 let remote = vec![
2472 ProviderHost::new("dup".to_string(), "first".to_string(), "1.1.1.1".to_string(), Vec::new()),
2473 ProviderHost::new("dup".to_string(), "second".to_string(), "2.2.2.2".to_string(), Vec::new()),
2474 ];
2475 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2476 assert_eq!(result.added, 1, "Only the first instance is added");
2477 assert_eq!(config.host_entries()[0].alias, "do-first");
2478 }
2479
2480 #[test]
2485 fn test_sync_empty_ip_existing_host_counted_unchanged() {
2486 let mut config = empty_config();
2487 let section = make_section();
2488
2489 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2491 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2492
2493 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), String::new(), Vec::new())];
2495 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
2496 assert_eq!(result.unchanged, 1);
2497 assert_eq!(result.removed, 0, "Host with empty IP not removed");
2498 assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
2499 }
2500
2501 #[test]
2506 fn test_sync_reset_tags_case_insensitive_no_update() {
2507 let mut config = empty_config();
2508 let section = make_section();
2509
2510 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Production".to_string()])];
2511 sync_provider_with_options(
2512 &mut config, &MockProvider, &remote, §ion, false, false, true,
2513 );
2514
2515 let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
2517 let result = sync_provider_with_options(
2518 &mut config, &MockProvider, &remote2, §ion, false, false, true,
2519 );
2520 assert_eq!(result.unchanged, 1, "Case-insensitive tag match = unchanged");
2521 }
2522
2523 #[test]
2528 fn test_sync_remove_cleans_up_group_header() {
2529 let mut config = empty_config();
2530 let section = make_section();
2531
2532 let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
2533 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2534
2535 let has_header = config.elements.iter().any(|e| {
2537 matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
2538 });
2539 assert!(has_header, "Group header present after add");
2540
2541 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
2543 assert_eq!(result.removed, 1);
2544
2545 let has_header_after = config.elements.iter().any(|e| {
2547 matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
2548 });
2549 assert!(!has_header_after, "Group header removed when all hosts gone");
2550 }
2551
2552 #[test]
2557 fn test_sync_adds_host_with_metadata() {
2558 let mut config = empty_config();
2559 let section = make_section();
2560 let remote = vec![ProviderHost {
2561 server_id: "1".to_string(),
2562 name: "web".to_string(),
2563 ip: "1.2.3.4".to_string(),
2564 tags: Vec::new(),
2565 metadata: vec![
2566 ("region".to_string(), "nyc3".to_string()),
2567 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2568 ],
2569 }];
2570 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2571 assert_eq!(result.added, 1);
2572 let entries = config.host_entries();
2573 assert_eq!(entries[0].provider_meta.len(), 2);
2574 assert_eq!(entries[0].provider_meta[0], ("region".to_string(), "nyc3".to_string()));
2575 assert_eq!(entries[0].provider_meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
2576 }
2577
2578 #[test]
2579 fn test_sync_updates_changed_metadata() {
2580 let mut config = empty_config();
2581 let section = make_section();
2582 let remote = vec![ProviderHost {
2583 server_id: "1".to_string(),
2584 name: "web".to_string(),
2585 ip: "1.2.3.4".to_string(),
2586 tags: Vec::new(),
2587 metadata: vec![("region".to_string(), "nyc3".to_string())],
2588 }];
2589 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2590
2591 let remote2 = vec![ProviderHost {
2593 server_id: "1".to_string(),
2594 name: "web".to_string(),
2595 ip: "1.2.3.4".to_string(),
2596 tags: Vec::new(),
2597 metadata: vec![
2598 ("region".to_string(), "sfo3".to_string()),
2599 ("plan".to_string(), "s-2vcpu-2gb".to_string()),
2600 ],
2601 }];
2602 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2603 assert_eq!(result.updated, 1);
2604 let entries = config.host_entries();
2605 assert_eq!(entries[0].provider_meta.len(), 2);
2606 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
2607 assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
2608 }
2609
2610 #[test]
2611 fn test_sync_metadata_unchanged_no_update() {
2612 let mut config = empty_config();
2613 let section = make_section();
2614 let remote = vec![ProviderHost {
2615 server_id: "1".to_string(),
2616 name: "web".to_string(),
2617 ip: "1.2.3.4".to_string(),
2618 tags: Vec::new(),
2619 metadata: vec![("region".to_string(), "nyc3".to_string())],
2620 }];
2621 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2622
2623 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2625 assert_eq!(result.unchanged, 1);
2626 assert_eq!(result.updated, 0);
2627 }
2628
2629 #[test]
2630 fn test_sync_metadata_order_insensitive() {
2631 let mut config = empty_config();
2632 let section = make_section();
2633 let remote = vec![ProviderHost {
2634 server_id: "1".to_string(),
2635 name: "web".to_string(),
2636 ip: "1.2.3.4".to_string(),
2637 tags: Vec::new(),
2638 metadata: vec![
2639 ("region".to_string(), "nyc3".to_string()),
2640 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2641 ],
2642 }];
2643 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2644
2645 let remote2 = vec![ProviderHost {
2647 server_id: "1".to_string(),
2648 name: "web".to_string(),
2649 ip: "1.2.3.4".to_string(),
2650 tags: Vec::new(),
2651 metadata: vec![
2652 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2653 ("region".to_string(), "nyc3".to_string()),
2654 ],
2655 }];
2656 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2657 assert_eq!(result.unchanged, 1);
2658 assert_eq!(result.updated, 0);
2659 }
2660
2661 #[test]
2662 fn test_sync_metadata_with_rename() {
2663 let mut config = empty_config();
2664 let section = make_section();
2665 let remote = vec![ProviderHost {
2666 server_id: "1".to_string(),
2667 name: "old-name".to_string(),
2668 ip: "1.2.3.4".to_string(),
2669 tags: Vec::new(),
2670 metadata: vec![("region".to_string(), "nyc3".to_string())],
2671 }];
2672 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2673 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
2674
2675 let remote2 = vec![ProviderHost {
2677 server_id: "1".to_string(),
2678 name: "new-name".to_string(),
2679 ip: "1.2.3.4".to_string(),
2680 tags: Vec::new(),
2681 metadata: vec![("region".to_string(), "sfo3".to_string())],
2682 }];
2683 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2684 assert_eq!(result.updated, 1);
2685 assert!(!result.renames.is_empty());
2686 let entries = config.host_entries();
2687 assert_eq!(entries[0].alias, "do-new-name");
2688 assert_eq!(entries[0].provider_meta[0].1, "sfo3");
2689 }
2690
2691 #[test]
2692 fn test_sync_metadata_dry_run_no_mutation() {
2693 let mut config = empty_config();
2694 let section = make_section();
2695 let remote = vec![ProviderHost {
2696 server_id: "1".to_string(),
2697 name: "web".to_string(),
2698 ip: "1.2.3.4".to_string(),
2699 tags: Vec::new(),
2700 metadata: vec![("region".to_string(), "nyc3".to_string())],
2701 }];
2702 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2703
2704 let remote2 = vec![ProviderHost {
2706 server_id: "1".to_string(),
2707 name: "web".to_string(),
2708 ip: "1.2.3.4".to_string(),
2709 tags: Vec::new(),
2710 metadata: vec![("region".to_string(), "sfo3".to_string())],
2711 }];
2712 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
2713 assert_eq!(result.updated, 1);
2714 assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
2716 }
2717
2718 #[test]
2719 fn test_sync_metadata_only_change_triggers_update() {
2720 let mut config = empty_config();
2721 let section = make_section();
2722 let remote = vec![ProviderHost {
2723 server_id: "1".to_string(),
2724 name: "web".to_string(),
2725 ip: "1.2.3.4".to_string(),
2726 tags: vec!["prod".to_string()],
2727 metadata: vec![("region".to_string(), "nyc3".to_string())],
2728 }];
2729 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
2730
2731 let remote2 = vec![ProviderHost {
2733 server_id: "1".to_string(),
2734 name: "web".to_string(),
2735 ip: "1.2.3.4".to_string(),
2736 tags: vec!["prod".to_string()],
2737 metadata: vec![
2738 ("region".to_string(), "nyc3".to_string()),
2739 ("plan".to_string(), "s-1vcpu-1gb".to_string()),
2740 ],
2741 }];
2742 let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
2743 assert_eq!(result.updated, 1);
2744 assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
2745 }
2746}