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 pub compartment: String,
21}
22
23fn default_auto_sync(provider: &str) -> bool {
25 !matches!(provider, "proxmox")
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct ProviderConfig {
31 pub sections: Vec<ProviderSection>,
32 pub path_override: Option<PathBuf>,
35}
36
37fn config_path() -> Option<PathBuf> {
38 dirs::home_dir().map(|h| h.join(".purple/providers"))
39}
40
41impl ProviderConfig {
42 pub fn load() -> Self {
46 let path = match config_path() {
47 Some(p) => p,
48 None => return Self::default(),
49 };
50 let content = match std::fs::read_to_string(&path) {
51 Ok(c) => c,
52 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
53 Err(e) => {
54 eprintln!("! Could not read {}: {}", path.display(), e);
55 return Self::default();
56 }
57 };
58 Self::parse(&content)
59 }
60
61 fn parse(content: &str) -> Self {
63 let mut sections = Vec::new();
64 let mut current: Option<ProviderSection> = None;
65
66 for line in content.lines() {
67 let trimmed = line.trim();
68 if trimmed.is_empty() || trimmed.starts_with('#') {
69 continue;
70 }
71 if trimmed.starts_with('[') && trimmed.ends_with(']') {
72 if let Some(section) = current.take() {
73 if !sections
74 .iter()
75 .any(|s: &ProviderSection| s.provider == section.provider)
76 {
77 sections.push(section);
78 }
79 }
80 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
81 if sections.iter().any(|s| s.provider == name) {
82 current = None;
83 continue;
84 }
85 let short_label = super::get_provider(&name)
86 .map(|p| p.short_label().to_string())
87 .unwrap_or_else(|| name.clone());
88 let auto_sync_default = default_auto_sync(&name);
89 current = Some(ProviderSection {
90 provider: name,
91 token: String::new(),
92 alias_prefix: short_label,
93 user: "root".to_string(),
94 identity_file: String::new(),
95 url: String::new(),
96 verify_tls: true,
97 auto_sync: auto_sync_default,
98 profile: String::new(),
99 regions: String::new(),
100 project: String::new(),
101 compartment: String::new(),
102 });
103 } else if let Some(ref mut section) = current {
104 if let Some((key, value)) = trimmed.split_once('=') {
105 let key = key.trim();
106 let value = value.trim().to_string();
107 match key {
108 "token" => section.token = value,
109 "alias_prefix" => section.alias_prefix = value,
110 "user" => section.user = value,
111 "key" => section.identity_file = value,
112 "url" => section.url = value,
113 "verify_tls" => {
114 section.verify_tls =
115 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
116 }
117 "auto_sync" => {
118 section.auto_sync =
119 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
120 }
121 "profile" => section.profile = value,
122 "regions" => section.regions = value,
123 "project" => section.project = value,
124 "compartment" => section.compartment = value,
125 _ => {}
126 }
127 }
128 }
129 }
130 if let Some(section) = current {
131 if !sections.iter().any(|s| s.provider == section.provider) {
132 sections.push(section);
133 }
134 }
135 Self {
136 sections,
137 path_override: None,
138 }
139 }
140
141 pub fn save(&self) -> io::Result<()> {
144 let path = match &self.path_override {
145 Some(p) => p.clone(),
146 None => match config_path() {
147 Some(p) => p,
148 None => {
149 return Err(io::Error::new(
150 io::ErrorKind::NotFound,
151 "Could not determine home directory",
152 ));
153 }
154 },
155 };
156
157 let mut content = String::new();
158 for (i, section) in self.sections.iter().enumerate() {
159 if i > 0 {
160 content.push('\n');
161 }
162 content.push_str(&format!("[{}]\n", section.provider));
163 content.push_str(&format!("token={}\n", section.token));
164 content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
165 content.push_str(&format!("user={}\n", section.user));
166 if !section.identity_file.is_empty() {
167 content.push_str(&format!("key={}\n", section.identity_file));
168 }
169 if !section.url.is_empty() {
170 content.push_str(&format!("url={}\n", section.url));
171 }
172 if !section.verify_tls {
173 content.push_str("verify_tls=false\n");
174 }
175 if !section.profile.is_empty() {
176 content.push_str(&format!("profile={}\n", section.profile));
177 }
178 if !section.regions.is_empty() {
179 content.push_str(&format!("regions={}\n", section.regions));
180 }
181 if !section.project.is_empty() {
182 content.push_str(&format!("project={}\n", section.project));
183 }
184 if !section.compartment.is_empty() {
185 content.push_str(&format!("compartment={}\n", section.compartment));
186 }
187 if section.auto_sync != default_auto_sync(§ion.provider) {
188 content.push_str(if section.auto_sync {
189 "auto_sync=true\n"
190 } else {
191 "auto_sync=false\n"
192 });
193 }
194 }
195
196 fs_util::atomic_write(&path, content.as_bytes())
197 }
198
199 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
201 self.sections.iter().find(|s| s.provider == provider)
202 }
203
204 pub fn set_section(&mut self, section: ProviderSection) {
206 if let Some(existing) = self
207 .sections
208 .iter_mut()
209 .find(|s| s.provider == section.provider)
210 {
211 *existing = section;
212 } else {
213 self.sections.push(section);
214 }
215 }
216
217 pub fn remove_section(&mut self, provider: &str) {
219 self.sections.retain(|s| s.provider != provider);
220 }
221
222 pub fn configured_providers(&self) -> &[ProviderSection] {
224 &self.sections
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_parse_empty() {
234 let config = ProviderConfig::parse("");
235 assert!(config.sections.is_empty());
236 }
237
238 #[test]
239 fn test_parse_single_section() {
240 let content = "\
241[digitalocean]
242token=dop_v1_abc123
243alias_prefix=do
244user=root
245key=~/.ssh/id_ed25519
246";
247 let config = ProviderConfig::parse(content);
248 assert_eq!(config.sections.len(), 1);
249 let s = &config.sections[0];
250 assert_eq!(s.provider, "digitalocean");
251 assert_eq!(s.token, "dop_v1_abc123");
252 assert_eq!(s.alias_prefix, "do");
253 assert_eq!(s.user, "root");
254 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
255 }
256
257 #[test]
258 fn test_parse_multiple_sections() {
259 let content = "\
260[digitalocean]
261token=abc
262
263[vultr]
264token=xyz
265user=deploy
266";
267 let config = ProviderConfig::parse(content);
268 assert_eq!(config.sections.len(), 2);
269 assert_eq!(config.sections[0].provider, "digitalocean");
270 assert_eq!(config.sections[1].provider, "vultr");
271 assert_eq!(config.sections[1].user, "deploy");
272 }
273
274 #[test]
275 fn test_parse_comments_and_blanks() {
276 let content = "\
277# Provider config
278
279[linode]
280# API token
281token=mytoken
282";
283 let config = ProviderConfig::parse(content);
284 assert_eq!(config.sections.len(), 1);
285 assert_eq!(config.sections[0].token, "mytoken");
286 }
287
288 #[test]
289 fn test_set_section_add() {
290 let mut config = ProviderConfig::default();
291 config.set_section(ProviderSection {
292 provider: "vultr".to_string(),
293 token: "abc".to_string(),
294 alias_prefix: "vultr".to_string(),
295 user: "root".to_string(),
296 identity_file: String::new(),
297 url: String::new(),
298 verify_tls: true,
299 auto_sync: true,
300 profile: String::new(),
301 regions: String::new(),
302 project: String::new(),
303 compartment: String::new(),
304 });
305 assert_eq!(config.sections.len(), 1);
306 }
307
308 #[test]
309 fn test_set_section_replace() {
310 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
311 config.set_section(ProviderSection {
312 provider: "vultr".to_string(),
313 token: "new".to_string(),
314 alias_prefix: "vultr".to_string(),
315 user: "root".to_string(),
316 identity_file: String::new(),
317 url: String::new(),
318 verify_tls: true,
319 auto_sync: true,
320 profile: String::new(),
321 regions: String::new(),
322 project: String::new(),
323 compartment: String::new(),
324 });
325 assert_eq!(config.sections.len(), 1);
326 assert_eq!(config.sections[0].token, "new");
327 }
328
329 #[test]
330 fn test_remove_section() {
331 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
332 config.remove_section("vultr");
333 assert_eq!(config.sections.len(), 1);
334 assert_eq!(config.sections[0].provider, "linode");
335 }
336
337 #[test]
338 fn test_section_lookup() {
339 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
340 assert!(config.section("digitalocean").is_some());
341 assert!(config.section("vultr").is_none());
342 }
343
344 #[test]
345 fn test_parse_duplicate_sections_first_wins() {
346 let content = "\
347[digitalocean]
348token=first
349
350[digitalocean]
351token=second
352";
353 let config = ProviderConfig::parse(content);
354 assert_eq!(config.sections.len(), 1);
355 assert_eq!(config.sections[0].token, "first");
356 }
357
358 #[test]
359 fn test_parse_duplicate_sections_trailing() {
360 let content = "\
361[vultr]
362token=abc
363
364[linode]
365token=xyz
366
367[vultr]
368token=dup
369";
370 let config = ProviderConfig::parse(content);
371 assert_eq!(config.sections.len(), 2);
372 assert_eq!(config.sections[0].provider, "vultr");
373 assert_eq!(config.sections[0].token, "abc");
374 assert_eq!(config.sections[1].provider, "linode");
375 }
376
377 #[test]
378 fn test_defaults_applied() {
379 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
380 let s = &config.sections[0];
381 assert_eq!(s.user, "root");
382 assert_eq!(s.alias_prefix, "hetzner");
383 assert!(s.identity_file.is_empty());
384 assert!(s.url.is_empty());
385 assert!(s.verify_tls);
386 assert!(s.auto_sync);
387 }
388
389 #[test]
390 fn test_parse_url_and_verify_tls() {
391 let content = "\
392[proxmox]
393token=user@pam!purple=secret
394url=https://pve.example.com:8006
395verify_tls=false
396";
397 let config = ProviderConfig::parse(content);
398 assert_eq!(config.sections.len(), 1);
399 let s = &config.sections[0];
400 assert_eq!(s.url, "https://pve.example.com:8006");
401 assert!(!s.verify_tls);
402 }
403
404 #[test]
405 fn test_url_and_verify_tls_round_trip() {
406 let content = "\
407[proxmox]
408token=tok
409alias_prefix=pve
410user=root
411url=https://pve.local:8006
412verify_tls=false
413";
414 let config = ProviderConfig::parse(content);
415 let s = &config.sections[0];
416 assert_eq!(s.url, "https://pve.local:8006");
417 assert!(!s.verify_tls);
418 }
419
420 #[test]
421 fn test_verify_tls_default_true() {
422 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
424 assert!(config.sections[0].verify_tls);
425 }
426
427 #[test]
428 fn test_verify_tls_false_variants() {
429 for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
430 let content = format!(
431 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
432 value
433 );
434 let config = ProviderConfig::parse(&content);
435 assert!(
436 !config.sections[0].verify_tls,
437 "verify_tls={} should be false",
438 value
439 );
440 }
441 }
442
443 #[test]
444 fn test_verify_tls_true_variants() {
445 for value in &["true", "True", "1", "yes"] {
446 let content = format!(
447 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
448 value
449 );
450 let config = ProviderConfig::parse(&content);
451 assert!(
452 config.sections[0].verify_tls,
453 "verify_tls={} should be true",
454 value
455 );
456 }
457 }
458
459 #[test]
460 fn test_non_proxmox_url_not_written() {
461 let section = ProviderSection {
463 provider: "digitalocean".to_string(),
464 token: "tok".to_string(),
465 alias_prefix: "do".to_string(),
466 user: "root".to_string(),
467 identity_file: String::new(),
468 url: String::new(), verify_tls: true, auto_sync: true, profile: String::new(),
472 regions: String::new(),
473 project: String::new(),
474 compartment: String::new(),
475 };
476 let mut config = ProviderConfig::default();
477 config.set_section(section);
478 let s = &config.sections[0];
480 assert!(s.url.is_empty());
481 assert!(s.verify_tls);
482 }
483
484 #[test]
485 fn test_proxmox_url_fallback_in_section() {
486 let existing = ProviderConfig::parse(
488 "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
489 );
490 let existing_url = existing
491 .section("proxmox")
492 .map(|s| s.url.clone())
493 .unwrap_or_default();
494 assert_eq!(existing_url, "https://pve.local:8006");
495
496 let mut config = existing;
497 config.set_section(ProviderSection {
498 provider: "proxmox".to_string(),
499 token: "new".to_string(),
500 alias_prefix: "pve".to_string(),
501 user: "root".to_string(),
502 identity_file: String::new(),
503 url: existing_url,
504 verify_tls: true,
505 auto_sync: false,
506 profile: String::new(),
507 regions: String::new(),
508 project: String::new(),
509 compartment: String::new(),
510 });
511 assert_eq!(config.sections[0].token, "new");
512 assert_eq!(config.sections[0].url, "https://pve.local:8006");
513 }
514
515 #[test]
516 fn test_auto_sync_default_true_for_non_proxmox() {
517 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
518 assert!(config.sections[0].auto_sync);
519 }
520
521 #[test]
522 fn test_auto_sync_default_false_for_proxmox() {
523 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
524 assert!(!config.sections[0].auto_sync);
525 }
526
527 #[test]
528 fn test_auto_sync_explicit_true() {
529 let config =
530 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
531 assert!(config.sections[0].auto_sync);
532 }
533
534 #[test]
535 fn test_auto_sync_explicit_false_non_proxmox() {
536 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
537 assert!(!config.sections[0].auto_sync);
538 }
539
540 #[test]
541 fn test_auto_sync_not_written_when_default() {
542 let mut config = ProviderConfig::default();
544 config.set_section(ProviderSection {
545 provider: "digitalocean".to_string(),
546 token: "tok".to_string(),
547 alias_prefix: "do".to_string(),
548 user: "root".to_string(),
549 identity_file: String::new(),
550 url: String::new(),
551 verify_tls: true,
552 auto_sync: true,
553 profile: String::new(),
554 regions: String::new(),
555 project: String::new(),
556 compartment: String::new(),
557 });
558 assert!(config.sections[0].auto_sync);
560
561 let mut config2 = ProviderConfig::default();
563 config2.set_section(ProviderSection {
564 provider: "proxmox".to_string(),
565 token: "tok".to_string(),
566 alias_prefix: "pve".to_string(),
567 user: "root".to_string(),
568 identity_file: String::new(),
569 url: "https://pve:8006".to_string(),
570 verify_tls: true,
571 auto_sync: false,
572 profile: String::new(),
573 regions: String::new(),
574 project: String::new(),
575 compartment: String::new(),
576 });
577 assert!(!config2.sections[0].auto_sync);
578 }
579
580 #[test]
581 fn test_auto_sync_false_variants() {
582 for value in &["false", "False", "FALSE", "0", "no"] {
583 let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
584 let config = ProviderConfig::parse(&content);
585 assert!(
586 !config.sections[0].auto_sync,
587 "auto_sync={} should be false",
588 value
589 );
590 }
591 }
592
593 #[test]
594 fn test_auto_sync_true_variants() {
595 for value in &["true", "True", "TRUE", "1", "yes"] {
596 let content = format!(
598 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n",
599 value
600 );
601 let config = ProviderConfig::parse(&content);
602 assert!(
603 config.sections[0].auto_sync,
604 "auto_sync={} should be true",
605 value
606 );
607 }
608 }
609
610 #[test]
611 fn test_auto_sync_malformed_value_treated_as_true() {
612 let config =
614 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
615 assert!(config.sections[0].auto_sync);
616 }
617
618 #[test]
619 fn test_auto_sync_written_only_when_non_default() {
620 let mut config = ProviderConfig::default();
622 config.set_section(ProviderSection {
623 provider: "proxmox".to_string(),
624 token: "tok".to_string(),
625 alias_prefix: "pve".to_string(),
626 user: "root".to_string(),
627 identity_file: String::new(),
628 url: "https://pve:8006".to_string(),
629 verify_tls: true,
630 auto_sync: true, profile: String::new(),
632 regions: String::new(),
633 project: String::new(),
634 compartment: String::new(),
635 });
636 let content =
638 "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
639 .to_string();
640 let reparsed = ProviderConfig::parse(&content);
641 assert!(reparsed.sections[0].auto_sync);
642
643 let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
645 let reparsed2 = ProviderConfig::parse(content2);
646 assert!(!reparsed2.sections[0].auto_sync);
647 }
648
649 #[test]
654 fn test_configured_providers_empty() {
655 let config = ProviderConfig::default();
656 assert!(config.configured_providers().is_empty());
657 }
658
659 #[test]
660 fn test_configured_providers_returns_all() {
661 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
662 let config = ProviderConfig::parse(content);
663 assert_eq!(config.configured_providers().len(), 2);
664 }
665
666 #[test]
671 fn test_parse_unknown_keys_ignored() {
672 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
673 let config = ProviderConfig::parse(content);
674 assert_eq!(config.sections.len(), 1);
675 assert_eq!(config.sections[0].token, "abc");
676 }
677
678 #[test]
679 fn test_parse_unknown_provider_still_parsed() {
680 let content = "[aws]\ntoken=secret\n";
681 let config = ProviderConfig::parse(content);
682 assert_eq!(config.sections.len(), 1);
683 assert_eq!(config.sections[0].provider, "aws");
684 }
685
686 #[test]
687 fn test_parse_whitespace_in_section_name() {
688 let content = "[ digitalocean ]\ntoken=abc\n";
689 let config = ProviderConfig::parse(content);
690 assert_eq!(config.sections.len(), 1);
691 assert_eq!(config.sections[0].provider, "digitalocean");
692 }
693
694 #[test]
695 fn test_parse_value_with_equals() {
696 let content = "[digitalocean]\ntoken=abc=def==\n";
698 let config = ProviderConfig::parse(content);
699 assert_eq!(config.sections[0].token, "abc=def==");
700 }
701
702 #[test]
703 fn test_parse_whitespace_around_key_value() {
704 let content = "[digitalocean]\n token = my-token \n";
705 let config = ProviderConfig::parse(content);
706 assert_eq!(config.sections[0].token, "my-token");
707 }
708
709 #[test]
710 fn test_parse_key_field_sets_identity_file() {
711 let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
712 let config = ProviderConfig::parse(content);
713 assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
714 }
715
716 #[test]
717 fn test_section_lookup_missing() {
718 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
719 assert!(config.section("vultr").is_none());
720 }
721
722 #[test]
723 fn test_section_lookup_found() {
724 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
725 let section = config.section("digitalocean").unwrap();
726 assert_eq!(section.token, "abc");
727 }
728
729 #[test]
730 fn test_remove_nonexistent_section_noop() {
731 let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
732 config.remove_section("vultr");
733 assert_eq!(config.sections.len(), 1);
734 }
735
736 #[test]
741 fn test_default_alias_prefix_digitalocean() {
742 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
743 assert_eq!(config.sections[0].alias_prefix, "do");
744 }
745
746 #[test]
747 fn test_default_alias_prefix_upcloud() {
748 let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
749 assert_eq!(config.sections[0].alias_prefix, "uc");
750 }
751
752 #[test]
753 fn test_default_alias_prefix_proxmox() {
754 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
755 assert_eq!(config.sections[0].alias_prefix, "pve");
756 }
757
758 #[test]
759 fn test_alias_prefix_override() {
760 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
761 assert_eq!(config.sections[0].alias_prefix, "ocean");
762 }
763
764 #[test]
769 fn test_default_user_is_root() {
770 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
771 assert_eq!(config.sections[0].user, "root");
772 }
773
774 #[test]
775 fn test_user_override() {
776 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
777 assert_eq!(config.sections[0].user, "admin");
778 }
779
780 #[test]
785 fn test_proxmox_url_parsed() {
786 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
787 assert_eq!(config.sections[0].url, "https://pve.local:8006");
788 }
789
790 #[test]
791 fn test_non_proxmox_url_parsed_but_ignored() {
792 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
794 assert_eq!(config.sections[0].url, "https://api.do.com");
795 }
796
797 #[test]
802 fn test_duplicate_section_first_wins() {
803 let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
804 let config = ProviderConfig::parse(content);
805 assert_eq!(config.sections.len(), 1);
806 assert_eq!(config.sections[0].token, "first");
807 }
808
809 #[test]
818 fn test_auto_sync_default_proxmox_false() {
819 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
820 assert!(!config.sections[0].auto_sync);
821 }
822
823 #[test]
824 fn test_auto_sync_default_all_others_true() {
825 for provider in &[
826 "digitalocean",
827 "vultr",
828 "linode",
829 "hetzner",
830 "upcloud",
831 "aws",
832 "scaleway",
833 "gcp",
834 "azure",
835 "tailscale",
836 ] {
837 let content = format!("[{}]\ntoken=abc\n", provider);
838 let config = ProviderConfig::parse(&content);
839 assert!(
840 config.sections[0].auto_sync,
841 "auto_sync should default to true for {}",
842 provider
843 );
844 }
845 }
846
847 #[test]
848 fn test_auto_sync_override_proxmox_to_true() {
849 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
850 assert!(config.sections[0].auto_sync);
851 }
852
853 #[test]
854 fn test_auto_sync_override_do_to_false() {
855 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
856 assert!(!config.sections[0].auto_sync);
857 }
858
859 #[test]
864 fn test_set_section_adds_new() {
865 let mut config = ProviderConfig::default();
866 let section = ProviderSection {
867 provider: "vultr".to_string(),
868 token: "tok".to_string(),
869 alias_prefix: "vultr".to_string(),
870 user: "root".to_string(),
871 identity_file: String::new(),
872 url: String::new(),
873 verify_tls: true,
874 auto_sync: true,
875 profile: String::new(),
876 regions: String::new(),
877 project: String::new(),
878 compartment: String::new(),
879 };
880 config.set_section(section);
881 assert_eq!(config.sections.len(), 1);
882 assert_eq!(config.sections[0].provider, "vultr");
883 }
884
885 #[test]
886 fn test_set_section_replaces_existing() {
887 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
888 assert_eq!(config.sections[0].token, "old");
889 let section = ProviderSection {
890 provider: "vultr".to_string(),
891 token: "new".to_string(),
892 alias_prefix: "vultr".to_string(),
893 user: "root".to_string(),
894 identity_file: String::new(),
895 url: String::new(),
896 verify_tls: true,
897 auto_sync: true,
898 profile: String::new(),
899 regions: String::new(),
900 project: String::new(),
901 compartment: String::new(),
902 };
903 config.set_section(section);
904 assert_eq!(config.sections.len(), 1);
905 assert_eq!(config.sections[0].token, "new");
906 }
907
908 #[test]
909 fn test_remove_section_keeps_others() {
910 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
911 assert_eq!(config.sections.len(), 2);
912 config.remove_section("vultr");
913 assert_eq!(config.sections.len(), 1);
914 assert_eq!(config.sections[0].provider, "linode");
915 }
916
917 #[test]
922 fn test_comments_ignored() {
923 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
924 let config = ProviderConfig::parse(content);
925 assert_eq!(config.sections.len(), 1);
926 assert_eq!(config.sections[0].token, "abc");
927 }
928
929 #[test]
930 fn test_blank_lines_ignored() {
931 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
932 let config = ProviderConfig::parse(content);
933 assert_eq!(config.sections.len(), 1);
934 assert_eq!(config.sections[0].token, "abc");
935 }
936
937 #[test]
942 fn test_multiple_providers() {
943 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
944 let config = ProviderConfig::parse(content);
945 assert_eq!(config.sections.len(), 3);
946 assert_eq!(config.sections[0].provider, "digitalocean");
947 assert_eq!(config.sections[1].provider, "vultr");
948 assert_eq!(config.sections[2].provider, "proxmox");
949 assert_eq!(config.sections[2].url, "https://pve:8006");
950 }
951
952 #[test]
957 fn test_token_with_equals_sign() {
958 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
960 let config = ProviderConfig::parse(content);
961 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
963 }
964
965 #[test]
966 fn test_proxmox_token_with_exclamation() {
967 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
968 let config = ProviderConfig::parse(content);
969 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
970 }
971
972 #[test]
977 fn test_serialize_roundtrip_single_provider() {
978 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
979 let config = ProviderConfig::parse(content);
980 let mut serialized = String::new();
981 for section in &config.sections {
982 serialized.push_str(&format!("[{}]\n", section.provider));
983 serialized.push_str(&format!("token={}\n", section.token));
984 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
985 serialized.push_str(&format!("user={}\n", section.user));
986 }
987 let reparsed = ProviderConfig::parse(&serialized);
988 assert_eq!(reparsed.sections.len(), 1);
989 assert_eq!(reparsed.sections[0].token, "abc");
990 assert_eq!(reparsed.sections[0].alias_prefix, "do");
991 assert_eq!(reparsed.sections[0].user, "root");
992 }
993
994 #[test]
999 fn test_verify_tls_values() {
1000 for (val, expected) in [
1001 ("false", false),
1002 ("False", false),
1003 ("FALSE", false),
1004 ("0", false),
1005 ("no", false),
1006 ("No", false),
1007 ("NO", false),
1008 ("true", true),
1009 ("True", true),
1010 ("1", true),
1011 ("yes", true),
1012 ("anything", true), ] {
1014 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1015 let config = ProviderConfig::parse(&content);
1016 assert_eq!(
1017 config.sections[0].verify_tls, expected,
1018 "verify_tls={} should be {}",
1019 val, expected
1020 );
1021 }
1022 }
1023
1024 #[test]
1029 fn test_auto_sync_values() {
1030 for (val, expected) in [
1031 ("false", false),
1032 ("False", false),
1033 ("FALSE", false),
1034 ("0", false),
1035 ("no", false),
1036 ("No", false),
1037 ("true", true),
1038 ("1", true),
1039 ("yes", true),
1040 ] {
1041 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1042 let config = ProviderConfig::parse(&content);
1043 assert_eq!(
1044 config.sections[0].auto_sync, expected,
1045 "auto_sync={} should be {}",
1046 val, expected
1047 );
1048 }
1049 }
1050
1051 #[test]
1056 fn test_default_user_root_when_not_specified() {
1057 let content = "[digitalocean]\ntoken=abc\n";
1058 let config = ProviderConfig::parse(content);
1059 assert_eq!(config.sections[0].user, "root");
1060 }
1061
1062 #[test]
1063 fn test_default_alias_prefix_from_short_label() {
1064 let content = "[digitalocean]\ntoken=abc\n";
1066 let config = ProviderConfig::parse(content);
1067 assert_eq!(config.sections[0].alias_prefix, "do");
1068 }
1069
1070 #[test]
1071 fn test_default_alias_prefix_unknown_provider() {
1072 let content = "[unknown_cloud]\ntoken=abc\n";
1074 let config = ProviderConfig::parse(content);
1075 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1076 }
1077
1078 #[test]
1079 fn test_default_identity_file_empty() {
1080 let content = "[digitalocean]\ntoken=abc\n";
1081 let config = ProviderConfig::parse(content);
1082 assert!(config.sections[0].identity_file.is_empty());
1083 }
1084
1085 #[test]
1086 fn test_default_url_empty() {
1087 let content = "[digitalocean]\ntoken=abc\n";
1088 let config = ProviderConfig::parse(content);
1089 assert!(config.sections[0].url.is_empty());
1090 }
1091
1092 #[test]
1097 fn test_gcp_project_parsed() {
1098 let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1099 assert_eq!(config.sections[0].project, "my-gcp-project");
1100 }
1101
1102 #[test]
1103 fn test_gcp_project_default_empty() {
1104 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1105 assert!(config.sections[0].project.is_empty());
1106 }
1107
1108 #[test]
1109 fn test_gcp_project_roundtrip() {
1110 let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1111 let config = ProviderConfig::parse(content);
1112 assert_eq!(config.sections[0].project, "my-project");
1113 assert_eq!(config.sections[0].regions, "us-central1-a");
1114 let serialized = format!(
1116 "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1117 config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1118 );
1119 let reparsed = ProviderConfig::parse(&serialized);
1120 assert_eq!(reparsed.sections[0].project, "my-project");
1121 assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1122 }
1123
1124 #[test]
1125 fn test_default_alias_prefix_gcp() {
1126 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1127 assert_eq!(config.sections[0].alias_prefix, "gcp");
1128 }
1129
1130 #[test]
1135 fn test_configured_providers_returns_all_sections() {
1136 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1137 let config = ProviderConfig::parse(content);
1138 assert_eq!(config.configured_providers().len(), 2);
1139 }
1140
1141 #[test]
1142 fn test_section_by_name() {
1143 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1144 let config = ProviderConfig::parse(content);
1145 let do_section = config.section("digitalocean").unwrap();
1146 assert_eq!(do_section.token, "do-tok");
1147 let vultr_section = config.section("vultr").unwrap();
1148 assert_eq!(vultr_section.token, "vultr-tok");
1149 }
1150
1151 #[test]
1152 fn test_section_not_found() {
1153 let config = ProviderConfig::parse("");
1154 assert!(config.section("nonexistent").is_none());
1155 }
1156
1157 #[test]
1162 fn test_line_without_equals_ignored() {
1163 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1164 let config = ProviderConfig::parse(content);
1165 assert_eq!(config.sections[0].token, "abc");
1166 assert_eq!(config.sections[0].user, "admin");
1167 }
1168
1169 #[test]
1170 fn test_unknown_key_ignored() {
1171 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1172 let config = ProviderConfig::parse(content);
1173 assert_eq!(config.sections[0].token, "abc");
1174 assert_eq!(config.sections[0].user, "admin");
1175 }
1176
1177 #[test]
1182 fn test_whitespace_around_section_name() {
1183 let content = "[ digitalocean ]\ntoken=abc\n";
1184 let config = ProviderConfig::parse(content);
1185 assert_eq!(config.sections[0].provider, "digitalocean");
1186 }
1187
1188 #[test]
1189 fn test_whitespace_around_key_value() {
1190 let content = "[digitalocean]\n token = abc \n user = admin \n";
1191 let config = ProviderConfig::parse(content);
1192 assert_eq!(config.sections[0].token, "abc");
1193 assert_eq!(config.sections[0].user, "admin");
1194 }
1195
1196 #[test]
1201 fn test_set_section_multiple_adds() {
1202 let mut config = ProviderConfig::default();
1203 for name in ["digitalocean", "vultr", "hetzner"] {
1204 config.set_section(ProviderSection {
1205 provider: name.to_string(),
1206 token: format!("{}-tok", name),
1207 alias_prefix: name.to_string(),
1208 user: "root".to_string(),
1209 identity_file: String::new(),
1210 url: String::new(),
1211 verify_tls: true,
1212 auto_sync: true,
1213 profile: String::new(),
1214 regions: String::new(),
1215 project: String::new(),
1216 compartment: String::new(),
1217 });
1218 }
1219 assert_eq!(config.sections.len(), 3);
1220 }
1221
1222 #[test]
1223 fn test_remove_section_all() {
1224 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1225 let mut config = ProviderConfig::parse(content);
1226 config.remove_section("digitalocean");
1227 config.remove_section("vultr");
1228 assert!(config.sections.is_empty());
1229 }
1230
1231 #[test]
1236 fn test_compartment_field_round_trip() {
1237 use std::path::PathBuf;
1238 let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1239 let config = ProviderConfig::parse(content);
1240 assert_eq!(
1241 config.sections[0].compartment,
1242 "ocid1.compartment.oc1..example"
1243 );
1244
1245 let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1247 let mut cfg = config;
1248 cfg.path_override = Some(PathBuf::from(&tmp));
1249 cfg.save().expect("save failed");
1250 let saved = std::fs::read_to_string(&tmp).expect("read failed");
1251 let _ = std::fs::remove_file(&tmp);
1252 let reparsed = ProviderConfig::parse(&saved);
1253 assert_eq!(
1254 reparsed.sections[0].compartment,
1255 "ocid1.compartment.oc1..example"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_auto_sync_default_true_for_oracle() {
1261 let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1262 assert!(config.sections[0].auto_sync);
1263 }
1264}