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}
16
17fn sanitize_name(name: &str) -> String {
21 let mut result = String::new();
22 for c in name.chars() {
23 if c.is_ascii_alphanumeric() {
24 result.push(c.to_ascii_lowercase());
25 } else if !result.ends_with('-') {
26 result.push('-');
27 }
28 }
29 let trimmed = result.trim_matches('-').to_string();
30 if trimmed.is_empty() {
31 "server".to_string()
32 } else {
33 trimmed
34 }
35}
36
37fn provider_header(name: &str) -> &str {
39 match name {
40 "digitalocean" => "DigitalOcean",
41 "vultr" => "Vultr",
42 "linode" => "Linode",
43 "hetzner" => "Hetzner",
44 other => other,
45 }
46}
47
48pub fn sync_provider(
50 config: &mut SshConfigFile,
51 provider: &dyn Provider,
52 remote_hosts: &[ProviderHost],
53 section: &ProviderSection,
54 remove_deleted: bool,
55 dry_run: bool,
56) -> SyncResult {
57 let mut result = SyncResult::default();
58
59 let existing = config.find_hosts_by_provider(provider.name());
61 let mut existing_map: HashMap<String, String> = HashMap::new();
62 for (alias, server_id) in &existing {
63 existing_map.insert(server_id.clone(), alias.clone());
64 }
65
66 let entries_map: HashMap<String, HostEntry> = config
68 .host_entries()
69 .into_iter()
70 .map(|e| (e.alias.clone(), e))
71 .collect();
72
73 let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
75
76 let mut needs_header = !dry_run && existing_map.is_empty();
78
79 for remote in remote_hosts {
80 if !remote_ids.insert(remote.server_id.clone()) {
81 continue; }
83
84 if let Some(existing_alias) = existing_map.get(&remote.server_id) {
85 if let Some(entry) = entries_map.get(existing_alias) {
87 if entry.source_file.is_some() {
89 result.unchanged += 1;
90 continue;
91 }
92 let ip_changed = entry.hostname != remote.ip;
93 let mut sorted_local = entry.tags.clone();
94 sorted_local.sort();
95 let mut sorted_remote = remote.tags.clone();
96 sorted_remote.sort();
97 let tags_changed = sorted_local != sorted_remote;
98 if ip_changed || tags_changed {
99 if !dry_run {
100 if ip_changed {
101 let updated = HostEntry {
102 hostname: remote.ip.clone(),
103 ..entry.clone()
104 };
105 config.update_host(existing_alias, &updated);
106 }
107 if tags_changed {
108 config.set_host_tags(existing_alias, &remote.tags);
109 }
110 }
111 result.updated += 1;
112 } else {
113 result.unchanged += 1;
114 }
115 } else {
116 result.unchanged += 1;
117 }
118 } else {
119 let sanitized = sanitize_name(&remote.name);
121 let base_alias = format!("{}-{}", section.alias_prefix, sanitized);
122 let alias = if dry_run {
123 base_alias
124 } else {
125 config.deduplicate_alias(&base_alias)
126 };
127
128 if !dry_run {
129 if needs_header {
131 if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
132 config
133 .elements
134 .push(ConfigElement::GlobalLine(String::new()));
135 }
136 config
137 .elements
138 .push(ConfigElement::GlobalLine(format!(
139 "# {}",
140 provider_header(provider.name())
141 )));
142 needs_header = false;
143 }
144
145 let entry = HostEntry {
146 alias: alias.clone(),
147 hostname: remote.ip.clone(),
148 user: section.user.clone(),
149 port: 22,
150 identity_file: section.identity_file.clone(),
151 proxy_jump: String::new(),
152 source_file: None,
153 tags: remote.tags.clone(),
154 provider: Some(provider.name().to_string()),
155 };
156
157 let block = SshConfigFile::entry_to_block(&entry);
158 config.elements.push(ConfigElement::HostBlock(block));
159 config.set_host_provider(&alias, provider.name(), &remote.server_id);
160 if !remote.tags.is_empty() {
161 config.set_host_tags(&alias, &remote.tags);
162 }
163 }
164
165 result.added += 1;
166 }
167 }
168
169 if remove_deleted && !dry_run {
171 let to_remove: Vec<String> = existing_map
172 .iter()
173 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
174 .filter(|(_, alias)| {
175 entries_map
176 .get(alias.as_str())
177 .is_none_or(|e| e.source_file.is_none())
178 })
179 .map(|(_, alias)| alias.clone())
180 .collect();
181 for alias in &to_remove {
182 config.delete_host(alias);
183 }
184 result.removed = to_remove.len();
185
186 if config.find_hosts_by_provider(provider.name()).is_empty() {
188 let header_text = format!("# {}", provider_header(provider.name()));
189 config
190 .elements
191 .retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
192 }
193 } else if remove_deleted {
194 result.removed = existing_map
195 .iter()
196 .filter(|(id, _)| !remote_ids.contains(id.as_str()))
197 .filter(|(_, alias)| {
198 entries_map
199 .get(alias.as_str())
200 .is_none_or(|e| e.source_file.is_none())
201 })
202 .count();
203 }
204
205 result
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::path::PathBuf;
212
213 fn empty_config() -> SshConfigFile {
214 SshConfigFile {
215 elements: Vec::new(),
216 path: PathBuf::from("/tmp/test_config"),
217 crlf: false,
218 }
219 }
220
221 fn make_section() -> ProviderSection {
222 ProviderSection {
223 provider: "digitalocean".to_string(),
224 token: "test".to_string(),
225 alias_prefix: "do".to_string(),
226 user: "root".to_string(),
227 identity_file: String::new(),
228 }
229 }
230
231 struct MockProvider;
232 impl Provider for MockProvider {
233 fn name(&self) -> &str {
234 "digitalocean"
235 }
236 fn short_label(&self) -> &str {
237 "do"
238 }
239 fn fetch_hosts(
240 &self,
241 _token: &str,
242 ) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
243 Ok(Vec::new())
244 }
245 }
246
247 #[test]
248 fn test_sanitize_name() {
249 assert_eq!(sanitize_name("web-1"), "web-1");
250 assert_eq!(sanitize_name("My Server"), "my-server");
251 assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
252 assert_eq!(sanitize_name("--weird--"), "weird");
253 assert_eq!(sanitize_name("UPPER"), "upper");
254 assert_eq!(sanitize_name("a--b"), "a-b");
255 assert_eq!(sanitize_name(""), "server");
256 assert_eq!(sanitize_name("..."), "server");
257 }
258
259 #[test]
260 fn test_sync_adds_new_hosts() {
261 let mut config = empty_config();
262 let section = make_section();
263 let remote = vec![
264 ProviderHost {
265 server_id: "123".to_string(),
266 name: "web-1".to_string(),
267 ip: "1.2.3.4".to_string(),
268 tags: Vec::new(),
269 },
270 ProviderHost {
271 server_id: "456".to_string(),
272 name: "db-1".to_string(),
273 ip: "5.6.7.8".to_string(),
274 tags: Vec::new(),
275 },
276 ];
277
278 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
279 assert_eq!(result.added, 2);
280 assert_eq!(result.updated, 0);
281 assert_eq!(result.unchanged, 0);
282
283 let entries = config.host_entries();
284 assert_eq!(entries.len(), 2);
285 assert_eq!(entries[0].alias, "do-web-1");
286 assert_eq!(entries[0].hostname, "1.2.3.4");
287 assert_eq!(entries[1].alias, "do-db-1");
288 }
289
290 #[test]
291 fn test_sync_updates_changed_ip() {
292 let mut config = empty_config();
293 let section = make_section();
294
295 let remote = vec![ProviderHost {
297 server_id: "123".to_string(),
298 name: "web-1".to_string(),
299 ip: "1.2.3.4".to_string(),
300 tags: Vec::new(),
301 }];
302 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
303
304 let remote = vec![ProviderHost {
306 server_id: "123".to_string(),
307 name: "web-1".to_string(),
308 ip: "9.8.7.6".to_string(),
309 tags: Vec::new(),
310 }];
311 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
312 assert_eq!(result.updated, 1);
313 assert_eq!(result.added, 0);
314
315 let entries = config.host_entries();
316 assert_eq!(entries[0].hostname, "9.8.7.6");
317 }
318
319 #[test]
320 fn test_sync_unchanged() {
321 let mut config = empty_config();
322 let section = make_section();
323
324 let remote = vec![ProviderHost {
325 server_id: "123".to_string(),
326 name: "web-1".to_string(),
327 ip: "1.2.3.4".to_string(),
328 tags: Vec::new(),
329 }];
330 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
331
332 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
334 assert_eq!(result.unchanged, 1);
335 assert_eq!(result.added, 0);
336 assert_eq!(result.updated, 0);
337 }
338
339 #[test]
340 fn test_sync_removes_deleted() {
341 let mut config = empty_config();
342 let section = make_section();
343
344 let remote = vec![ProviderHost {
345 server_id: "123".to_string(),
346 name: "web-1".to_string(),
347 ip: "1.2.3.4".to_string(),
348 tags: Vec::new(),
349 }];
350 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
351 assert_eq!(config.host_entries().len(), 1);
352
353 let result =
355 sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
356 assert_eq!(result.removed, 1);
357 assert_eq!(config.host_entries().len(), 0);
358 }
359
360 #[test]
361 fn test_sync_dry_run_no_mutations() {
362 let mut config = empty_config();
363 let section = make_section();
364
365 let remote = vec![ProviderHost {
366 server_id: "123".to_string(),
367 name: "web-1".to_string(),
368 ip: "1.2.3.4".to_string(),
369 tags: Vec::new(),
370 }];
371
372 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
373 assert_eq!(result.added, 1);
374 assert_eq!(config.host_entries().len(), 0); }
376
377 #[test]
378 fn test_sync_dedup_server_id_in_response() {
379 let mut config = empty_config();
380 let section = make_section();
381 let remote = vec![
382 ProviderHost {
383 server_id: "123".to_string(),
384 name: "web-1".to_string(),
385 ip: "1.2.3.4".to_string(),
386 tags: Vec::new(),
387 },
388 ProviderHost {
389 server_id: "123".to_string(),
390 name: "web-1-dup".to_string(),
391 ip: "5.6.7.8".to_string(),
392 tags: Vec::new(),
393 },
394 ];
395
396 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
397 assert_eq!(result.added, 1);
398 assert_eq!(config.host_entries().len(), 1);
399 assert_eq!(config.host_entries()[0].alias, "do-web-1");
400 }
401
402 #[test]
403 fn test_sync_no_duplicate_header_on_repeated_sync() {
404 let mut config = empty_config();
405 let section = make_section();
406
407 let remote = vec![ProviderHost {
409 server_id: "123".to_string(),
410 name: "web-1".to_string(),
411 ip: "1.2.3.4".to_string(),
412 tags: Vec::new(),
413 }];
414 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
415
416 let remote = vec![
418 ProviderHost {
419 server_id: "123".to_string(),
420 name: "web-1".to_string(),
421 ip: "1.2.3.4".to_string(),
422 tags: Vec::new(),
423 },
424 ProviderHost {
425 server_id: "456".to_string(),
426 name: "db-1".to_string(),
427 ip: "5.6.7.8".to_string(),
428 tags: Vec::new(),
429 },
430 ];
431 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
432
433 let header_count = config
435 .elements
436 .iter()
437 .filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"))
438 .count();
439 assert_eq!(header_count, 1);
440 assert_eq!(config.host_entries().len(), 2);
441 }
442
443 #[test]
444 fn test_sync_removes_orphan_header() {
445 let mut config = empty_config();
446 let section = make_section();
447
448 let remote = vec![ProviderHost {
450 server_id: "123".to_string(),
451 name: "web-1".to_string(),
452 ip: "1.2.3.4".to_string(),
453 tags: Vec::new(),
454 }];
455 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
456
457 let has_header = config
459 .elements
460 .iter()
461 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
462 assert!(has_header);
463
464 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
466 assert_eq!(result.removed, 1);
467
468 let has_header = config
470 .elements
471 .iter()
472 .any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
473 assert!(!has_header);
474 }
475
476 #[test]
477 fn test_sync_writes_provider_tags() {
478 let mut config = empty_config();
479 let section = make_section();
480 let remote = vec![ProviderHost {
481 server_id: "123".to_string(),
482 name: "web-1".to_string(),
483 ip: "1.2.3.4".to_string(),
484 tags: vec!["production".to_string(), "us-east".to_string()],
485 }];
486
487 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
488
489 let entries = config.host_entries();
490 assert_eq!(entries[0].tags, vec!["production", "us-east"]);
491 }
492
493 #[test]
494 fn test_sync_updates_changed_tags() {
495 let mut config = empty_config();
496 let section = make_section();
497
498 let remote = vec![ProviderHost {
500 server_id: "123".to_string(),
501 name: "web-1".to_string(),
502 ip: "1.2.3.4".to_string(),
503 tags: vec!["staging".to_string()],
504 }];
505 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
506 assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
507
508 let remote = vec![ProviderHost {
510 server_id: "123".to_string(),
511 name: "web-1".to_string(),
512 ip: "1.2.3.4".to_string(),
513 tags: vec!["production".to_string(), "us-east".to_string()],
514 }];
515 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
516 assert_eq!(result.updated, 1);
517 assert_eq!(
518 config.host_entries()[0].tags,
519 vec!["production", "us-east"]
520 );
521 }
522
523 #[test]
524 fn test_sync_combined_add_update_remove() {
525 let mut config = empty_config();
526 let section = make_section();
527
528 let remote = vec![
530 ProviderHost {
531 server_id: "1".to_string(),
532 name: "web".to_string(),
533 ip: "1.1.1.1".to_string(),
534 tags: Vec::new(),
535 },
536 ProviderHost {
537 server_id: "2".to_string(),
538 name: "db".to_string(),
539 ip: "2.2.2.2".to_string(),
540 tags: Vec::new(),
541 },
542 ];
543 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
544 assert_eq!(config.host_entries().len(), 2);
545
546 let remote = vec![
548 ProviderHost {
549 server_id: "1".to_string(),
550 name: "web".to_string(),
551 ip: "9.9.9.9".to_string(),
552 tags: Vec::new(),
553 },
554 ProviderHost {
555 server_id: "3".to_string(),
556 name: "cache".to_string(),
557 ip: "3.3.3.3".to_string(),
558 tags: Vec::new(),
559 },
560 ];
561 let result =
562 sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
563 assert_eq!(result.updated, 1);
564 assert_eq!(result.added, 1);
565 assert_eq!(result.removed, 1);
566
567 let entries = config.host_entries();
568 assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
570 assert_eq!(entries[0].hostname, "9.9.9.9");
571 assert_eq!(entries[1].alias, "do-cache");
572 }
573
574 #[test]
575 fn test_sync_tag_order_insensitive() {
576 let mut config = empty_config();
577 let section = make_section();
578
579 let remote = vec![ProviderHost {
581 server_id: "123".to_string(),
582 name: "web-1".to_string(),
583 ip: "1.2.3.4".to_string(),
584 tags: vec!["beta".to_string(), "alpha".to_string()],
585 }];
586 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
587
588 let remote = vec![ProviderHost {
590 server_id: "123".to_string(),
591 name: "web-1".to_string(),
592 ip: "1.2.3.4".to_string(),
593 tags: vec!["alpha".to_string(), "beta".to_string()],
594 }];
595 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
596 assert_eq!(result.unchanged, 1);
597 assert_eq!(result.updated, 0);
598 }
599
600 fn config_with_include_provider_host() -> SshConfigFile {
601 use crate::ssh_config::model::{IncludeDirective, IncludedFile};
602
603 let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
605 let included_elements = SshConfigFile::parse_content(content);
606
607 SshConfigFile {
608 elements: vec![ConfigElement::Include(IncludeDirective {
609 raw_line: "Include conf.d/*".to_string(),
610 pattern: "conf.d/*".to_string(),
611 resolved_files: vec![IncludedFile {
612 path: PathBuf::from("/tmp/included.conf"),
613 elements: included_elements,
614 }],
615 })],
616 path: PathBuf::from("/tmp/test_config"),
617 crlf: false,
618 }
619 }
620
621 #[test]
622 fn test_sync_include_host_skips_update() {
623 let mut config = config_with_include_provider_host();
624 let section = make_section();
625
626 let remote = vec![ProviderHost {
628 server_id: "inc1".to_string(),
629 name: "included".to_string(),
630 ip: "9.9.9.9".to_string(),
631 tags: Vec::new(),
632 }];
633 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
634 assert_eq!(result.unchanged, 1);
635 assert_eq!(result.updated, 0);
636 assert_eq!(result.added, 0);
637
638 let entries = config.host_entries();
640 let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
641 assert_eq!(included.hostname, "1.2.3.4");
642 }
643
644 #[test]
645 fn test_sync_include_host_skips_remove() {
646 let mut config = config_with_include_provider_host();
647 let section = make_section();
648
649 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
651 assert_eq!(result.removed, 0);
652 assert_eq!(config.host_entries().len(), 1);
653 }
654
655 #[test]
656 fn test_sync_dry_run_remove_count() {
657 let mut config = empty_config();
658 let section = make_section();
659
660 let remote = vec![
662 ProviderHost {
663 server_id: "1".to_string(),
664 name: "web".to_string(),
665 ip: "1.1.1.1".to_string(),
666 tags: Vec::new(),
667 },
668 ProviderHost {
669 server_id: "2".to_string(),
670 name: "db".to_string(),
671 ip: "2.2.2.2".to_string(),
672 tags: Vec::new(),
673 },
674 ];
675 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
676 assert_eq!(config.host_entries().len(), 2);
677
678 let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
680 assert_eq!(result.removed, 2);
681 assert_eq!(config.host_entries().len(), 2); }
683
684 #[test]
685 fn test_sync_tags_cleared() {
686 let mut config = empty_config();
687 let section = make_section();
688
689 let remote = vec![ProviderHost {
691 server_id: "123".to_string(),
692 name: "web-1".to_string(),
693 ip: "1.2.3.4".to_string(),
694 tags: vec!["production".to_string()],
695 }];
696 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
697 assert_eq!(config.host_entries()[0].tags, vec!["production"]);
698
699 let remote = vec![ProviderHost {
701 server_id: "123".to_string(),
702 name: "web-1".to_string(),
703 ip: "1.2.3.4".to_string(),
704 tags: Vec::new(),
705 }];
706 let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
707 assert_eq!(result.updated, 1);
708 assert!(config.host_entries()[0].tags.is_empty());
709 }
710
711 #[test]
712 fn test_sync_deduplicates_alias() {
713 let content = "Host do-web-1\n HostName 10.0.0.1\n";
714 let mut config = SshConfigFile {
715 elements: SshConfigFile::parse_content(content),
716 path: PathBuf::from("/tmp/test_config"),
717 crlf: false,
718 };
719 let section = make_section();
720
721 let remote = vec![ProviderHost {
722 server_id: "999".to_string(),
723 name: "web-1".to_string(),
724 ip: "1.2.3.4".to_string(),
725 tags: Vec::new(),
726 }];
727
728 sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
729
730 let entries = config.host_entries();
731 assert_eq!(entries.len(), 2);
733 assert_eq!(entries[0].alias, "do-web-1");
734 assert_eq!(entries[1].alias, "do-web-1-2");
735 }
736}