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 "oracle",
861 "ovh",
862 ] {
863 let content = format!("[{}]\ntoken=abc\n", provider);
864 let config = ProviderConfig::parse(&content);
865 assert!(
866 config.sections[0].auto_sync,
867 "auto_sync should default to true for {}",
868 provider
869 );
870 }
871 }
872
873 #[test]
874 fn test_auto_sync_override_proxmox_to_true() {
875 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
876 assert!(config.sections[0].auto_sync);
877 }
878
879 #[test]
880 fn test_auto_sync_override_do_to_false() {
881 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
882 assert!(!config.sections[0].auto_sync);
883 }
884
885 #[test]
890 fn test_set_section_adds_new() {
891 let mut config = ProviderConfig::default();
892 let section = ProviderSection {
893 provider: "vultr".to_string(),
894 token: "tok".to_string(),
895 alias_prefix: "vultr".to_string(),
896 user: "root".to_string(),
897 identity_file: String::new(),
898 url: String::new(),
899 verify_tls: true,
900 auto_sync: true,
901 profile: String::new(),
902 regions: String::new(),
903 project: String::new(),
904 compartment: String::new(),
905 };
906 config.set_section(section);
907 assert_eq!(config.sections.len(), 1);
908 assert_eq!(config.sections[0].provider, "vultr");
909 }
910
911 #[test]
912 fn test_set_section_replaces_existing() {
913 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
914 assert_eq!(config.sections[0].token, "old");
915 let section = ProviderSection {
916 provider: "vultr".to_string(),
917 token: "new".to_string(),
918 alias_prefix: "vultr".to_string(),
919 user: "root".to_string(),
920 identity_file: String::new(),
921 url: String::new(),
922 verify_tls: true,
923 auto_sync: true,
924 profile: String::new(),
925 regions: String::new(),
926 project: String::new(),
927 compartment: String::new(),
928 };
929 config.set_section(section);
930 assert_eq!(config.sections.len(), 1);
931 assert_eq!(config.sections[0].token, "new");
932 }
933
934 #[test]
935 fn test_remove_section_keeps_others() {
936 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
937 assert_eq!(config.sections.len(), 2);
938 config.remove_section("vultr");
939 assert_eq!(config.sections.len(), 1);
940 assert_eq!(config.sections[0].provider, "linode");
941 }
942
943 #[test]
948 fn test_comments_ignored() {
949 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
950 let config = ProviderConfig::parse(content);
951 assert_eq!(config.sections.len(), 1);
952 assert_eq!(config.sections[0].token, "abc");
953 }
954
955 #[test]
956 fn test_blank_lines_ignored() {
957 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
958 let config = ProviderConfig::parse(content);
959 assert_eq!(config.sections.len(), 1);
960 assert_eq!(config.sections[0].token, "abc");
961 }
962
963 #[test]
968 fn test_multiple_providers() {
969 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
970 let config = ProviderConfig::parse(content);
971 assert_eq!(config.sections.len(), 3);
972 assert_eq!(config.sections[0].provider, "digitalocean");
973 assert_eq!(config.sections[1].provider, "vultr");
974 assert_eq!(config.sections[2].provider, "proxmox");
975 assert_eq!(config.sections[2].url, "https://pve:8006");
976 }
977
978 #[test]
983 fn test_token_with_equals_sign() {
984 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
986 let config = ProviderConfig::parse(content);
987 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
989 }
990
991 #[test]
992 fn test_proxmox_token_with_exclamation() {
993 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
994 let config = ProviderConfig::parse(content);
995 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
996 }
997
998 #[test]
1003 fn test_serialize_roundtrip_single_provider() {
1004 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
1005 let config = ProviderConfig::parse(content);
1006 let mut serialized = String::new();
1007 for section in &config.sections {
1008 serialized.push_str(&format!("[{}]\n", section.provider));
1009 serialized.push_str(&format!("token={}\n", section.token));
1010 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
1011 serialized.push_str(&format!("user={}\n", section.user));
1012 }
1013 let reparsed = ProviderConfig::parse(&serialized);
1014 assert_eq!(reparsed.sections.len(), 1);
1015 assert_eq!(reparsed.sections[0].token, "abc");
1016 assert_eq!(reparsed.sections[0].alias_prefix, "do");
1017 assert_eq!(reparsed.sections[0].user, "root");
1018 }
1019
1020 #[test]
1025 fn test_verify_tls_values() {
1026 for (val, expected) in [
1027 ("false", false),
1028 ("False", false),
1029 ("FALSE", false),
1030 ("0", false),
1031 ("no", false),
1032 ("No", false),
1033 ("NO", false),
1034 ("true", true),
1035 ("True", true),
1036 ("1", true),
1037 ("yes", true),
1038 ("anything", true), ] {
1040 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1041 let config = ProviderConfig::parse(&content);
1042 assert_eq!(
1043 config.sections[0].verify_tls, expected,
1044 "verify_tls={} should be {}",
1045 val, expected
1046 );
1047 }
1048 }
1049
1050 #[test]
1055 fn test_auto_sync_values() {
1056 for (val, expected) in [
1057 ("false", false),
1058 ("False", false),
1059 ("FALSE", false),
1060 ("0", false),
1061 ("no", false),
1062 ("No", false),
1063 ("true", true),
1064 ("1", true),
1065 ("yes", true),
1066 ] {
1067 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1068 let config = ProviderConfig::parse(&content);
1069 assert_eq!(
1070 config.sections[0].auto_sync, expected,
1071 "auto_sync={} should be {}",
1072 val, expected
1073 );
1074 }
1075 }
1076
1077 #[test]
1082 fn test_default_user_root_when_not_specified() {
1083 let content = "[digitalocean]\ntoken=abc\n";
1084 let config = ProviderConfig::parse(content);
1085 assert_eq!(config.sections[0].user, "root");
1086 }
1087
1088 #[test]
1089 fn test_default_alias_prefix_from_short_label() {
1090 let content = "[digitalocean]\ntoken=abc\n";
1092 let config = ProviderConfig::parse(content);
1093 assert_eq!(config.sections[0].alias_prefix, "do");
1094 }
1095
1096 #[test]
1097 fn test_default_alias_prefix_unknown_provider() {
1098 let content = "[unknown_cloud]\ntoken=abc\n";
1100 let config = ProviderConfig::parse(content);
1101 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1102 }
1103
1104 #[test]
1105 fn test_default_identity_file_empty() {
1106 let content = "[digitalocean]\ntoken=abc\n";
1107 let config = ProviderConfig::parse(content);
1108 assert!(config.sections[0].identity_file.is_empty());
1109 }
1110
1111 #[test]
1112 fn test_default_url_empty() {
1113 let content = "[digitalocean]\ntoken=abc\n";
1114 let config = ProviderConfig::parse(content);
1115 assert!(config.sections[0].url.is_empty());
1116 }
1117
1118 #[test]
1123 fn test_gcp_project_parsed() {
1124 let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1125 assert_eq!(config.sections[0].project, "my-gcp-project");
1126 }
1127
1128 #[test]
1129 fn test_gcp_project_default_empty() {
1130 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1131 assert!(config.sections[0].project.is_empty());
1132 }
1133
1134 #[test]
1135 fn test_gcp_project_roundtrip() {
1136 let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1137 let config = ProviderConfig::parse(content);
1138 assert_eq!(config.sections[0].project, "my-project");
1139 assert_eq!(config.sections[0].regions, "us-central1-a");
1140 let serialized = format!(
1142 "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1143 config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1144 );
1145 let reparsed = ProviderConfig::parse(&serialized);
1146 assert_eq!(reparsed.sections[0].project, "my-project");
1147 assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1148 }
1149
1150 #[test]
1151 fn test_default_alias_prefix_gcp() {
1152 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1153 assert_eq!(config.sections[0].alias_prefix, "gcp");
1154 }
1155
1156 #[test]
1161 fn test_configured_providers_returns_all_sections() {
1162 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1163 let config = ProviderConfig::parse(content);
1164 assert_eq!(config.configured_providers().len(), 2);
1165 }
1166
1167 #[test]
1168 fn test_section_by_name() {
1169 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1170 let config = ProviderConfig::parse(content);
1171 let do_section = config.section("digitalocean").unwrap();
1172 assert_eq!(do_section.token, "do-tok");
1173 let vultr_section = config.section("vultr").unwrap();
1174 assert_eq!(vultr_section.token, "vultr-tok");
1175 }
1176
1177 #[test]
1178 fn test_section_not_found() {
1179 let config = ProviderConfig::parse("");
1180 assert!(config.section("nonexistent").is_none());
1181 }
1182
1183 #[test]
1188 fn test_line_without_equals_ignored() {
1189 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1190 let config = ProviderConfig::parse(content);
1191 assert_eq!(config.sections[0].token, "abc");
1192 assert_eq!(config.sections[0].user, "admin");
1193 }
1194
1195 #[test]
1196 fn test_unknown_key_ignored() {
1197 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1198 let config = ProviderConfig::parse(content);
1199 assert_eq!(config.sections[0].token, "abc");
1200 assert_eq!(config.sections[0].user, "admin");
1201 }
1202
1203 #[test]
1208 fn test_whitespace_around_section_name() {
1209 let content = "[ digitalocean ]\ntoken=abc\n";
1210 let config = ProviderConfig::parse(content);
1211 assert_eq!(config.sections[0].provider, "digitalocean");
1212 }
1213
1214 #[test]
1215 fn test_whitespace_around_key_value() {
1216 let content = "[digitalocean]\n token = abc \n user = admin \n";
1217 let config = ProviderConfig::parse(content);
1218 assert_eq!(config.sections[0].token, "abc");
1219 assert_eq!(config.sections[0].user, "admin");
1220 }
1221
1222 #[test]
1227 fn test_set_section_multiple_adds() {
1228 let mut config = ProviderConfig::default();
1229 for name in ["digitalocean", "vultr", "hetzner"] {
1230 config.set_section(ProviderSection {
1231 provider: name.to_string(),
1232 token: format!("{}-tok", name),
1233 alias_prefix: name.to_string(),
1234 user: "root".to_string(),
1235 identity_file: String::new(),
1236 url: String::new(),
1237 verify_tls: true,
1238 auto_sync: true,
1239 profile: String::new(),
1240 regions: String::new(),
1241 project: String::new(),
1242 compartment: String::new(),
1243 });
1244 }
1245 assert_eq!(config.sections.len(), 3);
1246 }
1247
1248 #[test]
1249 fn test_remove_section_all() {
1250 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1251 let mut config = ProviderConfig::parse(content);
1252 config.remove_section("digitalocean");
1253 config.remove_section("vultr");
1254 assert!(config.sections.is_empty());
1255 }
1256
1257 #[test]
1262 fn test_compartment_field_round_trip() {
1263 use std::path::PathBuf;
1264 let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1265 let config = ProviderConfig::parse(content);
1266 assert_eq!(
1267 config.sections[0].compartment,
1268 "ocid1.compartment.oc1..example"
1269 );
1270
1271 let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1273 let mut cfg = config;
1274 cfg.path_override = Some(PathBuf::from(&tmp));
1275 cfg.save().expect("save failed");
1276 let saved = std::fs::read_to_string(&tmp).expect("read failed");
1277 let _ = std::fs::remove_file(&tmp);
1278 let reparsed = ProviderConfig::parse(&saved);
1279 assert_eq!(
1280 reparsed.sections[0].compartment,
1281 "ocid1.compartment.oc1..example"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_auto_sync_default_true_for_oracle() {
1287 let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1288 assert!(config.sections[0].auto_sync);
1289 }
1290
1291 #[test]
1292 fn test_sanitize_value_strips_control_chars() {
1293 assert_eq!(ProviderConfig::sanitize_value("clean"), "clean");
1294 assert_eq!(ProviderConfig::sanitize_value("has\nnewline"), "hasnewline");
1295 assert_eq!(ProviderConfig::sanitize_value("has\ttab"), "hastab");
1296 assert_eq!(
1297 ProviderConfig::sanitize_value("has\rcarriage"),
1298 "hascarriage"
1299 );
1300 assert_eq!(ProviderConfig::sanitize_value("has\x00null"), "hasnull");
1301 assert_eq!(ProviderConfig::sanitize_value(""), "");
1302 }
1303
1304 #[test]
1305 fn test_save_sanitizes_token_with_newline() {
1306 let path = std::env::temp_dir().join(format!(
1307 "__purple_test_config_sanitize_{}.ini",
1308 std::process::id()
1309 ));
1310 let config = ProviderConfig {
1311 sections: vec![ProviderSection {
1312 provider: "digitalocean".to_string(),
1313 token: "abc\ndef".to_string(),
1314 alias_prefix: "do".to_string(),
1315 user: "root".to_string(),
1316 identity_file: String::new(),
1317 url: String::new(),
1318 verify_tls: true,
1319 auto_sync: true,
1320 profile: String::new(),
1321 regions: String::new(),
1322 project: String::new(),
1323 compartment: String::new(),
1324 }],
1325 path_override: Some(path.clone()),
1326 };
1327 config.save().unwrap();
1328 let content = std::fs::read_to_string(&path).unwrap();
1329 let _ = std::fs::remove_file(&path);
1330 assert!(content.contains("token=abcdef\n"));
1332 assert!(!content.contains("token=abc\ndef"));
1333 }
1334}