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 pub(crate) 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 fn sanitize_value(s: &str) -> String {
144 s.chars().filter(|c| !c.is_control()).collect()
145 }
146
147 pub fn save(&self) -> io::Result<()> {
150 if crate::demo_flag::is_demo() {
151 return Ok(());
152 }
153 let path = match &self.path_override {
154 Some(p) => p.clone(),
155 None => match config_path() {
156 Some(p) => p,
157 None => {
158 return Err(io::Error::new(
159 io::ErrorKind::NotFound,
160 "Could not determine home directory",
161 ));
162 }
163 },
164 };
165
166 let mut content = String::new();
167 for (i, section) in self.sections.iter().enumerate() {
168 if i > 0 {
169 content.push('\n');
170 }
171 content.push_str(&format!("[{}]\n", Self::sanitize_value(§ion.provider)));
172 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
173 content.push_str(&format!(
174 "alias_prefix={}\n",
175 Self::sanitize_value(§ion.alias_prefix)
176 ));
177 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
178 if !section.identity_file.is_empty() {
179 content.push_str(&format!(
180 "key={}\n",
181 Self::sanitize_value(§ion.identity_file)
182 ));
183 }
184 if !section.url.is_empty() {
185 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
186 }
187 if !section.verify_tls {
188 content.push_str("verify_tls=false\n");
189 }
190 if !section.profile.is_empty() {
191 content.push_str(&format!(
192 "profile={}\n",
193 Self::sanitize_value(§ion.profile)
194 ));
195 }
196 if !section.regions.is_empty() {
197 content.push_str(&format!(
198 "regions={}\n",
199 Self::sanitize_value(§ion.regions)
200 ));
201 }
202 if !section.project.is_empty() {
203 content.push_str(&format!(
204 "project={}\n",
205 Self::sanitize_value(§ion.project)
206 ));
207 }
208 if !section.compartment.is_empty() {
209 content.push_str(&format!(
210 "compartment={}\n",
211 Self::sanitize_value(§ion.compartment)
212 ));
213 }
214 if section.auto_sync != default_auto_sync(§ion.provider) {
215 content.push_str(if section.auto_sync {
216 "auto_sync=true\n"
217 } else {
218 "auto_sync=false\n"
219 });
220 }
221 }
222
223 fs_util::atomic_write(&path, content.as_bytes())
224 }
225
226 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
228 self.sections.iter().find(|s| s.provider == provider)
229 }
230
231 pub fn set_section(&mut self, section: ProviderSection) {
233 if let Some(existing) = self
234 .sections
235 .iter_mut()
236 .find(|s| s.provider == section.provider)
237 {
238 *existing = section;
239 } else {
240 self.sections.push(section);
241 }
242 }
243
244 pub fn remove_section(&mut self, provider: &str) {
246 self.sections.retain(|s| s.provider != provider);
247 }
248
249 pub fn configured_providers(&self) -> &[ProviderSection] {
251 &self.sections
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[test]
260 fn test_parse_empty() {
261 let config = ProviderConfig::parse("");
262 assert!(config.sections.is_empty());
263 }
264
265 #[test]
266 fn test_parse_single_section() {
267 let content = "\
268[digitalocean]
269token=dop_v1_abc123
270alias_prefix=do
271user=root
272key=~/.ssh/id_ed25519
273";
274 let config = ProviderConfig::parse(content);
275 assert_eq!(config.sections.len(), 1);
276 let s = &config.sections[0];
277 assert_eq!(s.provider, "digitalocean");
278 assert_eq!(s.token, "dop_v1_abc123");
279 assert_eq!(s.alias_prefix, "do");
280 assert_eq!(s.user, "root");
281 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
282 }
283
284 #[test]
285 fn test_parse_multiple_sections() {
286 let content = "\
287[digitalocean]
288token=abc
289
290[vultr]
291token=xyz
292user=deploy
293";
294 let config = ProviderConfig::parse(content);
295 assert_eq!(config.sections.len(), 2);
296 assert_eq!(config.sections[0].provider, "digitalocean");
297 assert_eq!(config.sections[1].provider, "vultr");
298 assert_eq!(config.sections[1].user, "deploy");
299 }
300
301 #[test]
302 fn test_parse_comments_and_blanks() {
303 let content = "\
304# Provider config
305
306[linode]
307# API token
308token=mytoken
309";
310 let config = ProviderConfig::parse(content);
311 assert_eq!(config.sections.len(), 1);
312 assert_eq!(config.sections[0].token, "mytoken");
313 }
314
315 #[test]
316 fn test_set_section_add() {
317 let mut config = ProviderConfig::default();
318 config.set_section(ProviderSection {
319 provider: "vultr".to_string(),
320 token: "abc".to_string(),
321 alias_prefix: "vultr".to_string(),
322 user: "root".to_string(),
323 identity_file: String::new(),
324 url: String::new(),
325 verify_tls: true,
326 auto_sync: true,
327 profile: String::new(),
328 regions: String::new(),
329 project: String::new(),
330 compartment: String::new(),
331 });
332 assert_eq!(config.sections.len(), 1);
333 }
334
335 #[test]
336 fn test_set_section_replace() {
337 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
338 config.set_section(ProviderSection {
339 provider: "vultr".to_string(),
340 token: "new".to_string(),
341 alias_prefix: "vultr".to_string(),
342 user: "root".to_string(),
343 identity_file: String::new(),
344 url: String::new(),
345 verify_tls: true,
346 auto_sync: true,
347 profile: String::new(),
348 regions: String::new(),
349 project: String::new(),
350 compartment: String::new(),
351 });
352 assert_eq!(config.sections.len(), 1);
353 assert_eq!(config.sections[0].token, "new");
354 }
355
356 #[test]
357 fn test_remove_section() {
358 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
359 config.remove_section("vultr");
360 assert_eq!(config.sections.len(), 1);
361 assert_eq!(config.sections[0].provider, "linode");
362 }
363
364 #[test]
365 fn test_section_lookup() {
366 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
367 assert!(config.section("digitalocean").is_some());
368 assert!(config.section("vultr").is_none());
369 }
370
371 #[test]
372 fn test_parse_duplicate_sections_first_wins() {
373 let content = "\
374[digitalocean]
375token=first
376
377[digitalocean]
378token=second
379";
380 let config = ProviderConfig::parse(content);
381 assert_eq!(config.sections.len(), 1);
382 assert_eq!(config.sections[0].token, "first");
383 }
384
385 #[test]
386 fn test_parse_duplicate_sections_trailing() {
387 let content = "\
388[vultr]
389token=abc
390
391[linode]
392token=xyz
393
394[vultr]
395token=dup
396";
397 let config = ProviderConfig::parse(content);
398 assert_eq!(config.sections.len(), 2);
399 assert_eq!(config.sections[0].provider, "vultr");
400 assert_eq!(config.sections[0].token, "abc");
401 assert_eq!(config.sections[1].provider, "linode");
402 }
403
404 #[test]
405 fn test_defaults_applied() {
406 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
407 let s = &config.sections[0];
408 assert_eq!(s.user, "root");
409 assert_eq!(s.alias_prefix, "hetzner");
410 assert!(s.identity_file.is_empty());
411 assert!(s.url.is_empty());
412 assert!(s.verify_tls);
413 assert!(s.auto_sync);
414 }
415
416 #[test]
417 fn test_parse_url_and_verify_tls() {
418 let content = "\
419[proxmox]
420token=user@pam!purple=secret
421url=https://pve.example.com:8006
422verify_tls=false
423";
424 let config = ProviderConfig::parse(content);
425 assert_eq!(config.sections.len(), 1);
426 let s = &config.sections[0];
427 assert_eq!(s.url, "https://pve.example.com:8006");
428 assert!(!s.verify_tls);
429 }
430
431 #[test]
432 fn test_url_and_verify_tls_round_trip() {
433 let content = "\
434[proxmox]
435token=tok
436alias_prefix=pve
437user=root
438url=https://pve.local:8006
439verify_tls=false
440";
441 let config = ProviderConfig::parse(content);
442 let s = &config.sections[0];
443 assert_eq!(s.url, "https://pve.local:8006");
444 assert!(!s.verify_tls);
445 }
446
447 #[test]
448 fn test_verify_tls_default_true() {
449 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
451 assert!(config.sections[0].verify_tls);
452 }
453
454 #[test]
455 fn test_verify_tls_false_variants() {
456 for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
457 let content = format!(
458 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
459 value
460 );
461 let config = ProviderConfig::parse(&content);
462 assert!(
463 !config.sections[0].verify_tls,
464 "verify_tls={} should be false",
465 value
466 );
467 }
468 }
469
470 #[test]
471 fn test_verify_tls_true_variants() {
472 for value in &["true", "True", "1", "yes"] {
473 let content = format!(
474 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
475 value
476 );
477 let config = ProviderConfig::parse(&content);
478 assert!(
479 config.sections[0].verify_tls,
480 "verify_tls={} should be true",
481 value
482 );
483 }
484 }
485
486 #[test]
487 fn test_non_proxmox_url_not_written() {
488 let section = ProviderSection {
490 provider: "digitalocean".to_string(),
491 token: "tok".to_string(),
492 alias_prefix: "do".to_string(),
493 user: "root".to_string(),
494 identity_file: String::new(),
495 url: String::new(), verify_tls: true, auto_sync: true, profile: String::new(),
499 regions: String::new(),
500 project: String::new(),
501 compartment: String::new(),
502 };
503 let mut config = ProviderConfig::default();
504 config.set_section(section);
505 let s = &config.sections[0];
507 assert!(s.url.is_empty());
508 assert!(s.verify_tls);
509 }
510
511 #[test]
512 fn test_proxmox_url_fallback_in_section() {
513 let existing = ProviderConfig::parse(
515 "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
516 );
517 let existing_url = existing
518 .section("proxmox")
519 .map(|s| s.url.clone())
520 .unwrap_or_default();
521 assert_eq!(existing_url, "https://pve.local:8006");
522
523 let mut config = existing;
524 config.set_section(ProviderSection {
525 provider: "proxmox".to_string(),
526 token: "new".to_string(),
527 alias_prefix: "pve".to_string(),
528 user: "root".to_string(),
529 identity_file: String::new(),
530 url: existing_url,
531 verify_tls: true,
532 auto_sync: false,
533 profile: String::new(),
534 regions: String::new(),
535 project: String::new(),
536 compartment: String::new(),
537 });
538 assert_eq!(config.sections[0].token, "new");
539 assert_eq!(config.sections[0].url, "https://pve.local:8006");
540 }
541
542 #[test]
543 fn test_auto_sync_default_true_for_non_proxmox() {
544 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
545 assert!(config.sections[0].auto_sync);
546 }
547
548 #[test]
549 fn test_auto_sync_default_false_for_proxmox() {
550 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
551 assert!(!config.sections[0].auto_sync);
552 }
553
554 #[test]
555 fn test_auto_sync_explicit_true() {
556 let config =
557 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
558 assert!(config.sections[0].auto_sync);
559 }
560
561 #[test]
562 fn test_auto_sync_explicit_false_non_proxmox() {
563 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
564 assert!(!config.sections[0].auto_sync);
565 }
566
567 #[test]
568 fn test_auto_sync_not_written_when_default() {
569 let mut config = ProviderConfig::default();
571 config.set_section(ProviderSection {
572 provider: "digitalocean".to_string(),
573 token: "tok".to_string(),
574 alias_prefix: "do".to_string(),
575 user: "root".to_string(),
576 identity_file: String::new(),
577 url: String::new(),
578 verify_tls: true,
579 auto_sync: true,
580 profile: String::new(),
581 regions: String::new(),
582 project: String::new(),
583 compartment: String::new(),
584 });
585 assert!(config.sections[0].auto_sync);
587
588 let mut config2 = ProviderConfig::default();
590 config2.set_section(ProviderSection {
591 provider: "proxmox".to_string(),
592 token: "tok".to_string(),
593 alias_prefix: "pve".to_string(),
594 user: "root".to_string(),
595 identity_file: String::new(),
596 url: "https://pve:8006".to_string(),
597 verify_tls: true,
598 auto_sync: false,
599 profile: String::new(),
600 regions: String::new(),
601 project: String::new(),
602 compartment: String::new(),
603 });
604 assert!(!config2.sections[0].auto_sync);
605 }
606
607 #[test]
608 fn test_auto_sync_false_variants() {
609 for value in &["false", "False", "FALSE", "0", "no"] {
610 let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
611 let config = ProviderConfig::parse(&content);
612 assert!(
613 !config.sections[0].auto_sync,
614 "auto_sync={} should be false",
615 value
616 );
617 }
618 }
619
620 #[test]
621 fn test_auto_sync_true_variants() {
622 for value in &["true", "True", "TRUE", "1", "yes"] {
623 let content = format!(
625 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n",
626 value
627 );
628 let config = ProviderConfig::parse(&content);
629 assert!(
630 config.sections[0].auto_sync,
631 "auto_sync={} should be true",
632 value
633 );
634 }
635 }
636
637 #[test]
638 fn test_auto_sync_malformed_value_treated_as_true() {
639 let config =
641 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
642 assert!(config.sections[0].auto_sync);
643 }
644
645 #[test]
646 fn test_auto_sync_written_only_when_non_default() {
647 let mut config = ProviderConfig::default();
649 config.set_section(ProviderSection {
650 provider: "proxmox".to_string(),
651 token: "tok".to_string(),
652 alias_prefix: "pve".to_string(),
653 user: "root".to_string(),
654 identity_file: String::new(),
655 url: "https://pve:8006".to_string(),
656 verify_tls: true,
657 auto_sync: true, profile: String::new(),
659 regions: String::new(),
660 project: String::new(),
661 compartment: String::new(),
662 });
663 let content =
665 "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
666 .to_string();
667 let reparsed = ProviderConfig::parse(&content);
668 assert!(reparsed.sections[0].auto_sync);
669
670 let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
672 let reparsed2 = ProviderConfig::parse(content2);
673 assert!(!reparsed2.sections[0].auto_sync);
674 }
675
676 #[test]
681 fn test_configured_providers_empty() {
682 let config = ProviderConfig::default();
683 assert!(config.configured_providers().is_empty());
684 }
685
686 #[test]
687 fn test_configured_providers_returns_all() {
688 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
689 let config = ProviderConfig::parse(content);
690 assert_eq!(config.configured_providers().len(), 2);
691 }
692
693 #[test]
698 fn test_parse_unknown_keys_ignored() {
699 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
700 let config = ProviderConfig::parse(content);
701 assert_eq!(config.sections.len(), 1);
702 assert_eq!(config.sections[0].token, "abc");
703 }
704
705 #[test]
706 fn test_parse_unknown_provider_still_parsed() {
707 let content = "[aws]\ntoken=secret\n";
708 let config = ProviderConfig::parse(content);
709 assert_eq!(config.sections.len(), 1);
710 assert_eq!(config.sections[0].provider, "aws");
711 }
712
713 #[test]
714 fn test_parse_whitespace_in_section_name() {
715 let content = "[ digitalocean ]\ntoken=abc\n";
716 let config = ProviderConfig::parse(content);
717 assert_eq!(config.sections.len(), 1);
718 assert_eq!(config.sections[0].provider, "digitalocean");
719 }
720
721 #[test]
722 fn test_parse_value_with_equals() {
723 let content = "[digitalocean]\ntoken=abc=def==\n";
725 let config = ProviderConfig::parse(content);
726 assert_eq!(config.sections[0].token, "abc=def==");
727 }
728
729 #[test]
730 fn test_parse_whitespace_around_key_value() {
731 let content = "[digitalocean]\n token = my-token \n";
732 let config = ProviderConfig::parse(content);
733 assert_eq!(config.sections[0].token, "my-token");
734 }
735
736 #[test]
737 fn test_parse_key_field_sets_identity_file() {
738 let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
739 let config = ProviderConfig::parse(content);
740 assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
741 }
742
743 #[test]
744 fn test_section_lookup_missing() {
745 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
746 assert!(config.section("vultr").is_none());
747 }
748
749 #[test]
750 fn test_section_lookup_found() {
751 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
752 let section = config.section("digitalocean").unwrap();
753 assert_eq!(section.token, "abc");
754 }
755
756 #[test]
757 fn test_remove_nonexistent_section_noop() {
758 let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
759 config.remove_section("vultr");
760 assert_eq!(config.sections.len(), 1);
761 }
762
763 #[test]
768 fn test_default_alias_prefix_digitalocean() {
769 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
770 assert_eq!(config.sections[0].alias_prefix, "do");
771 }
772
773 #[test]
774 fn test_default_alias_prefix_upcloud() {
775 let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
776 assert_eq!(config.sections[0].alias_prefix, "uc");
777 }
778
779 #[test]
780 fn test_default_alias_prefix_proxmox() {
781 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
782 assert_eq!(config.sections[0].alias_prefix, "pve");
783 }
784
785 #[test]
786 fn test_alias_prefix_override() {
787 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
788 assert_eq!(config.sections[0].alias_prefix, "ocean");
789 }
790
791 #[test]
796 fn test_default_user_is_root() {
797 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
798 assert_eq!(config.sections[0].user, "root");
799 }
800
801 #[test]
802 fn test_user_override() {
803 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
804 assert_eq!(config.sections[0].user, "admin");
805 }
806
807 #[test]
812 fn test_proxmox_url_parsed() {
813 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
814 assert_eq!(config.sections[0].url, "https://pve.local:8006");
815 }
816
817 #[test]
818 fn test_non_proxmox_url_parsed_but_ignored() {
819 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
821 assert_eq!(config.sections[0].url, "https://api.do.com");
822 }
823
824 #[test]
829 fn test_duplicate_section_first_wins() {
830 let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
831 let config = ProviderConfig::parse(content);
832 assert_eq!(config.sections.len(), 1);
833 assert_eq!(config.sections[0].token, "first");
834 }
835
836 #[test]
845 fn test_auto_sync_default_proxmox_false() {
846 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
847 assert!(!config.sections[0].auto_sync);
848 }
849
850 #[test]
851 fn test_auto_sync_default_all_others_true() {
852 for provider in &[
853 "digitalocean",
854 "vultr",
855 "linode",
856 "hetzner",
857 "upcloud",
858 "aws",
859 "scaleway",
860 "gcp",
861 "azure",
862 "tailscale",
863 "oracle",
864 "ovh",
865 ] {
866 let content = format!("[{}]\ntoken=abc\n", provider);
867 let config = ProviderConfig::parse(&content);
868 assert!(
869 config.sections[0].auto_sync,
870 "auto_sync should default to true for {}",
871 provider
872 );
873 }
874 }
875
876 #[test]
877 fn test_auto_sync_override_proxmox_to_true() {
878 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
879 assert!(config.sections[0].auto_sync);
880 }
881
882 #[test]
883 fn test_auto_sync_override_do_to_false() {
884 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
885 assert!(!config.sections[0].auto_sync);
886 }
887
888 #[test]
893 fn test_set_section_adds_new() {
894 let mut config = ProviderConfig::default();
895 let section = ProviderSection {
896 provider: "vultr".to_string(),
897 token: "tok".to_string(),
898 alias_prefix: "vultr".to_string(),
899 user: "root".to_string(),
900 identity_file: String::new(),
901 url: String::new(),
902 verify_tls: true,
903 auto_sync: true,
904 profile: String::new(),
905 regions: String::new(),
906 project: String::new(),
907 compartment: String::new(),
908 };
909 config.set_section(section);
910 assert_eq!(config.sections.len(), 1);
911 assert_eq!(config.sections[0].provider, "vultr");
912 }
913
914 #[test]
915 fn test_set_section_replaces_existing() {
916 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
917 assert_eq!(config.sections[0].token, "old");
918 let section = ProviderSection {
919 provider: "vultr".to_string(),
920 token: "new".to_string(),
921 alias_prefix: "vultr".to_string(),
922 user: "root".to_string(),
923 identity_file: String::new(),
924 url: String::new(),
925 verify_tls: true,
926 auto_sync: true,
927 profile: String::new(),
928 regions: String::new(),
929 project: String::new(),
930 compartment: String::new(),
931 };
932 config.set_section(section);
933 assert_eq!(config.sections.len(), 1);
934 assert_eq!(config.sections[0].token, "new");
935 }
936
937 #[test]
938 fn test_remove_section_keeps_others() {
939 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
940 assert_eq!(config.sections.len(), 2);
941 config.remove_section("vultr");
942 assert_eq!(config.sections.len(), 1);
943 assert_eq!(config.sections[0].provider, "linode");
944 }
945
946 #[test]
951 fn test_comments_ignored() {
952 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
953 let config = ProviderConfig::parse(content);
954 assert_eq!(config.sections.len(), 1);
955 assert_eq!(config.sections[0].token, "abc");
956 }
957
958 #[test]
959 fn test_blank_lines_ignored() {
960 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
961 let config = ProviderConfig::parse(content);
962 assert_eq!(config.sections.len(), 1);
963 assert_eq!(config.sections[0].token, "abc");
964 }
965
966 #[test]
971 fn test_multiple_providers() {
972 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
973 let config = ProviderConfig::parse(content);
974 assert_eq!(config.sections.len(), 3);
975 assert_eq!(config.sections[0].provider, "digitalocean");
976 assert_eq!(config.sections[1].provider, "vultr");
977 assert_eq!(config.sections[2].provider, "proxmox");
978 assert_eq!(config.sections[2].url, "https://pve:8006");
979 }
980
981 #[test]
986 fn test_token_with_equals_sign() {
987 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
989 let config = ProviderConfig::parse(content);
990 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
992 }
993
994 #[test]
995 fn test_proxmox_token_with_exclamation() {
996 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
997 let config = ProviderConfig::parse(content);
998 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
999 }
1000
1001 #[test]
1006 fn test_serialize_roundtrip_single_provider() {
1007 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
1008 let config = ProviderConfig::parse(content);
1009 let mut serialized = String::new();
1010 for section in &config.sections {
1011 serialized.push_str(&format!("[{}]\n", section.provider));
1012 serialized.push_str(&format!("token={}\n", section.token));
1013 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
1014 serialized.push_str(&format!("user={}\n", section.user));
1015 }
1016 let reparsed = ProviderConfig::parse(&serialized);
1017 assert_eq!(reparsed.sections.len(), 1);
1018 assert_eq!(reparsed.sections[0].token, "abc");
1019 assert_eq!(reparsed.sections[0].alias_prefix, "do");
1020 assert_eq!(reparsed.sections[0].user, "root");
1021 }
1022
1023 #[test]
1028 fn test_verify_tls_values() {
1029 for (val, expected) in [
1030 ("false", false),
1031 ("False", false),
1032 ("FALSE", false),
1033 ("0", false),
1034 ("no", false),
1035 ("No", false),
1036 ("NO", false),
1037 ("true", true),
1038 ("True", true),
1039 ("1", true),
1040 ("yes", true),
1041 ("anything", true), ] {
1043 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1044 let config = ProviderConfig::parse(&content);
1045 assert_eq!(
1046 config.sections[0].verify_tls, expected,
1047 "verify_tls={} should be {}",
1048 val, expected
1049 );
1050 }
1051 }
1052
1053 #[test]
1058 fn test_auto_sync_values() {
1059 for (val, expected) in [
1060 ("false", false),
1061 ("False", false),
1062 ("FALSE", false),
1063 ("0", false),
1064 ("no", false),
1065 ("No", false),
1066 ("true", true),
1067 ("1", true),
1068 ("yes", true),
1069 ] {
1070 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1071 let config = ProviderConfig::parse(&content);
1072 assert_eq!(
1073 config.sections[0].auto_sync, expected,
1074 "auto_sync={} should be {}",
1075 val, expected
1076 );
1077 }
1078 }
1079
1080 #[test]
1085 fn test_default_user_root_when_not_specified() {
1086 let content = "[digitalocean]\ntoken=abc\n";
1087 let config = ProviderConfig::parse(content);
1088 assert_eq!(config.sections[0].user, "root");
1089 }
1090
1091 #[test]
1092 fn test_default_alias_prefix_from_short_label() {
1093 let content = "[digitalocean]\ntoken=abc\n";
1095 let config = ProviderConfig::parse(content);
1096 assert_eq!(config.sections[0].alias_prefix, "do");
1097 }
1098
1099 #[test]
1100 fn test_default_alias_prefix_unknown_provider() {
1101 let content = "[unknown_cloud]\ntoken=abc\n";
1103 let config = ProviderConfig::parse(content);
1104 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1105 }
1106
1107 #[test]
1108 fn test_default_identity_file_empty() {
1109 let content = "[digitalocean]\ntoken=abc\n";
1110 let config = ProviderConfig::parse(content);
1111 assert!(config.sections[0].identity_file.is_empty());
1112 }
1113
1114 #[test]
1115 fn test_default_url_empty() {
1116 let content = "[digitalocean]\ntoken=abc\n";
1117 let config = ProviderConfig::parse(content);
1118 assert!(config.sections[0].url.is_empty());
1119 }
1120
1121 #[test]
1126 fn test_gcp_project_parsed() {
1127 let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1128 assert_eq!(config.sections[0].project, "my-gcp-project");
1129 }
1130
1131 #[test]
1132 fn test_gcp_project_default_empty() {
1133 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1134 assert!(config.sections[0].project.is_empty());
1135 }
1136
1137 #[test]
1138 fn test_gcp_project_roundtrip() {
1139 let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1140 let config = ProviderConfig::parse(content);
1141 assert_eq!(config.sections[0].project, "my-project");
1142 assert_eq!(config.sections[0].regions, "us-central1-a");
1143 let serialized = format!(
1145 "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1146 config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1147 );
1148 let reparsed = ProviderConfig::parse(&serialized);
1149 assert_eq!(reparsed.sections[0].project, "my-project");
1150 assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1151 }
1152
1153 #[test]
1154 fn test_default_alias_prefix_gcp() {
1155 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1156 assert_eq!(config.sections[0].alias_prefix, "gcp");
1157 }
1158
1159 #[test]
1164 fn test_configured_providers_returns_all_sections() {
1165 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1166 let config = ProviderConfig::parse(content);
1167 assert_eq!(config.configured_providers().len(), 2);
1168 }
1169
1170 #[test]
1171 fn test_section_by_name() {
1172 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1173 let config = ProviderConfig::parse(content);
1174 let do_section = config.section("digitalocean").unwrap();
1175 assert_eq!(do_section.token, "do-tok");
1176 let vultr_section = config.section("vultr").unwrap();
1177 assert_eq!(vultr_section.token, "vultr-tok");
1178 }
1179
1180 #[test]
1181 fn test_section_not_found() {
1182 let config = ProviderConfig::parse("");
1183 assert!(config.section("nonexistent").is_none());
1184 }
1185
1186 #[test]
1191 fn test_line_without_equals_ignored() {
1192 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1193 let config = ProviderConfig::parse(content);
1194 assert_eq!(config.sections[0].token, "abc");
1195 assert_eq!(config.sections[0].user, "admin");
1196 }
1197
1198 #[test]
1199 fn test_unknown_key_ignored() {
1200 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1201 let config = ProviderConfig::parse(content);
1202 assert_eq!(config.sections[0].token, "abc");
1203 assert_eq!(config.sections[0].user, "admin");
1204 }
1205
1206 #[test]
1211 fn test_whitespace_around_section_name() {
1212 let content = "[ digitalocean ]\ntoken=abc\n";
1213 let config = ProviderConfig::parse(content);
1214 assert_eq!(config.sections[0].provider, "digitalocean");
1215 }
1216
1217 #[test]
1218 fn test_whitespace_around_key_value() {
1219 let content = "[digitalocean]\n token = abc \n user = admin \n";
1220 let config = ProviderConfig::parse(content);
1221 assert_eq!(config.sections[0].token, "abc");
1222 assert_eq!(config.sections[0].user, "admin");
1223 }
1224
1225 #[test]
1230 fn test_set_section_multiple_adds() {
1231 let mut config = ProviderConfig::default();
1232 for name in ["digitalocean", "vultr", "hetzner"] {
1233 config.set_section(ProviderSection {
1234 provider: name.to_string(),
1235 token: format!("{}-tok", name),
1236 alias_prefix: name.to_string(),
1237 user: "root".to_string(),
1238 identity_file: String::new(),
1239 url: String::new(),
1240 verify_tls: true,
1241 auto_sync: true,
1242 profile: String::new(),
1243 regions: String::new(),
1244 project: String::new(),
1245 compartment: String::new(),
1246 });
1247 }
1248 assert_eq!(config.sections.len(), 3);
1249 }
1250
1251 #[test]
1252 fn test_remove_section_all() {
1253 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1254 let mut config = ProviderConfig::parse(content);
1255 config.remove_section("digitalocean");
1256 config.remove_section("vultr");
1257 assert!(config.sections.is_empty());
1258 }
1259
1260 #[test]
1265 fn test_compartment_field_round_trip() {
1266 use std::path::PathBuf;
1267 let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1268 let config = ProviderConfig::parse(content);
1269 assert_eq!(
1270 config.sections[0].compartment,
1271 "ocid1.compartment.oc1..example"
1272 );
1273
1274 let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1276 let mut cfg = config;
1277 cfg.path_override = Some(PathBuf::from(&tmp));
1278 cfg.save().expect("save failed");
1279 let saved = std::fs::read_to_string(&tmp).expect("read failed");
1280 let _ = std::fs::remove_file(&tmp);
1281 let reparsed = ProviderConfig::parse(&saved);
1282 assert_eq!(
1283 reparsed.sections[0].compartment,
1284 "ocid1.compartment.oc1..example"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_auto_sync_default_true_for_oracle() {
1290 let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1291 assert!(config.sections[0].auto_sync);
1292 }
1293
1294 #[test]
1295 fn test_sanitize_value_strips_control_chars() {
1296 assert_eq!(ProviderConfig::sanitize_value("clean"), "clean");
1297 assert_eq!(ProviderConfig::sanitize_value("has\nnewline"), "hasnewline");
1298 assert_eq!(ProviderConfig::sanitize_value("has\ttab"), "hastab");
1299 assert_eq!(
1300 ProviderConfig::sanitize_value("has\rcarriage"),
1301 "hascarriage"
1302 );
1303 assert_eq!(ProviderConfig::sanitize_value("has\x00null"), "hasnull");
1304 assert_eq!(ProviderConfig::sanitize_value(""), "");
1305 }
1306
1307 #[test]
1308 fn test_save_sanitizes_token_with_newline() {
1309 let path = std::env::temp_dir().join(format!(
1310 "__purple_test_config_sanitize_{}.ini",
1311 std::process::id()
1312 ));
1313 let config = ProviderConfig {
1314 sections: vec![ProviderSection {
1315 provider: "digitalocean".to_string(),
1316 token: "abc\ndef".to_string(),
1317 alias_prefix: "do".to_string(),
1318 user: "root".to_string(),
1319 identity_file: String::new(),
1320 url: String::new(),
1321 verify_tls: true,
1322 auto_sync: true,
1323 profile: String::new(),
1324 regions: String::new(),
1325 project: String::new(),
1326 compartment: String::new(),
1327 }],
1328 path_override: Some(path.clone()),
1329 };
1330 config.save().unwrap();
1331 let content = std::fs::read_to_string(&path).unwrap();
1332 let _ = std::fs::remove_file(&path);
1333 assert!(content.contains("token=abcdef\n"));
1335 assert!(!content.contains("token=abc\ndef"));
1336 }
1337}