1pub mod aws;
2pub mod azure;
3pub mod config;
4mod digitalocean;
5pub mod gcp;
6mod hetzner;
7mod linode;
8pub mod oracle;
9mod proxmox;
10pub mod scaleway;
11pub mod sync;
12mod tailscale;
13mod upcloud;
14mod vultr;
15
16use std::sync::atomic::AtomicBool;
17
18use thiserror::Error;
19
20#[derive(Debug, Clone)]
22#[allow(dead_code)]
23pub struct ProviderHost {
24 pub server_id: String,
26 pub name: String,
28 pub ip: String,
30 pub tags: Vec<String>,
32 pub metadata: Vec<(String, String)>,
34}
35
36impl ProviderHost {
37 #[allow(dead_code)]
39 pub fn new(server_id: String, name: String, ip: String, tags: Vec<String>) -> Self {
40 Self {
41 server_id,
42 name,
43 ip,
44 tags,
45 metadata: Vec::new(),
46 }
47 }
48}
49
50#[derive(Debug, Error)]
52pub enum ProviderError {
53 #[error("HTTP error: {0}")]
54 Http(String),
55 #[error("Failed to parse response: {0}")]
56 Parse(String),
57 #[error("Authentication failed. Check your API token.")]
58 AuthFailed,
59 #[error("Rate limited. Try again in a moment.")]
60 RateLimited,
61 #[error("{0}")]
62 Execute(String),
63 #[error("Cancelled.")]
64 Cancelled,
65 #[error("Partial result: {failures} of {total} failed")]
68 PartialResult {
69 hosts: Vec<ProviderHost>,
70 failures: usize,
71 total: usize,
72 },
73}
74
75pub trait Provider {
77 fn name(&self) -> &str;
79 fn short_label(&self) -> &str;
81 fn fetch_hosts_cancellable(
83 &self,
84 token: &str,
85 cancel: &AtomicBool,
86 ) -> Result<Vec<ProviderHost>, ProviderError>;
87 #[allow(dead_code)]
89 fn fetch_hosts(&self, token: &str) -> Result<Vec<ProviderHost>, ProviderError> {
90 self.fetch_hosts_cancellable(token, &AtomicBool::new(false))
91 }
92 fn fetch_hosts_with_progress(
94 &self,
95 token: &str,
96 cancel: &AtomicBool,
97 _progress: &dyn Fn(&str),
98 ) -> Result<Vec<ProviderHost>, ProviderError> {
99 self.fetch_hosts_cancellable(token, cancel)
100 }
101}
102
103pub const PROVIDER_NAMES: &[&str] = &[
105 "digitalocean",
106 "vultr",
107 "linode",
108 "hetzner",
109 "upcloud",
110 "proxmox",
111 "aws",
112 "scaleway",
113 "gcp",
114 "azure",
115 "tailscale",
116 "oracle",
117];
118
119pub fn get_provider(name: &str) -> Option<Box<dyn Provider>> {
121 match name {
122 "digitalocean" => Some(Box::new(digitalocean::DigitalOcean)),
123 "vultr" => Some(Box::new(vultr::Vultr)),
124 "linode" => Some(Box::new(linode::Linode)),
125 "hetzner" => Some(Box::new(hetzner::Hetzner)),
126 "upcloud" => Some(Box::new(upcloud::UpCloud)),
127 "proxmox" => Some(Box::new(proxmox::Proxmox {
128 base_url: String::new(),
129 verify_tls: true,
130 })),
131 "aws" => Some(Box::new(aws::Aws {
132 regions: Vec::new(),
133 profile: String::new(),
134 })),
135 "scaleway" => Some(Box::new(scaleway::Scaleway { zones: Vec::new() })),
136 "gcp" => Some(Box::new(gcp::Gcp {
137 zones: Vec::new(),
138 project: String::new(),
139 })),
140 "azure" => Some(Box::new(azure::Azure {
141 subscriptions: Vec::new(),
142 })),
143 "tailscale" => Some(Box::new(tailscale::Tailscale)),
144 "oracle" => Some(Box::new(oracle::Oracle {
145 regions: Vec::new(),
146 compartment: String::new(),
147 })),
148 _ => None,
149 }
150}
151
152pub fn get_provider_with_config(
156 name: &str,
157 section: &config::ProviderSection,
158) -> Option<Box<dyn Provider>> {
159 match name {
160 "proxmox" => Some(Box::new(proxmox::Proxmox {
161 base_url: section.url.clone(),
162 verify_tls: section.verify_tls,
163 })),
164 "aws" => Some(Box::new(aws::Aws {
165 regions: section
166 .regions
167 .split(',')
168 .map(|s| s.trim().to_string())
169 .filter(|s| !s.is_empty())
170 .collect(),
171 profile: section.profile.clone(),
172 })),
173 "scaleway" => Some(Box::new(scaleway::Scaleway {
174 zones: section
175 .regions
176 .split(',')
177 .map(|s| s.trim().to_string())
178 .filter(|s| !s.is_empty())
179 .collect(),
180 })),
181 "gcp" => Some(Box::new(gcp::Gcp {
182 zones: section
183 .regions
184 .split(',')
185 .map(|s| s.trim().to_string())
186 .filter(|s| !s.is_empty())
187 .collect(),
188 project: section.project.clone(),
189 })),
190 "azure" => Some(Box::new(azure::Azure {
191 subscriptions: section
192 .regions
193 .split(',')
194 .map(|s| s.trim().to_string())
195 .filter(|s| !s.is_empty())
196 .collect(),
197 })),
198 "oracle" => Some(Box::new(oracle::Oracle {
199 regions: section
200 .regions
201 .split(',')
202 .map(|s| s.trim().to_string())
203 .filter(|s| !s.is_empty())
204 .collect(),
205 compartment: section.compartment.clone(),
206 })),
207 _ => get_provider(name),
208 }
209}
210
211pub fn provider_display_name(name: &str) -> &str {
213 match name {
214 "digitalocean" => "DigitalOcean",
215 "vultr" => "Vultr",
216 "linode" => "Linode",
217 "hetzner" => "Hetzner",
218 "upcloud" => "UpCloud",
219 "proxmox" => "Proxmox VE",
220 "aws" => "AWS EC2",
221 "scaleway" => "Scaleway",
222 "gcp" => "GCP",
223 "azure" => "Azure",
224 "tailscale" => "Tailscale",
225 "oracle" => "Oracle Cloud",
226 other => other,
227 }
228}
229
230pub(crate) fn http_agent() -> ureq::Agent {
232 ureq::Agent::config_builder()
233 .timeout_global(Some(std::time::Duration::from_secs(30)))
234 .max_redirects(0)
235 .build()
236 .new_agent()
237}
238
239pub(crate) fn http_agent_insecure() -> Result<ureq::Agent, ProviderError> {
241 Ok(ureq::Agent::config_builder()
242 .timeout_global(Some(std::time::Duration::from_secs(30)))
243 .max_redirects(0)
244 .tls_config(
245 ureq::tls::TlsConfig::builder()
246 .provider(ureq::tls::TlsProvider::NativeTls)
247 .disable_verification(true)
248 .build(),
249 )
250 .build()
251 .new_agent())
252}
253
254pub(crate) fn strip_cidr(ip: &str) -> &str {
258 if let Some(pos) = ip.rfind('/') {
260 if ip[pos + 1..].bytes().all(|b| b.is_ascii_digit()) && pos + 1 < ip.len() {
261 return &ip[..pos];
262 }
263 }
264 ip
265}
266
267pub(crate) fn percent_encode(s: &str) -> String {
270 let mut result = String::with_capacity(s.len());
271 for byte in s.bytes() {
272 match byte {
273 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
274 result.push(byte as char);
275 }
276 _ => {
277 result.push_str(&format!("%{:02X}", byte));
278 }
279 }
280 }
281 result
282}
283
284pub(crate) struct EpochDate {
286 pub year: u64,
287 pub month: u64, pub day: u64, pub hours: u64,
290 pub minutes: u64,
291 pub seconds: u64,
292 pub epoch_days: u64,
294}
295
296pub(crate) fn epoch_to_date(epoch_secs: u64) -> EpochDate {
298 let secs_per_day = 86400u64;
299 let epoch_days = epoch_secs / secs_per_day;
300 let mut remaining_days = epoch_days;
301 let day_secs = epoch_secs % secs_per_day;
302
303 let mut year = 1970u64;
304 loop {
305 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
306 let days_in_year = if leap { 366 } else { 365 };
307 if remaining_days < days_in_year {
308 break;
309 }
310 remaining_days -= days_in_year;
311 year += 1;
312 }
313
314 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
315 let days_per_month: [u64; 12] = [
316 31,
317 if leap { 29 } else { 28 },
318 31,
319 30,
320 31,
321 30,
322 31,
323 31,
324 30,
325 31,
326 30,
327 31,
328 ];
329 let mut month = 0usize;
330 while month < 12 && remaining_days >= days_per_month[month] {
331 remaining_days -= days_per_month[month];
332 month += 1;
333 }
334
335 EpochDate {
336 year,
337 month: (month + 1) as u64,
338 day: remaining_days + 1,
339 hours: day_secs / 3600,
340 minutes: (day_secs % 3600) / 60,
341 seconds: day_secs % 60,
342 epoch_days,
343 }
344}
345
346fn map_ureq_error(err: ureq::Error) -> ProviderError {
348 match err {
349 ureq::Error::StatusCode(code) => match code {
350 401 | 403 => ProviderError::AuthFailed,
351 429 => ProviderError::RateLimited,
352 _ => ProviderError::Http(format!("HTTP {}", code)),
353 },
354 other => ProviderError::Http(other.to_string()),
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
367 fn test_strip_cidr_ipv6_with_prefix() {
368 assert_eq!(strip_cidr("2600:3c00::1/128"), "2600:3c00::1");
369 assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
370 }
371
372 #[test]
373 fn test_strip_cidr_bare_ipv6() {
374 assert_eq!(strip_cidr("2600:3c00::1"), "2600:3c00::1");
375 }
376
377 #[test]
378 fn test_strip_cidr_ipv4_passthrough() {
379 assert_eq!(strip_cidr("1.2.3.4"), "1.2.3.4");
380 assert_eq!(strip_cidr("10.0.0.1/24"), "10.0.0.1");
381 }
382
383 #[test]
384 fn test_strip_cidr_empty() {
385 assert_eq!(strip_cidr(""), "");
386 }
387
388 #[test]
389 fn test_strip_cidr_slash_without_digits() {
390 assert_eq!(strip_cidr("path/to/something"), "path/to/something");
392 }
393
394 #[test]
395 fn test_strip_cidr_trailing_slash() {
396 assert_eq!(strip_cidr("1.2.3.4/"), "1.2.3.4/");
398 }
399
400 #[test]
405 fn test_percent_encode_unreserved_passthrough() {
406 assert_eq!(percent_encode("abc123-_.~"), "abc123-_.~");
407 }
408
409 #[test]
410 fn test_percent_encode_spaces_and_specials() {
411 assert_eq!(percent_encode("hello world"), "hello%20world");
412 assert_eq!(percent_encode("a=b&c"), "a%3Db%26c");
413 assert_eq!(percent_encode("/path"), "%2Fpath");
414 }
415
416 #[test]
417 fn test_percent_encode_empty() {
418 assert_eq!(percent_encode(""), "");
419 }
420
421 #[test]
422 fn test_percent_encode_plus_equals_slash() {
423 assert_eq!(percent_encode("a+b=c/d"), "a%2Bb%3Dc%2Fd");
424 }
425
426 #[test]
431 fn test_epoch_to_date_unix_epoch() {
432 let d = epoch_to_date(0);
433 assert_eq!((d.year, d.month, d.day), (1970, 1, 1));
434 assert_eq!((d.hours, d.minutes, d.seconds), (0, 0, 0));
435 }
436
437 #[test]
438 fn test_epoch_to_date_known_date() {
439 let d = epoch_to_date(1705321845);
441 assert_eq!((d.year, d.month, d.day), (2024, 1, 15));
442 assert_eq!((d.hours, d.minutes, d.seconds), (12, 30, 45));
443 }
444
445 #[test]
446 fn test_epoch_to_date_leap_year() {
447 let d = epoch_to_date(1709164800);
449 assert_eq!((d.year, d.month, d.day), (2024, 2, 29));
450 }
451
452 #[test]
453 fn test_epoch_to_date_end_of_year() {
454 let d = epoch_to_date(1704067199);
456 assert_eq!((d.year, d.month, d.day), (2023, 12, 31));
457 assert_eq!((d.hours, d.minutes, d.seconds), (23, 59, 59));
458 }
459
460 #[test]
465 fn test_get_provider_digitalocean() {
466 let p = get_provider("digitalocean").unwrap();
467 assert_eq!(p.name(), "digitalocean");
468 assert_eq!(p.short_label(), "do");
469 }
470
471 #[test]
472 fn test_get_provider_vultr() {
473 let p = get_provider("vultr").unwrap();
474 assert_eq!(p.name(), "vultr");
475 assert_eq!(p.short_label(), "vultr");
476 }
477
478 #[test]
479 fn test_get_provider_linode() {
480 let p = get_provider("linode").unwrap();
481 assert_eq!(p.name(), "linode");
482 assert_eq!(p.short_label(), "linode");
483 }
484
485 #[test]
486 fn test_get_provider_hetzner() {
487 let p = get_provider("hetzner").unwrap();
488 assert_eq!(p.name(), "hetzner");
489 assert_eq!(p.short_label(), "hetzner");
490 }
491
492 #[test]
493 fn test_get_provider_upcloud() {
494 let p = get_provider("upcloud").unwrap();
495 assert_eq!(p.name(), "upcloud");
496 assert_eq!(p.short_label(), "uc");
497 }
498
499 #[test]
500 fn test_get_provider_proxmox() {
501 let p = get_provider("proxmox").unwrap();
502 assert_eq!(p.name(), "proxmox");
503 assert_eq!(p.short_label(), "pve");
504 }
505
506 #[test]
507 fn test_get_provider_unknown_returns_none() {
508 assert!(get_provider("unknown_provider").is_none());
509 assert!(get_provider("").is_none());
510 assert!(get_provider("DigitalOcean").is_none()); }
512
513 #[test]
514 fn test_get_provider_all_names_resolve() {
515 for name in PROVIDER_NAMES {
516 assert!(
517 get_provider(name).is_some(),
518 "Provider '{}' should resolve",
519 name
520 );
521 }
522 }
523
524 #[test]
529 fn test_get_provider_with_config_proxmox_uses_url() {
530 let section = config::ProviderSection {
531 provider: "proxmox".to_string(),
532 token: "user@pam!token=secret".to_string(),
533 alias_prefix: "pve-".to_string(),
534 user: String::new(),
535 identity_file: String::new(),
536 url: "https://pve.example.com:8006".to_string(),
537 verify_tls: false,
538 auto_sync: false,
539 profile: String::new(),
540 regions: String::new(),
541 project: String::new(),
542 compartment: String::new(),
543 };
544 let p = get_provider_with_config("proxmox", §ion).unwrap();
545 assert_eq!(p.name(), "proxmox");
546 }
547
548 #[test]
549 fn test_get_provider_with_config_non_proxmox_delegates() {
550 let section = config::ProviderSection {
551 provider: "digitalocean".to_string(),
552 token: "do-token".to_string(),
553 alias_prefix: "do-".to_string(),
554 user: String::new(),
555 identity_file: String::new(),
556 url: String::new(),
557 verify_tls: true,
558 auto_sync: true,
559 profile: String::new(),
560 regions: String::new(),
561 project: String::new(),
562 compartment: String::new(),
563 };
564 let p = get_provider_with_config("digitalocean", §ion).unwrap();
565 assert_eq!(p.name(), "digitalocean");
566 }
567
568 #[test]
569 fn test_get_provider_with_config_gcp_uses_project_and_zones() {
570 let section = config::ProviderSection {
571 provider: "gcp".to_string(),
572 token: "sa.json".to_string(),
573 alias_prefix: "gcp".to_string(),
574 user: String::new(),
575 identity_file: String::new(),
576 url: String::new(),
577 verify_tls: true,
578 auto_sync: true,
579 profile: String::new(),
580 regions: "us-central1-a, europe-west1-b".to_string(),
581 project: "my-project".to_string(),
582 compartment: String::new(),
583 };
584 let p = get_provider_with_config("gcp", §ion).unwrap();
585 assert_eq!(p.name(), "gcp");
586 }
587
588 #[test]
589 fn test_get_provider_with_config_unknown_returns_none() {
590 let section = config::ProviderSection {
591 provider: "unknown_provider".to_string(),
592 token: String::new(),
593 alias_prefix: String::new(),
594 user: String::new(),
595 identity_file: String::new(),
596 url: String::new(),
597 verify_tls: true,
598 auto_sync: true,
599 profile: String::new(),
600 regions: String::new(),
601 project: String::new(),
602 compartment: String::new(),
603 };
604 assert!(get_provider_with_config("unknown_provider", §ion).is_none());
605 }
606
607 #[test]
612 fn test_display_name_all_providers() {
613 assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
614 assert_eq!(provider_display_name("vultr"), "Vultr");
615 assert_eq!(provider_display_name("linode"), "Linode");
616 assert_eq!(provider_display_name("hetzner"), "Hetzner");
617 assert_eq!(provider_display_name("upcloud"), "UpCloud");
618 assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
619 assert_eq!(provider_display_name("aws"), "AWS EC2");
620 assert_eq!(provider_display_name("scaleway"), "Scaleway");
621 assert_eq!(provider_display_name("gcp"), "GCP");
622 assert_eq!(provider_display_name("azure"), "Azure");
623 assert_eq!(provider_display_name("tailscale"), "Tailscale");
624 assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
625 }
626
627 #[test]
628 fn test_display_name_unknown_returns_input() {
629 assert_eq!(
630 provider_display_name("unknown_provider"),
631 "unknown_provider"
632 );
633 assert_eq!(provider_display_name(""), "");
634 }
635
636 #[test]
641 fn test_provider_names_count() {
642 assert_eq!(PROVIDER_NAMES.len(), 12);
643 }
644
645 #[test]
646 fn test_provider_names_contains_all() {
647 assert!(PROVIDER_NAMES.contains(&"digitalocean"));
648 assert!(PROVIDER_NAMES.contains(&"vultr"));
649 assert!(PROVIDER_NAMES.contains(&"linode"));
650 assert!(PROVIDER_NAMES.contains(&"hetzner"));
651 assert!(PROVIDER_NAMES.contains(&"upcloud"));
652 assert!(PROVIDER_NAMES.contains(&"proxmox"));
653 assert!(PROVIDER_NAMES.contains(&"aws"));
654 assert!(PROVIDER_NAMES.contains(&"scaleway"));
655 assert!(PROVIDER_NAMES.contains(&"gcp"));
656 assert!(PROVIDER_NAMES.contains(&"azure"));
657 assert!(PROVIDER_NAMES.contains(&"tailscale"));
658 assert!(PROVIDER_NAMES.contains(&"oracle"));
659 }
660
661 #[test]
666 fn test_provider_error_display_http() {
667 let err = ProviderError::Http("connection refused".to_string());
668 assert_eq!(format!("{}", err), "HTTP error: connection refused");
669 }
670
671 #[test]
672 fn test_provider_error_display_parse() {
673 let err = ProviderError::Parse("invalid JSON".to_string());
674 assert_eq!(format!("{}", err), "Failed to parse response: invalid JSON");
675 }
676
677 #[test]
678 fn test_provider_error_display_auth() {
679 let err = ProviderError::AuthFailed;
680 assert!(format!("{}", err).contains("Authentication failed"));
681 }
682
683 #[test]
684 fn test_provider_error_display_rate_limited() {
685 let err = ProviderError::RateLimited;
686 assert!(format!("{}", err).contains("Rate limited"));
687 }
688
689 #[test]
690 fn test_provider_error_display_cancelled() {
691 let err = ProviderError::Cancelled;
692 assert_eq!(format!("{}", err), "Cancelled.");
693 }
694
695 #[test]
696 fn test_provider_error_display_partial_result() {
697 let err = ProviderError::PartialResult {
698 hosts: vec![],
699 failures: 3,
700 total: 10,
701 };
702 assert!(format!("{}", err).contains("3 of 10 failed"));
703 }
704
705 #[test]
710 fn test_provider_host_construction() {
711 let host = ProviderHost::new(
712 "12345".to_string(),
713 "web-01".to_string(),
714 "1.2.3.4".to_string(),
715 vec!["prod".to_string(), "web".to_string()],
716 );
717 assert_eq!(host.server_id, "12345");
718 assert_eq!(host.name, "web-01");
719 assert_eq!(host.ip, "1.2.3.4");
720 assert_eq!(host.tags.len(), 2);
721 }
722
723 #[test]
724 fn test_provider_host_clone() {
725 let host = ProviderHost::new(
726 "1".to_string(),
727 "a".to_string(),
728 "1.1.1.1".to_string(),
729 vec![],
730 );
731 let cloned = host.clone();
732 assert_eq!(cloned.server_id, host.server_id);
733 assert_eq!(cloned.name, host.name);
734 }
735
736 #[test]
741 fn test_strip_cidr_ipv6_with_64() {
742 assert_eq!(strip_cidr("2a01:4f8::1/64"), "2a01:4f8::1");
743 }
744
745 #[test]
746 fn test_strip_cidr_ipv4_with_32() {
747 assert_eq!(strip_cidr("1.2.3.4/32"), "1.2.3.4");
748 }
749
750 #[test]
751 fn test_strip_cidr_ipv4_with_8() {
752 assert_eq!(strip_cidr("10.0.0.1/8"), "10.0.0.1");
753 }
754
755 #[test]
756 fn test_strip_cidr_just_slash() {
757 assert_eq!(strip_cidr("/"), "/");
759 }
760
761 #[test]
762 fn test_strip_cidr_slash_with_letters() {
763 assert_eq!(strip_cidr("10.0.0.1/abc"), "10.0.0.1/abc");
764 }
765
766 #[test]
767 fn test_strip_cidr_multiple_slashes() {
768 assert_eq!(strip_cidr("10.0.0.1/24/48"), "10.0.0.1/24");
770 }
771
772 #[test]
773 fn test_strip_cidr_ipv6_full_notation() {
774 assert_eq!(
775 strip_cidr("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128"),
776 "2001:0db8:85a3:0000:0000:8a2e:0370:7334"
777 );
778 }
779
780 #[test]
785 fn test_provider_error_debug_http() {
786 let err = ProviderError::Http("timeout".to_string());
787 let debug = format!("{:?}", err);
788 assert!(debug.contains("Http"));
789 assert!(debug.contains("timeout"));
790 }
791
792 #[test]
793 fn test_provider_error_debug_partial_result() {
794 let err = ProviderError::PartialResult {
795 hosts: vec![ProviderHost::new(
796 "1".to_string(),
797 "web".to_string(),
798 "1.2.3.4".to_string(),
799 vec![],
800 )],
801 failures: 2,
802 total: 5,
803 };
804 let debug = format!("{:?}", err);
805 assert!(debug.contains("PartialResult"));
806 assert!(debug.contains("failures: 2"));
807 }
808
809 #[test]
814 fn test_provider_host_empty_fields() {
815 let host = ProviderHost::new(String::new(), String::new(), String::new(), vec![]);
816 assert!(host.server_id.is_empty());
817 assert!(host.name.is_empty());
818 assert!(host.ip.is_empty());
819 }
820
821 #[test]
826 fn test_get_provider_with_config_all_providers() {
827 for &name in PROVIDER_NAMES {
828 let section = config::ProviderSection {
829 provider: name.to_string(),
830 token: "tok".to_string(),
831 alias_prefix: "test".to_string(),
832 user: String::new(),
833 identity_file: String::new(),
834 url: if name == "proxmox" {
835 "https://pve:8006".to_string()
836 } else {
837 String::new()
838 },
839 verify_tls: true,
840 auto_sync: true,
841 profile: String::new(),
842 regions: String::new(),
843 project: String::new(),
844 compartment: String::new(),
845 };
846 let p = get_provider_with_config(name, §ion);
847 assert!(
848 p.is_some(),
849 "get_provider_with_config({}) should return Some",
850 name
851 );
852 assert_eq!(p.unwrap().name(), name);
853 }
854 }
855
856 #[test]
861 fn test_provider_fetch_hosts_delegates_to_cancellable() {
862 let provider = get_provider("digitalocean").unwrap();
863 let result = provider.fetch_hosts("fake-token");
867 assert!(result.is_err()); }
869
870 #[test]
875 fn test_strip_cidr_digit_then_letters_not_stripped() {
876 assert_eq!(strip_cidr("10.0.0.1/24abc"), "10.0.0.1/24abc");
877 }
878
879 #[test]
884 fn test_provider_display_name_all() {
885 assert_eq!(provider_display_name("digitalocean"), "DigitalOcean");
886 assert_eq!(provider_display_name("vultr"), "Vultr");
887 assert_eq!(provider_display_name("linode"), "Linode");
888 assert_eq!(provider_display_name("hetzner"), "Hetzner");
889 assert_eq!(provider_display_name("upcloud"), "UpCloud");
890 assert_eq!(provider_display_name("proxmox"), "Proxmox VE");
891 assert_eq!(provider_display_name("aws"), "AWS EC2");
892 assert_eq!(provider_display_name("scaleway"), "Scaleway");
893 assert_eq!(provider_display_name("gcp"), "GCP");
894 assert_eq!(provider_display_name("azure"), "Azure");
895 assert_eq!(provider_display_name("tailscale"), "Tailscale");
896 assert_eq!(provider_display_name("oracle"), "Oracle Cloud");
897 }
898
899 #[test]
900 fn test_provider_display_name_unknown() {
901 assert_eq!(
902 provider_display_name("unknown_provider"),
903 "unknown_provider"
904 );
905 }
906
907 #[test]
912 fn test_get_provider_all_known() {
913 for name in PROVIDER_NAMES {
914 assert!(
915 get_provider(name).is_some(),
916 "get_provider({}) should return Some",
917 name
918 );
919 }
920 }
921
922 #[test]
923 fn test_get_provider_case_sensitive_and_unknown() {
924 assert!(get_provider("unknown_provider").is_none());
925 assert!(get_provider("DigitalOcean").is_none()); assert!(get_provider("VULTR").is_none());
927 assert!(get_provider("").is_none());
928 }
929
930 #[test]
935 fn test_provider_names_has_all_twelve() {
936 assert_eq!(PROVIDER_NAMES.len(), 12);
937 assert!(PROVIDER_NAMES.contains(&"digitalocean"));
938 assert!(PROVIDER_NAMES.contains(&"proxmox"));
939 assert!(PROVIDER_NAMES.contains(&"aws"));
940 assert!(PROVIDER_NAMES.contains(&"scaleway"));
941 assert!(PROVIDER_NAMES.contains(&"azure"));
942 assert!(PROVIDER_NAMES.contains(&"tailscale"));
943 assert!(PROVIDER_NAMES.contains(&"oracle"));
944 }
945
946 #[test]
951 fn test_provider_short_labels() {
952 let cases = [
953 ("digitalocean", "do"),
954 ("vultr", "vultr"),
955 ("linode", "linode"),
956 ("hetzner", "hetzner"),
957 ("upcloud", "uc"),
958 ("proxmox", "pve"),
959 ("aws", "aws"),
960 ("scaleway", "scw"),
961 ("gcp", "gcp"),
962 ("azure", "az"),
963 ("tailscale", "ts"),
964 ];
965 for (name, expected_label) in &cases {
966 let p = get_provider(name).unwrap();
967 assert_eq!(p.short_label(), *expected_label, "short_label for {}", name);
968 }
969 }
970
971 #[test]
976 fn test_http_agent_creates_agent() {
977 let _agent = http_agent();
979 }
980
981 #[test]
982 fn test_http_agent_insecure_creates_agent() {
983 let agent = http_agent_insecure();
985 assert!(agent.is_ok());
986 }
987
988 #[test]
993 fn test_map_ureq_error_401_is_auth_failed() {
994 let err = map_ureq_error(ureq::Error::StatusCode(401));
995 assert!(matches!(err, ProviderError::AuthFailed));
996 }
997
998 #[test]
999 fn test_map_ureq_error_403_is_auth_failed() {
1000 let err = map_ureq_error(ureq::Error::StatusCode(403));
1001 assert!(matches!(err, ProviderError::AuthFailed));
1002 }
1003
1004 #[test]
1005 fn test_map_ureq_error_429_is_rate_limited() {
1006 let err = map_ureq_error(ureq::Error::StatusCode(429));
1007 assert!(matches!(err, ProviderError::RateLimited));
1008 }
1009
1010 #[test]
1011 fn test_map_ureq_error_500_is_http() {
1012 let err = map_ureq_error(ureq::Error::StatusCode(500));
1013 match err {
1014 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1015 other => panic!("expected Http, got {:?}", other),
1016 }
1017 }
1018
1019 #[test]
1020 fn test_map_ureq_error_404_is_http() {
1021 let err = map_ureq_error(ureq::Error::StatusCode(404));
1022 match err {
1023 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 404"),
1024 other => panic!("expected Http, got {:?}", other),
1025 }
1026 }
1027
1028 #[test]
1029 fn test_map_ureq_error_502_is_http() {
1030 let err = map_ureq_error(ureq::Error::StatusCode(502));
1031 match err {
1032 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 502"),
1033 other => panic!("expected Http, got {:?}", other),
1034 }
1035 }
1036
1037 #[test]
1038 fn test_map_ureq_error_503_is_http() {
1039 let err = map_ureq_error(ureq::Error::StatusCode(503));
1040 match err {
1041 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 503"),
1042 other => panic!("expected Http, got {:?}", other),
1043 }
1044 }
1045
1046 #[test]
1047 fn test_map_ureq_error_200_is_http() {
1048 let err = map_ureq_error(ureq::Error::StatusCode(200));
1050 match err {
1051 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 200"),
1052 other => panic!("expected Http, got {:?}", other),
1053 }
1054 }
1055
1056 #[test]
1057 fn test_map_ureq_error_non_status_is_http() {
1058 let err = map_ureq_error(ureq::Error::HostNotFound);
1060 match err {
1061 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1062 other => panic!("expected Http, got {:?}", other),
1063 }
1064 }
1065
1066 #[test]
1067 fn test_map_ureq_error_all_auth_codes_covered() {
1068 for code in [400, 402, 405, 406, 407, 408, 409, 410] {
1070 let err = map_ureq_error(ureq::Error::StatusCode(code));
1071 assert!(
1072 matches!(err, ProviderError::Http(_)),
1073 "status {} should be Http, not AuthFailed",
1074 code
1075 );
1076 }
1077 }
1078
1079 #[test]
1080 fn test_map_ureq_error_only_429_is_rate_limited() {
1081 for code in [428, 430, 431] {
1083 let err = map_ureq_error(ureq::Error::StatusCode(code));
1084 assert!(
1085 !matches!(err, ProviderError::RateLimited),
1086 "status {} should not be RateLimited",
1087 code
1088 );
1089 }
1090 }
1091
1092 #[test]
1093 fn test_map_ureq_error_io_error() {
1094 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1095 let err = map_ureq_error(ureq::Error::Io(io_err));
1096 match err {
1097 ProviderError::Http(msg) => assert!(msg.contains("refused"), "got: {}", msg),
1098 other => panic!("expected Http, got {:?}", other),
1099 }
1100 }
1101
1102 #[test]
1103 fn test_map_ureq_error_timeout() {
1104 let err = map_ureq_error(ureq::Error::Timeout(ureq::Timeout::Global));
1105 match err {
1106 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1107 other => panic!("expected Http, got {:?}", other),
1108 }
1109 }
1110
1111 #[test]
1112 fn test_map_ureq_error_connection_failed() {
1113 let err = map_ureq_error(ureq::Error::ConnectionFailed);
1114 match err {
1115 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1116 other => panic!("expected Http, got {:?}", other),
1117 }
1118 }
1119
1120 #[test]
1121 fn test_map_ureq_error_bad_uri() {
1122 let err = map_ureq_error(ureq::Error::BadUri("no scheme".to_string()));
1123 match err {
1124 ProviderError::Http(msg) => assert!(msg.contains("no scheme"), "got: {}", msg),
1125 other => panic!("expected Http, got {:?}", other),
1126 }
1127 }
1128
1129 #[test]
1130 fn test_map_ureq_error_too_many_redirects() {
1131 let err = map_ureq_error(ureq::Error::TooManyRedirects);
1132 match err {
1133 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1134 other => panic!("expected Http, got {:?}", other),
1135 }
1136 }
1137
1138 #[test]
1139 fn test_map_ureq_error_redirect_failed() {
1140 let err = map_ureq_error(ureq::Error::RedirectFailed);
1141 match err {
1142 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1143 other => panic!("expected Http, got {:?}", other),
1144 }
1145 }
1146
1147 #[test]
1148 fn test_map_ureq_error_all_status_codes_1xx_to_5xx() {
1149 for code in [
1151 100, 200, 201, 301, 302, 400, 401, 403, 404, 429, 500, 502, 503, 504,
1152 ] {
1153 let err = map_ureq_error(ureq::Error::StatusCode(code));
1154 match code {
1155 401 | 403 => assert!(
1156 matches!(err, ProviderError::AuthFailed),
1157 "status {} should be AuthFailed",
1158 code
1159 ),
1160 429 => assert!(
1161 matches!(err, ProviderError::RateLimited),
1162 "status {} should be RateLimited",
1163 code
1164 ),
1165 _ => assert!(
1166 matches!(err, ProviderError::Http(_)),
1167 "status {} should be Http",
1168 code
1169 ),
1170 }
1171 }
1172 }
1173
1174 #[test]
1180 fn test_http_get_json_response() {
1181 let mut server = mockito::Server::new();
1182 let mock = server
1183 .mock("GET", "/api/test")
1184 .with_status(200)
1185 .with_header("content-type", "application/json")
1186 .with_body(r#"{"name": "test-server", "id": 42}"#)
1187 .create();
1188
1189 let agent = http_agent();
1190 let mut resp = agent
1191 .get(&format!("{}/api/test", server.url()))
1192 .call()
1193 .unwrap();
1194
1195 #[derive(serde::Deserialize)]
1196 struct TestResp {
1197 name: String,
1198 id: u32,
1199 }
1200
1201 let body: TestResp = resp.body_mut().read_json().unwrap();
1202 assert_eq!(body.name, "test-server");
1203 assert_eq!(body.id, 42);
1204 mock.assert();
1205 }
1206
1207 #[test]
1208 fn test_http_get_with_bearer_header() {
1209 let mut server = mockito::Server::new();
1210 let mock = server
1211 .mock("GET", "/api/hosts")
1212 .match_header("Authorization", "Bearer my-secret-token")
1213 .with_status(200)
1214 .with_header("content-type", "application/json")
1215 .with_body(r#"{"hosts": []}"#)
1216 .create();
1217
1218 let agent = http_agent();
1219 let resp = agent
1220 .get(&format!("{}/api/hosts", server.url()))
1221 .header("Authorization", "Bearer my-secret-token")
1222 .call();
1223
1224 assert!(resp.is_ok());
1225 mock.assert();
1226 }
1227
1228 #[test]
1229 fn test_http_get_with_custom_header() {
1230 let mut server = mockito::Server::new();
1231 let mock = server
1232 .mock("GET", "/api/servers")
1233 .match_header("X-Auth-Token", "scw-token-123")
1234 .with_status(200)
1235 .with_header("content-type", "application/json")
1236 .with_body(r#"{"servers": []}"#)
1237 .create();
1238
1239 let agent = http_agent();
1240 let resp = agent
1241 .get(&format!("{}/api/servers", server.url()))
1242 .header("X-Auth-Token", "scw-token-123")
1243 .call();
1244
1245 assert!(resp.is_ok());
1246 mock.assert();
1247 }
1248
1249 #[test]
1250 fn test_http_401_maps_to_auth_failed() {
1251 let mut server = mockito::Server::new();
1252 let mock = server
1253 .mock("GET", "/api/test")
1254 .with_status(401)
1255 .with_body("Unauthorized")
1256 .create();
1257
1258 let agent = http_agent();
1259 let err = agent
1260 .get(&format!("{}/api/test", server.url()))
1261 .call()
1262 .unwrap_err();
1263
1264 let provider_err = map_ureq_error(err);
1265 assert!(matches!(provider_err, ProviderError::AuthFailed));
1266 mock.assert();
1267 }
1268
1269 #[test]
1270 fn test_http_403_maps_to_auth_failed() {
1271 let mut server = mockito::Server::new();
1272 let mock = server
1273 .mock("GET", "/api/test")
1274 .with_status(403)
1275 .with_body("Forbidden")
1276 .create();
1277
1278 let agent = http_agent();
1279 let err = agent
1280 .get(&format!("{}/api/test", server.url()))
1281 .call()
1282 .unwrap_err();
1283
1284 let provider_err = map_ureq_error(err);
1285 assert!(matches!(provider_err, ProviderError::AuthFailed));
1286 mock.assert();
1287 }
1288
1289 #[test]
1290 fn test_http_429_maps_to_rate_limited() {
1291 let mut server = mockito::Server::new();
1292 let mock = server
1293 .mock("GET", "/api/test")
1294 .with_status(429)
1295 .with_body("Too Many Requests")
1296 .create();
1297
1298 let agent = http_agent();
1299 let err = agent
1300 .get(&format!("{}/api/test", server.url()))
1301 .call()
1302 .unwrap_err();
1303
1304 let provider_err = map_ureq_error(err);
1305 assert!(matches!(provider_err, ProviderError::RateLimited));
1306 mock.assert();
1307 }
1308
1309 #[test]
1310 fn test_http_500_maps_to_http_error() {
1311 let mut server = mockito::Server::new();
1312 let mock = server
1313 .mock("GET", "/api/test")
1314 .with_status(500)
1315 .with_body("Internal Server Error")
1316 .create();
1317
1318 let agent = http_agent();
1319 let err = agent
1320 .get(&format!("{}/api/test", server.url()))
1321 .call()
1322 .unwrap_err();
1323
1324 let provider_err = map_ureq_error(err);
1325 match provider_err {
1326 ProviderError::Http(msg) => assert_eq!(msg, "HTTP 500"),
1327 other => panic!("expected Http, got {:?}", other),
1328 }
1329 mock.assert();
1330 }
1331
1332 #[test]
1333 fn test_http_post_form_encoding() {
1334 let mut server = mockito::Server::new();
1335 let mock = server
1336 .mock("POST", "/oauth/token")
1337 .match_header("content-type", "application/x-www-form-urlencoded")
1338 .match_body(
1339 "grant_type=client_credentials&client_id=my-app&client_secret=secret123&scope=api",
1340 )
1341 .with_status(200)
1342 .with_header("content-type", "application/json")
1343 .with_body(r#"{"access_token": "eyJ.abc.def"}"#)
1344 .create();
1345
1346 let agent = http_agent();
1347 let client_id = "my-app".to_string();
1348 let client_secret = "secret123".to_string();
1349 let mut resp = agent
1350 .post(&format!("{}/oauth/token", server.url()))
1351 .send_form([
1352 ("grant_type", "client_credentials"),
1353 ("client_id", client_id.as_str()),
1354 ("client_secret", client_secret.as_str()),
1355 ("scope", "api"),
1356 ])
1357 .unwrap();
1358
1359 #[derive(serde::Deserialize)]
1360 struct TokenResp {
1361 access_token: String,
1362 }
1363
1364 let body: TokenResp = resp.body_mut().read_json().unwrap();
1365 assert_eq!(body.access_token, "eyJ.abc.def");
1366 mock.assert();
1367 }
1368
1369 #[test]
1370 fn test_http_read_to_string() {
1371 let mut server = mockito::Server::new();
1372 let mock = server
1373 .mock("GET", "/api/xml")
1374 .with_status(200)
1375 .with_header("content-type", "text/xml")
1376 .with_body("<root><item>hello</item></root>")
1377 .create();
1378
1379 let agent = http_agent();
1380 let mut resp = agent
1381 .get(&format!("{}/api/xml", server.url()))
1382 .call()
1383 .unwrap();
1384
1385 let body = resp.body_mut().read_to_string().unwrap();
1386 assert_eq!(body, "<root><item>hello</item></root>");
1387 mock.assert();
1388 }
1389
1390 #[test]
1391 fn test_http_body_reader_with_take() {
1392 use std::io::Read;
1394
1395 let mut server = mockito::Server::new();
1396 let mock = server
1397 .mock("GET", "/download")
1398 .with_status(200)
1399 .with_body("binary-content-here-12345")
1400 .create();
1401
1402 let agent = http_agent();
1403 let mut resp = agent
1404 .get(&format!("{}/download", server.url()))
1405 .call()
1406 .unwrap();
1407
1408 let mut bytes = Vec::new();
1409 resp.body_mut()
1410 .as_reader()
1411 .take(1_048_576)
1412 .read_to_end(&mut bytes)
1413 .unwrap();
1414
1415 assert_eq!(bytes, b"binary-content-here-12345");
1416 mock.assert();
1417 }
1418
1419 #[test]
1420 fn test_http_body_reader_take_truncates() {
1421 use std::io::Read;
1423
1424 let mut server = mockito::Server::new();
1425 let mock = server
1426 .mock("GET", "/large")
1427 .with_status(200)
1428 .with_body("abcdefghijklmnopqrstuvwxyz")
1429 .create();
1430
1431 let agent = http_agent();
1432 let mut resp = agent
1433 .get(&format!("{}/large", server.url()))
1434 .call()
1435 .unwrap();
1436
1437 let mut bytes = Vec::new();
1438 resp.body_mut()
1439 .as_reader()
1440 .take(10) .read_to_end(&mut bytes)
1442 .unwrap();
1443
1444 assert_eq!(bytes, b"abcdefghij");
1445 mock.assert();
1446 }
1447
1448 #[test]
1449 fn test_http_no_redirects() {
1450 let mut server = mockito::Server::new();
1454 let redirect_mock = server
1455 .mock("GET", "/redirect")
1456 .with_status(302)
1457 .with_header("Location", "/target")
1458 .create();
1459 let target_mock = server.mock("GET", "/target").with_status(200).create();
1460
1461 let agent = http_agent();
1462 let resp = agent
1463 .get(&format!("{}/redirect", server.url()))
1464 .call()
1465 .unwrap();
1466
1467 assert_eq!(resp.status(), 302);
1468 redirect_mock.assert();
1469 target_mock.expect(0); }
1471
1472 #[test]
1473 fn test_http_invalid_json_returns_parse_error() {
1474 let mut server = mockito::Server::new();
1475 let mock = server
1476 .mock("GET", "/api/bad")
1477 .with_status(200)
1478 .with_header("content-type", "application/json")
1479 .with_body("this is not json")
1480 .create();
1481
1482 let agent = http_agent();
1483 let mut resp = agent
1484 .get(&format!("{}/api/bad", server.url()))
1485 .call()
1486 .unwrap();
1487
1488 #[derive(serde::Deserialize)]
1489 #[allow(dead_code)]
1490 struct Expected {
1491 name: String,
1492 }
1493
1494 let result: Result<Expected, _> = resp.body_mut().read_json();
1495 assert!(result.is_err());
1496 mock.assert();
1497 }
1498
1499 #[test]
1500 fn test_http_empty_json_body_returns_parse_error() {
1501 let mut server = mockito::Server::new();
1502 let mock = server
1503 .mock("GET", "/api/empty")
1504 .with_status(200)
1505 .with_header("content-type", "application/json")
1506 .with_body("")
1507 .create();
1508
1509 let agent = http_agent();
1510 let mut resp = agent
1511 .get(&format!("{}/api/empty", server.url()))
1512 .call()
1513 .unwrap();
1514
1515 #[derive(serde::Deserialize)]
1516 #[allow(dead_code)]
1517 struct Expected {
1518 name: String,
1519 }
1520
1521 let result: Result<Expected, _> = resp.body_mut().read_json();
1522 assert!(result.is_err());
1523 mock.assert();
1524 }
1525
1526 #[test]
1527 fn test_http_multiple_headers() {
1528 let mut server = mockito::Server::new();
1530 let mock = server
1531 .mock("GET", "/api/aws")
1532 .match_header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1533 .match_header("x-amz-date", "20260324T120000Z")
1534 .with_status(200)
1535 .with_header("content-type", "text/xml")
1536 .with_body("<result/>")
1537 .create();
1538
1539 let agent = http_agent();
1540 let mut resp = agent
1541 .get(&format!("{}/api/aws", server.url()))
1542 .header("Authorization", "AWS4-HMAC-SHA256 cred=test")
1543 .header("x-amz-date", "20260324T120000Z")
1544 .call()
1545 .unwrap();
1546
1547 let body = resp.body_mut().read_to_string().unwrap();
1548 assert_eq!(body, "<result/>");
1549 mock.assert();
1550 }
1551
1552 #[test]
1553 fn test_http_connection_refused_maps_to_http_error() {
1554 let agent = http_agent();
1556 let err = agent.get("http://127.0.0.1:1").call().unwrap_err();
1557
1558 let provider_err = map_ureq_error(err);
1559 match provider_err {
1560 ProviderError::Http(msg) => assert!(!msg.is_empty()),
1561 other => panic!("expected Http, got {:?}", other),
1562 }
1563 }
1564
1565 #[test]
1566 fn test_http_nested_json_deserialization() {
1567 let mut server = mockito::Server::new();
1569 let mock = server
1570 .mock("GET", "/api/droplets")
1571 .with_status(200)
1572 .with_header("content-type", "application/json")
1573 .with_body(
1574 r#"{
1575 "data": [
1576 {"id": "1", "name": "web-01", "ip": "1.2.3.4"},
1577 {"id": "2", "name": "web-02", "ip": "5.6.7.8"}
1578 ],
1579 "meta": {"total": 2}
1580 }"#,
1581 )
1582 .create();
1583
1584 #[derive(serde::Deserialize)]
1585 #[allow(dead_code)]
1586 struct Host {
1587 id: String,
1588 name: String,
1589 ip: String,
1590 }
1591 #[derive(serde::Deserialize)]
1592 #[allow(dead_code)]
1593 struct Meta {
1594 total: u32,
1595 }
1596 #[derive(serde::Deserialize)]
1597 #[allow(dead_code)]
1598 struct Resp {
1599 data: Vec<Host>,
1600 meta: Meta,
1601 }
1602
1603 let agent = http_agent();
1604 let mut resp = agent
1605 .get(&format!("{}/api/droplets", server.url()))
1606 .call()
1607 .unwrap();
1608
1609 let body: Resp = resp.body_mut().read_json().unwrap();
1610 assert_eq!(body.data.len(), 2);
1611 assert_eq!(body.data[0].name, "web-01");
1612 assert_eq!(body.data[1].ip, "5.6.7.8");
1613 assert_eq!(body.meta.total, 2);
1614 mock.assert();
1615 }
1616
1617 #[test]
1618 fn test_http_xml_deserialization_with_quick_xml() {
1619 let mut server = mockito::Server::new();
1621 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1622 <DescribeInstancesResponse>
1623 <reservationSet>
1624 <item>
1625 <instancesSet>
1626 <item>
1627 <instanceId>i-abc123</instanceId>
1628 <instanceState><name>running</name></instanceState>
1629 </item>
1630 </instancesSet>
1631 </item>
1632 </reservationSet>
1633 </DescribeInstancesResponse>"#;
1634
1635 let mock = server
1636 .mock("GET", "/ec2")
1637 .with_status(200)
1638 .with_header("content-type", "text/xml")
1639 .with_body(xml)
1640 .create();
1641
1642 let agent = http_agent();
1643 let mut resp = agent.get(&format!("{}/ec2", server.url())).call().unwrap();
1644
1645 let body = resp.body_mut().read_to_string().unwrap();
1646 #[derive(serde::Deserialize)]
1648 struct InstanceState {
1649 name: String,
1650 }
1651 #[derive(serde::Deserialize)]
1652 struct Instance {
1653 #[serde(rename = "instanceId")]
1654 instance_id: String,
1655 #[serde(rename = "instanceState")]
1656 instance_state: InstanceState,
1657 }
1658 #[derive(serde::Deserialize)]
1659 struct InstanceSet {
1660 item: Vec<Instance>,
1661 }
1662 #[derive(serde::Deserialize)]
1663 struct Reservation {
1664 #[serde(rename = "instancesSet")]
1665 instances_set: InstanceSet,
1666 }
1667 #[derive(serde::Deserialize)]
1668 struct ReservationSet {
1669 item: Vec<Reservation>,
1670 }
1671 #[derive(serde::Deserialize)]
1672 struct DescribeResp {
1673 #[serde(rename = "reservationSet")]
1674 reservation_set: ReservationSet,
1675 }
1676
1677 let parsed: DescribeResp = quick_xml::de::from_str(&body).unwrap();
1678 assert_eq!(
1679 parsed.reservation_set.item[0].instances_set.item[0].instance_id,
1680 "i-abc123"
1681 );
1682 assert_eq!(
1683 parsed.reservation_set.item[0].instances_set.item[0]
1684 .instance_state
1685 .name,
1686 "running"
1687 );
1688 mock.assert();
1689 }
1690}