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