1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6#[derive(Debug, Clone)]
8pub struct ProviderSection {
9 pub provider: String,
10 pub token: String,
11 pub alias_prefix: String,
12 pub user: String,
13 pub identity_file: String,
14 pub url: String,
15 pub verify_tls: bool,
16 pub auto_sync: bool,
17 pub profile: String,
18 pub regions: String,
19}
20
21fn default_auto_sync(provider: &str) -> bool {
23 !matches!(provider, "proxmox")
24}
25
26#[derive(Debug, Clone, Default)]
28pub struct ProviderConfig {
29 pub sections: Vec<ProviderSection>,
30 pub path_override: Option<PathBuf>,
33}
34
35fn config_path() -> Option<PathBuf> {
36 dirs::home_dir().map(|h| h.join(".purple/providers"))
37}
38
39impl ProviderConfig {
40 pub fn load() -> Self {
44 let path = match config_path() {
45 Some(p) => p,
46 None => return Self::default(),
47 };
48 let content = match std::fs::read_to_string(&path) {
49 Ok(c) => c,
50 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
51 Err(e) => {
52 eprintln!("! Could not read {}: {}", path.display(), e);
53 return Self::default();
54 }
55 };
56 Self::parse(&content)
57 }
58
59 fn parse(content: &str) -> Self {
61 let mut sections = Vec::new();
62 let mut current: Option<ProviderSection> = None;
63
64 for line in content.lines() {
65 let trimmed = line.trim();
66 if trimmed.is_empty() || trimmed.starts_with('#') {
67 continue;
68 }
69 if trimmed.starts_with('[') && trimmed.ends_with(']') {
70 if let Some(section) = current.take() {
71 if !sections.iter().any(|s: &ProviderSection| s.provider == section.provider) {
72 sections.push(section);
73 }
74 }
75 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
76 if sections.iter().any(|s| s.provider == name) {
77 current = None;
78 continue;
79 }
80 let short_label = super::get_provider(&name)
81 .map(|p| p.short_label().to_string())
82 .unwrap_or_else(|| name.clone());
83 let auto_sync_default = default_auto_sync(&name);
84 current = Some(ProviderSection {
85 provider: name,
86 token: String::new(),
87 alias_prefix: short_label,
88 user: "root".to_string(),
89 identity_file: String::new(),
90 url: String::new(),
91 verify_tls: true,
92 auto_sync: auto_sync_default,
93 profile: String::new(),
94 regions: String::new(),
95 });
96 } else if let Some(ref mut section) = current {
97 if let Some((key, value)) = trimmed.split_once('=') {
98 let key = key.trim();
99 let value = value.trim().to_string();
100 match key {
101 "token" => section.token = value,
102 "alias_prefix" => section.alias_prefix = value,
103 "user" => section.user = value,
104 "key" => section.identity_file = value,
105 "url" => section.url = value,
106 "verify_tls" => section.verify_tls = !matches!(
107 value.to_lowercase().as_str(), "false" | "0" | "no"
108 ),
109 "auto_sync" => section.auto_sync = !matches!(
110 value.to_lowercase().as_str(), "false" | "0" | "no"
111 ),
112 "profile" => section.profile = value,
113 "regions" => section.regions = value,
114 _ => {}
115 }
116 }
117 }
118 }
119 if let Some(section) = current {
120 if !sections.iter().any(|s| s.provider == section.provider) {
121 sections.push(section);
122 }
123 }
124 Self { sections, path_override: None }
125 }
126
127 pub fn save(&self) -> io::Result<()> {
130 let path = match &self.path_override {
131 Some(p) => p.clone(),
132 None => match config_path() {
133 Some(p) => p,
134 None => {
135 return Err(io::Error::new(
136 io::ErrorKind::NotFound,
137 "Could not determine home directory",
138 ))
139 }
140 },
141 };
142
143 let mut content = String::new();
144 for (i, section) in self.sections.iter().enumerate() {
145 if i > 0 {
146 content.push('\n');
147 }
148 content.push_str(&format!("[{}]\n", section.provider));
149 content.push_str(&format!("token={}\n", section.token));
150 content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
151 content.push_str(&format!("user={}\n", section.user));
152 if !section.identity_file.is_empty() {
153 content.push_str(&format!("key={}\n", section.identity_file));
154 }
155 if !section.url.is_empty() {
156 content.push_str(&format!("url={}\n", section.url));
157 }
158 if !section.verify_tls {
159 content.push_str("verify_tls=false\n");
160 }
161 if !section.profile.is_empty() {
162 content.push_str(&format!("profile={}\n", section.profile));
163 }
164 if !section.regions.is_empty() {
165 content.push_str(&format!("regions={}\n", section.regions));
166 }
167 if section.auto_sync != default_auto_sync(§ion.provider) {
168 content.push_str(if section.auto_sync { "auto_sync=true\n" } else { "auto_sync=false\n" });
169 }
170 }
171
172 fs_util::atomic_write(&path, content.as_bytes())
173 }
174
175 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
177 self.sections.iter().find(|s| s.provider == provider)
178 }
179
180 pub fn set_section(&mut self, section: ProviderSection) {
182 if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
183 *existing = section;
184 } else {
185 self.sections.push(section);
186 }
187 }
188
189 pub fn remove_section(&mut self, provider: &str) {
191 self.sections.retain(|s| s.provider != provider);
192 }
193
194 pub fn configured_providers(&self) -> &[ProviderSection] {
196 &self.sections
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_parse_empty() {
206 let config = ProviderConfig::parse("");
207 assert!(config.sections.is_empty());
208 }
209
210 #[test]
211 fn test_parse_single_section() {
212 let content = "\
213[digitalocean]
214token=dop_v1_abc123
215alias_prefix=do
216user=root
217key=~/.ssh/id_ed25519
218";
219 let config = ProviderConfig::parse(content);
220 assert_eq!(config.sections.len(), 1);
221 let s = &config.sections[0];
222 assert_eq!(s.provider, "digitalocean");
223 assert_eq!(s.token, "dop_v1_abc123");
224 assert_eq!(s.alias_prefix, "do");
225 assert_eq!(s.user, "root");
226 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
227 }
228
229 #[test]
230 fn test_parse_multiple_sections() {
231 let content = "\
232[digitalocean]
233token=abc
234
235[vultr]
236token=xyz
237user=deploy
238";
239 let config = ProviderConfig::parse(content);
240 assert_eq!(config.sections.len(), 2);
241 assert_eq!(config.sections[0].provider, "digitalocean");
242 assert_eq!(config.sections[1].provider, "vultr");
243 assert_eq!(config.sections[1].user, "deploy");
244 }
245
246 #[test]
247 fn test_parse_comments_and_blanks() {
248 let content = "\
249# Provider config
250
251[linode]
252# API token
253token=mytoken
254";
255 let config = ProviderConfig::parse(content);
256 assert_eq!(config.sections.len(), 1);
257 assert_eq!(config.sections[0].token, "mytoken");
258 }
259
260 #[test]
261 fn test_set_section_add() {
262 let mut config = ProviderConfig::default();
263 config.set_section(ProviderSection {
264 provider: "vultr".to_string(),
265 token: "abc".to_string(),
266 alias_prefix: "vultr".to_string(),
267 user: "root".to_string(),
268 identity_file: String::new(),
269 url: String::new(),
270 verify_tls: true,
271 auto_sync: true,
272 profile: String::new(),
273 regions: String::new(),
274 });
275 assert_eq!(config.sections.len(), 1);
276 }
277
278 #[test]
279 fn test_set_section_replace() {
280 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
281 config.set_section(ProviderSection {
282 provider: "vultr".to_string(),
283 token: "new".to_string(),
284 alias_prefix: "vultr".to_string(),
285 user: "root".to_string(),
286 identity_file: String::new(),
287 url: String::new(),
288 verify_tls: true,
289 auto_sync: true,
290 profile: String::new(),
291 regions: String::new(),
292 });
293 assert_eq!(config.sections.len(), 1);
294 assert_eq!(config.sections[0].token, "new");
295 }
296
297 #[test]
298 fn test_remove_section() {
299 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
300 config.remove_section("vultr");
301 assert_eq!(config.sections.len(), 1);
302 assert_eq!(config.sections[0].provider, "linode");
303 }
304
305 #[test]
306 fn test_section_lookup() {
307 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
308 assert!(config.section("digitalocean").is_some());
309 assert!(config.section("vultr").is_none());
310 }
311
312 #[test]
313 fn test_parse_duplicate_sections_first_wins() {
314 let content = "\
315[digitalocean]
316token=first
317
318[digitalocean]
319token=second
320";
321 let config = ProviderConfig::parse(content);
322 assert_eq!(config.sections.len(), 1);
323 assert_eq!(config.sections[0].token, "first");
324 }
325
326 #[test]
327 fn test_parse_duplicate_sections_trailing() {
328 let content = "\
329[vultr]
330token=abc
331
332[linode]
333token=xyz
334
335[vultr]
336token=dup
337";
338 let config = ProviderConfig::parse(content);
339 assert_eq!(config.sections.len(), 2);
340 assert_eq!(config.sections[0].provider, "vultr");
341 assert_eq!(config.sections[0].token, "abc");
342 assert_eq!(config.sections[1].provider, "linode");
343 }
344
345 #[test]
346 fn test_defaults_applied() {
347 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
348 let s = &config.sections[0];
349 assert_eq!(s.user, "root");
350 assert_eq!(s.alias_prefix, "hetzner");
351 assert!(s.identity_file.is_empty());
352 assert!(s.url.is_empty());
353 assert!(s.verify_tls);
354 assert!(s.auto_sync);
355 }
356
357 #[test]
358 fn test_parse_url_and_verify_tls() {
359 let content = "\
360[proxmox]
361token=user@pam!purple=secret
362url=https://pve.example.com:8006
363verify_tls=false
364";
365 let config = ProviderConfig::parse(content);
366 assert_eq!(config.sections.len(), 1);
367 let s = &config.sections[0];
368 assert_eq!(s.url, "https://pve.example.com:8006");
369 assert!(!s.verify_tls);
370 }
371
372 #[test]
373 fn test_url_and_verify_tls_round_trip() {
374 let content = "\
375[proxmox]
376token=tok
377alias_prefix=pve
378user=root
379url=https://pve.local:8006
380verify_tls=false
381";
382 let config = ProviderConfig::parse(content);
383 let s = &config.sections[0];
384 assert_eq!(s.url, "https://pve.local:8006");
385 assert!(!s.verify_tls);
386 }
387
388 #[test]
389 fn test_verify_tls_default_true() {
390 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
392 assert!(config.sections[0].verify_tls);
393 }
394
395 #[test]
396 fn test_verify_tls_false_variants() {
397 for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
398 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
399 let config = ProviderConfig::parse(&content);
400 assert!(!config.sections[0].verify_tls, "verify_tls={} should be false", value);
401 }
402 }
403
404 #[test]
405 fn test_verify_tls_true_variants() {
406 for value in &["true", "True", "1", "yes"] {
407 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
408 let config = ProviderConfig::parse(&content);
409 assert!(config.sections[0].verify_tls, "verify_tls={} should be true", value);
410 }
411 }
412
413 #[test]
414 fn test_non_proxmox_url_not_written() {
415 let section = ProviderSection {
417 provider: "digitalocean".to_string(),
418 token: "tok".to_string(),
419 alias_prefix: "do".to_string(),
420 user: "root".to_string(),
421 identity_file: String::new(),
422 url: String::new(), verify_tls: true, auto_sync: true, profile: String::new(),
426 regions: String::new(),
427 };
428 let mut config = ProviderConfig::default();
429 config.set_section(section);
430 let s = &config.sections[0];
432 assert!(s.url.is_empty());
433 assert!(s.verify_tls);
434 }
435
436 #[test]
437 fn test_proxmox_url_fallback_in_section() {
438 let existing = ProviderConfig::parse(
440 "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
441 );
442 let existing_url = existing.section("proxmox").map(|s| s.url.clone()).unwrap_or_default();
443 assert_eq!(existing_url, "https://pve.local:8006");
444
445 let mut config = existing;
446 config.set_section(ProviderSection {
447 provider: "proxmox".to_string(),
448 token: "new".to_string(),
449 alias_prefix: "pve".to_string(),
450 user: "root".to_string(),
451 identity_file: String::new(),
452 url: existing_url,
453 verify_tls: true,
454 auto_sync: false,
455 profile: String::new(),
456 regions: String::new(),
457 });
458 assert_eq!(config.sections[0].token, "new");
459 assert_eq!(config.sections[0].url, "https://pve.local:8006");
460 }
461
462 #[test]
463 fn test_auto_sync_default_true_for_non_proxmox() {
464 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
465 assert!(config.sections[0].auto_sync);
466 }
467
468 #[test]
469 fn test_auto_sync_default_false_for_proxmox() {
470 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
471 assert!(!config.sections[0].auto_sync);
472 }
473
474 #[test]
475 fn test_auto_sync_explicit_true() {
476 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
477 assert!(config.sections[0].auto_sync);
478 }
479
480 #[test]
481 fn test_auto_sync_explicit_false_non_proxmox() {
482 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
483 assert!(!config.sections[0].auto_sync);
484 }
485
486 #[test]
487 fn test_auto_sync_not_written_when_default() {
488 let mut config = ProviderConfig::default();
490 config.set_section(ProviderSection {
491 provider: "digitalocean".to_string(),
492 token: "tok".to_string(),
493 alias_prefix: "do".to_string(),
494 user: "root".to_string(),
495 identity_file: String::new(),
496 url: String::new(),
497 verify_tls: true,
498 auto_sync: true,
499 profile: String::new(),
500 regions: String::new(),
501 });
502 assert!(config.sections[0].auto_sync);
504
505 let mut config2 = ProviderConfig::default();
507 config2.set_section(ProviderSection {
508 provider: "proxmox".to_string(),
509 token: "tok".to_string(),
510 alias_prefix: "pve".to_string(),
511 user: "root".to_string(),
512 identity_file: String::new(),
513 url: "https://pve:8006".to_string(),
514 verify_tls: true,
515 auto_sync: false,
516 profile: String::new(),
517 regions: String::new(),
518 });
519 assert!(!config2.sections[0].auto_sync);
520 }
521
522 #[test]
523 fn test_auto_sync_false_variants() {
524 for value in &["false", "False", "FALSE", "0", "no"] {
525 let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
526 let config = ProviderConfig::parse(&content);
527 assert!(!config.sections[0].auto_sync, "auto_sync={} should be false", value);
528 }
529 }
530
531 #[test]
532 fn test_auto_sync_true_variants() {
533 for value in &["true", "True", "TRUE", "1", "yes"] {
534 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n", value);
536 let config = ProviderConfig::parse(&content);
537 assert!(config.sections[0].auto_sync, "auto_sync={} should be true", value);
538 }
539 }
540
541 #[test]
542 fn test_auto_sync_malformed_value_treated_as_true() {
543 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
545 assert!(config.sections[0].auto_sync);
546 }
547
548 #[test]
549 fn test_auto_sync_written_only_when_non_default() {
550 let mut config = ProviderConfig::default();
552 config.set_section(ProviderSection {
553 provider: "proxmox".to_string(),
554 token: "tok".to_string(),
555 alias_prefix: "pve".to_string(),
556 user: "root".to_string(),
557 identity_file: String::new(),
558 url: "https://pve:8006".to_string(),
559 verify_tls: true,
560 auto_sync: true, profile: String::new(),
562 regions: String::new(),
563 });
564 let content =
566 "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
567 .to_string();
568 let reparsed = ProviderConfig::parse(&content);
569 assert!(reparsed.sections[0].auto_sync);
570
571 let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
573 let reparsed2 = ProviderConfig::parse(content2);
574 assert!(!reparsed2.sections[0].auto_sync);
575 }
576
577 #[test]
582 fn test_configured_providers_empty() {
583 let config = ProviderConfig::default();
584 assert!(config.configured_providers().is_empty());
585 }
586
587 #[test]
588 fn test_configured_providers_returns_all() {
589 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
590 let config = ProviderConfig::parse(content);
591 assert_eq!(config.configured_providers().len(), 2);
592 }
593
594 #[test]
599 fn test_parse_unknown_keys_ignored() {
600 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
601 let config = ProviderConfig::parse(content);
602 assert_eq!(config.sections.len(), 1);
603 assert_eq!(config.sections[0].token, "abc");
604 }
605
606 #[test]
607 fn test_parse_unknown_provider_still_parsed() {
608 let content = "[aws]\ntoken=secret\n";
609 let config = ProviderConfig::parse(content);
610 assert_eq!(config.sections.len(), 1);
611 assert_eq!(config.sections[0].provider, "aws");
612 }
613
614 #[test]
615 fn test_parse_whitespace_in_section_name() {
616 let content = "[ digitalocean ]\ntoken=abc\n";
617 let config = ProviderConfig::parse(content);
618 assert_eq!(config.sections.len(), 1);
619 assert_eq!(config.sections[0].provider, "digitalocean");
620 }
621
622 #[test]
623 fn test_parse_value_with_equals() {
624 let content = "[digitalocean]\ntoken=abc=def==\n";
626 let config = ProviderConfig::parse(content);
627 assert_eq!(config.sections[0].token, "abc=def==");
628 }
629
630 #[test]
631 fn test_parse_whitespace_around_key_value() {
632 let content = "[digitalocean]\n token = my-token \n";
633 let config = ProviderConfig::parse(content);
634 assert_eq!(config.sections[0].token, "my-token");
635 }
636
637 #[test]
638 fn test_parse_key_field_sets_identity_file() {
639 let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
640 let config = ProviderConfig::parse(content);
641 assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
642 }
643
644 #[test]
645 fn test_section_lookup_missing() {
646 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
647 assert!(config.section("vultr").is_none());
648 }
649
650 #[test]
651 fn test_section_lookup_found() {
652 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
653 let section = config.section("digitalocean").unwrap();
654 assert_eq!(section.token, "abc");
655 }
656
657 #[test]
658 fn test_remove_nonexistent_section_noop() {
659 let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
660 config.remove_section("vultr");
661 assert_eq!(config.sections.len(), 1);
662 }
663
664 #[test]
669 fn test_default_alias_prefix_digitalocean() {
670 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
671 assert_eq!(config.sections[0].alias_prefix, "do");
672 }
673
674 #[test]
675 fn test_default_alias_prefix_upcloud() {
676 let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
677 assert_eq!(config.sections[0].alias_prefix, "uc");
678 }
679
680 #[test]
681 fn test_default_alias_prefix_proxmox() {
682 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
683 assert_eq!(config.sections[0].alias_prefix, "pve");
684 }
685
686 #[test]
687 fn test_alias_prefix_override() {
688 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
689 assert_eq!(config.sections[0].alias_prefix, "ocean");
690 }
691
692 #[test]
697 fn test_default_user_is_root() {
698 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
699 assert_eq!(config.sections[0].user, "root");
700 }
701
702 #[test]
703 fn test_user_override() {
704 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
705 assert_eq!(config.sections[0].user, "admin");
706 }
707
708 #[test]
713 fn test_proxmox_url_parsed() {
714 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
715 assert_eq!(config.sections[0].url, "https://pve.local:8006");
716 }
717
718 #[test]
719 fn test_non_proxmox_url_parsed_but_ignored() {
720 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
722 assert_eq!(config.sections[0].url, "https://api.do.com");
723 }
724
725 #[test]
730 fn test_duplicate_section_first_wins() {
731 let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
732 let config = ProviderConfig::parse(content);
733 assert_eq!(config.sections.len(), 1);
734 assert_eq!(config.sections[0].token, "first");
735 }
736
737 #[test]
746 fn test_auto_sync_default_proxmox_false() {
747 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
748 assert!(!config.sections[0].auto_sync);
749 }
750
751 #[test]
752 fn test_auto_sync_default_all_others_true() {
753 for provider in &["digitalocean", "vultr", "linode", "hetzner", "upcloud", "aws", "scaleway"] {
754 let content = format!("[{}]\ntoken=abc\n", provider);
755 let config = ProviderConfig::parse(&content);
756 assert!(config.sections[0].auto_sync, "auto_sync should default to true for {}", provider);
757 }
758 }
759
760 #[test]
761 fn test_auto_sync_override_proxmox_to_true() {
762 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
763 assert!(config.sections[0].auto_sync);
764 }
765
766 #[test]
767 fn test_auto_sync_override_do_to_false() {
768 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
769 assert!(!config.sections[0].auto_sync);
770 }
771
772 #[test]
777 fn test_set_section_adds_new() {
778 let mut config = ProviderConfig::default();
779 let section = ProviderSection {
780 provider: "vultr".to_string(),
781 token: "tok".to_string(),
782 alias_prefix: "vultr".to_string(),
783 user: "root".to_string(),
784 identity_file: String::new(),
785 url: String::new(),
786 verify_tls: true,
787 auto_sync: true,
788 profile: String::new(),
789 regions: String::new(),
790 };
791 config.set_section(section);
792 assert_eq!(config.sections.len(), 1);
793 assert_eq!(config.sections[0].provider, "vultr");
794 }
795
796 #[test]
797 fn test_set_section_replaces_existing() {
798 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
799 assert_eq!(config.sections[0].token, "old");
800 let section = ProviderSection {
801 provider: "vultr".to_string(),
802 token: "new".to_string(),
803 alias_prefix: "vultr".to_string(),
804 user: "root".to_string(),
805 identity_file: String::new(),
806 url: String::new(),
807 verify_tls: true,
808 auto_sync: true,
809 profile: String::new(),
810 regions: String::new(),
811 };
812 config.set_section(section);
813 assert_eq!(config.sections.len(), 1);
814 assert_eq!(config.sections[0].token, "new");
815 }
816
817 #[test]
818 fn test_remove_section_keeps_others() {
819 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
820 assert_eq!(config.sections.len(), 2);
821 config.remove_section("vultr");
822 assert_eq!(config.sections.len(), 1);
823 assert_eq!(config.sections[0].provider, "linode");
824 }
825
826 #[test]
831 fn test_comments_ignored() {
832 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
833 let config = ProviderConfig::parse(content);
834 assert_eq!(config.sections.len(), 1);
835 assert_eq!(config.sections[0].token, "abc");
836 }
837
838 #[test]
839 fn test_blank_lines_ignored() {
840 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
841 let config = ProviderConfig::parse(content);
842 assert_eq!(config.sections.len(), 1);
843 assert_eq!(config.sections[0].token, "abc");
844 }
845
846 #[test]
851 fn test_multiple_providers() {
852 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
853 let config = ProviderConfig::parse(content);
854 assert_eq!(config.sections.len(), 3);
855 assert_eq!(config.sections[0].provider, "digitalocean");
856 assert_eq!(config.sections[1].provider, "vultr");
857 assert_eq!(config.sections[2].provider, "proxmox");
858 assert_eq!(config.sections[2].url, "https://pve:8006");
859 }
860
861 #[test]
866 fn test_token_with_equals_sign() {
867 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
869 let config = ProviderConfig::parse(content);
870 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
872 }
873
874 #[test]
875 fn test_proxmox_token_with_exclamation() {
876 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
877 let config = ProviderConfig::parse(content);
878 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
879 }
880
881 #[test]
886 fn test_serialize_roundtrip_single_provider() {
887 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
888 let config = ProviderConfig::parse(content);
889 let mut serialized = String::new();
890 for section in &config.sections {
891 serialized.push_str(&format!("[{}]\n", section.provider));
892 serialized.push_str(&format!("token={}\n", section.token));
893 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
894 serialized.push_str(&format!("user={}\n", section.user));
895 }
896 let reparsed = ProviderConfig::parse(&serialized);
897 assert_eq!(reparsed.sections.len(), 1);
898 assert_eq!(reparsed.sections[0].token, "abc");
899 assert_eq!(reparsed.sections[0].alias_prefix, "do");
900 assert_eq!(reparsed.sections[0].user, "root");
901 }
902
903 #[test]
908 fn test_verify_tls_values() {
909 for (val, expected) in [
910 ("false", false), ("False", false), ("FALSE", false),
911 ("0", false), ("no", false), ("No", false), ("NO", false),
912 ("true", true), ("True", true), ("1", true), ("yes", true),
913 ("anything", true), ] {
915 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
916 let config = ProviderConfig::parse(&content);
917 assert_eq!(
918 config.sections[0].verify_tls, expected,
919 "verify_tls={} should be {}",
920 val, expected
921 );
922 }
923 }
924
925 #[test]
930 fn test_auto_sync_values() {
931 for (val, expected) in [
932 ("false", false), ("False", false), ("FALSE", false),
933 ("0", false), ("no", false), ("No", false),
934 ("true", true), ("1", true), ("yes", true),
935 ] {
936 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
937 let config = ProviderConfig::parse(&content);
938 assert_eq!(
939 config.sections[0].auto_sync, expected,
940 "auto_sync={} should be {}",
941 val, expected
942 );
943 }
944 }
945
946 #[test]
951 fn test_default_user_root_when_not_specified() {
952 let content = "[digitalocean]\ntoken=abc\n";
953 let config = ProviderConfig::parse(content);
954 assert_eq!(config.sections[0].user, "root");
955 }
956
957 #[test]
958 fn test_default_alias_prefix_from_short_label() {
959 let content = "[digitalocean]\ntoken=abc\n";
961 let config = ProviderConfig::parse(content);
962 assert_eq!(config.sections[0].alias_prefix, "do");
963 }
964
965 #[test]
966 fn test_default_alias_prefix_unknown_provider() {
967 let content = "[unknown_cloud]\ntoken=abc\n";
969 let config = ProviderConfig::parse(content);
970 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
971 }
972
973 #[test]
974 fn test_default_identity_file_empty() {
975 let content = "[digitalocean]\ntoken=abc\n";
976 let config = ProviderConfig::parse(content);
977 assert!(config.sections[0].identity_file.is_empty());
978 }
979
980 #[test]
981 fn test_default_url_empty() {
982 let content = "[digitalocean]\ntoken=abc\n";
983 let config = ProviderConfig::parse(content);
984 assert!(config.sections[0].url.is_empty());
985 }
986
987 #[test]
992 fn test_configured_providers_returns_all_sections() {
993 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
994 let config = ProviderConfig::parse(content);
995 assert_eq!(config.configured_providers().len(), 2);
996 }
997
998 #[test]
999 fn test_section_by_name() {
1000 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1001 let config = ProviderConfig::parse(content);
1002 let do_section = config.section("digitalocean").unwrap();
1003 assert_eq!(do_section.token, "do-tok");
1004 let vultr_section = config.section("vultr").unwrap();
1005 assert_eq!(vultr_section.token, "vultr-tok");
1006 }
1007
1008 #[test]
1009 fn test_section_not_found() {
1010 let config = ProviderConfig::parse("");
1011 assert!(config.section("nonexistent").is_none());
1012 }
1013
1014 #[test]
1019 fn test_line_without_equals_ignored() {
1020 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1021 let config = ProviderConfig::parse(content);
1022 assert_eq!(config.sections[0].token, "abc");
1023 assert_eq!(config.sections[0].user, "admin");
1024 }
1025
1026 #[test]
1027 fn test_unknown_key_ignored() {
1028 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1029 let config = ProviderConfig::parse(content);
1030 assert_eq!(config.sections[0].token, "abc");
1031 assert_eq!(config.sections[0].user, "admin");
1032 }
1033
1034 #[test]
1039 fn test_whitespace_around_section_name() {
1040 let content = "[ digitalocean ]\ntoken=abc\n";
1041 let config = ProviderConfig::parse(content);
1042 assert_eq!(config.sections[0].provider, "digitalocean");
1043 }
1044
1045 #[test]
1046 fn test_whitespace_around_key_value() {
1047 let content = "[digitalocean]\n token = abc \n user = admin \n";
1048 let config = ProviderConfig::parse(content);
1049 assert_eq!(config.sections[0].token, "abc");
1050 assert_eq!(config.sections[0].user, "admin");
1051 }
1052
1053 #[test]
1058 fn test_set_section_multiple_adds() {
1059 let mut config = ProviderConfig::default();
1060 for name in ["digitalocean", "vultr", "hetzner"] {
1061 config.set_section(ProviderSection {
1062 provider: name.to_string(),
1063 token: format!("{}-tok", name),
1064 alias_prefix: name.to_string(),
1065 user: "root".to_string(),
1066 identity_file: String::new(),
1067 url: String::new(),
1068 verify_tls: true,
1069 auto_sync: true,
1070 profile: String::new(),
1071 regions: String::new(),
1072 });
1073 }
1074 assert_eq!(config.sections.len(), 3);
1075 }
1076
1077 #[test]
1078 fn test_remove_section_all() {
1079 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1080 let mut config = ProviderConfig::parse(content);
1081 config.remove_section("digitalocean");
1082 config.remove_section("vultr");
1083 assert!(config.sections.is_empty());
1084 }
1085}