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