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