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 pub vault_role: String,
22 pub vault_addr: String,
26}
27
28fn default_auto_sync(provider: &str) -> bool {
30 !matches!(provider, "proxmox")
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct ProviderConfig {
36 pub sections: Vec<ProviderSection>,
37 pub path_override: Option<PathBuf>,
40}
41
42fn config_path() -> Option<PathBuf> {
43 dirs::home_dir().map(|h| h.join(".purple/providers"))
44}
45
46impl ProviderConfig {
47 pub fn load() -> Self {
51 let path = match config_path() {
52 Some(p) => p,
53 None => return Self::default(),
54 };
55 let content = match std::fs::read_to_string(&path) {
56 Ok(c) => c,
57 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
58 Err(e) => {
59 eprintln!("! Could not read {}: {}", path.display(), e);
60 return Self::default();
61 }
62 };
63 Self::parse(&content)
64 }
65
66 pub(crate) fn parse(content: &str) -> Self {
68 let mut sections = Vec::new();
69 let mut current: Option<ProviderSection> = None;
70
71 for line in content.lines() {
72 let trimmed = line.trim();
73 if trimmed.is_empty() || trimmed.starts_with('#') {
74 continue;
75 }
76 if trimmed.starts_with('[') && trimmed.ends_with(']') {
77 if let Some(section) = current.take() {
78 if !sections
79 .iter()
80 .any(|s: &ProviderSection| s.provider == section.provider)
81 {
82 sections.push(section);
83 }
84 }
85 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
86 if sections.iter().any(|s| s.provider == name) {
87 current = None;
88 continue;
89 }
90 let short_label = super::get_provider(&name)
91 .map(|p| p.short_label().to_string())
92 .unwrap_or_else(|| name.clone());
93 let auto_sync_default = default_auto_sync(&name);
94 current = Some(ProviderSection {
95 provider: name,
96 token: String::new(),
97 alias_prefix: short_label,
98 user: "root".to_string(),
99 identity_file: String::new(),
100 url: String::new(),
101 verify_tls: true,
102 auto_sync: auto_sync_default,
103 profile: String::new(),
104 regions: String::new(),
105 project: String::new(),
106 compartment: String::new(),
107 vault_role: String::new(),
108 vault_addr: String::new(),
109 });
110 } else if let Some(ref mut section) = current {
111 if let Some((key, value)) = trimmed.split_once('=') {
112 let key = key.trim();
113 let value = value.trim().to_string();
114 match key {
115 "token" => section.token = value,
116 "alias_prefix" => section.alias_prefix = value,
117 "user" => section.user = value,
118 "key" => section.identity_file = value,
119 "url" => section.url = value,
120 "verify_tls" => {
121 section.verify_tls =
122 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
123 }
124 "auto_sync" => {
125 section.auto_sync =
126 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
127 }
128 "profile" => section.profile = value,
129 "regions" => section.regions = value,
130 "project" => section.project = value,
131 "compartment" => section.compartment = value,
132 "vault_role" => {
133 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
135 value
136 } else {
137 String::new()
138 };
139 }
140 "vault_addr" => {
141 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
145 value
146 } else {
147 String::new()
148 };
149 }
150 _ => {}
151 }
152 }
153 }
154 }
155 if let Some(section) = current {
156 if !sections.iter().any(|s| s.provider == section.provider) {
157 sections.push(section);
158 }
159 }
160 Self {
161 sections,
162 path_override: None,
163 }
164 }
165
166 fn sanitize_value(s: &str) -> String {
169 s.chars().filter(|c| !c.is_control()).collect()
170 }
171
172 pub fn save(&self) -> io::Result<()> {
175 if crate::demo_flag::is_demo() {
176 return Ok(());
177 }
178 let path = match &self.path_override {
179 Some(p) => p.clone(),
180 None => match config_path() {
181 Some(p) => p,
182 None => {
183 return Err(io::Error::new(
184 io::ErrorKind::NotFound,
185 "Could not determine home directory",
186 ));
187 }
188 },
189 };
190
191 let mut content = String::new();
192 for (i, section) in self.sections.iter().enumerate() {
193 if i > 0 {
194 content.push('\n');
195 }
196 content.push_str(&format!("[{}]\n", Self::sanitize_value(§ion.provider)));
197 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
198 content.push_str(&format!(
199 "alias_prefix={}\n",
200 Self::sanitize_value(§ion.alias_prefix)
201 ));
202 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
203 if !section.identity_file.is_empty() {
204 content.push_str(&format!(
205 "key={}\n",
206 Self::sanitize_value(§ion.identity_file)
207 ));
208 }
209 if !section.url.is_empty() {
210 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
211 }
212 if !section.verify_tls {
213 content.push_str("verify_tls=false\n");
214 }
215 if !section.profile.is_empty() {
216 content.push_str(&format!(
217 "profile={}\n",
218 Self::sanitize_value(§ion.profile)
219 ));
220 }
221 if !section.regions.is_empty() {
222 content.push_str(&format!(
223 "regions={}\n",
224 Self::sanitize_value(§ion.regions)
225 ));
226 }
227 if !section.project.is_empty() {
228 content.push_str(&format!(
229 "project={}\n",
230 Self::sanitize_value(§ion.project)
231 ));
232 }
233 if !section.compartment.is_empty() {
234 content.push_str(&format!(
235 "compartment={}\n",
236 Self::sanitize_value(§ion.compartment)
237 ));
238 }
239 if !section.vault_role.is_empty()
240 && crate::vault_ssh::is_valid_role(§ion.vault_role)
241 {
242 content.push_str(&format!(
243 "vault_role={}\n",
244 Self::sanitize_value(§ion.vault_role)
245 ));
246 }
247 if !section.vault_addr.is_empty()
248 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
249 {
250 content.push_str(&format!(
251 "vault_addr={}\n",
252 Self::sanitize_value(§ion.vault_addr)
253 ));
254 }
255 if section.auto_sync != default_auto_sync(§ion.provider) {
256 content.push_str(if section.auto_sync {
257 "auto_sync=true\n"
258 } else {
259 "auto_sync=false\n"
260 });
261 }
262 }
263
264 fs_util::atomic_write(&path, content.as_bytes())
265 }
266
267 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
269 self.sections.iter().find(|s| s.provider == provider)
270 }
271
272 pub fn set_section(&mut self, section: ProviderSection) {
274 if let Some(existing) = self
275 .sections
276 .iter_mut()
277 .find(|s| s.provider == section.provider)
278 {
279 *existing = section;
280 } else {
281 self.sections.push(section);
282 }
283 }
284
285 pub fn remove_section(&mut self, provider: &str) {
287 self.sections.retain(|s| s.provider != provider);
288 }
289
290 pub fn configured_providers(&self) -> &[ProviderSection] {
292 &self.sections
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn vault_role_invalid_is_dropped_on_parse() {
302 let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=-format=json\n");
303 assert_eq!(config.sections.len(), 1);
304 assert!(config.sections[0].vault_role.is_empty());
305 }
306
307 #[test]
308 fn vault_role_valid_is_parsed() {
309 let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=ssh/sign/engineer\n");
310 assert_eq!(config.sections[0].vault_role, "ssh/sign/engineer");
311 }
312
313 #[test]
314 fn vault_role_roundtrip_preserves_value() {
315 let tmp = std::env::temp_dir().join(format!(
319 "purple_vault_role_roundtrip_{}_{}",
320 std::process::id(),
321 std::time::SystemTime::now()
322 .duration_since(std::time::UNIX_EPOCH)
323 .map(|d| d.as_nanos())
324 .unwrap_or(0)
325 ));
326 let config = ProviderConfig {
327 path_override: Some(tmp.clone()),
328 sections: vec![ProviderSection {
329 provider: "aws".to_string(),
330 token: "abc".to_string(),
331 alias_prefix: "aws".to_string(),
332 user: "ec2-user".to_string(),
333 identity_file: String::new(),
334 url: String::new(),
335 verify_tls: true,
336 profile: String::new(),
337 regions: "us-east-1".to_string(),
338 project: String::new(),
339 compartment: String::new(),
340 vault_role: "ssh-client-signer/sign/engineer".to_string(),
341 vault_addr: String::new(),
342 auto_sync: true,
343 }],
344 };
345 config.save().expect("save failed");
346
347 let content = std::fs::read_to_string(&tmp).expect("read failed");
348 assert!(
349 content.contains("vault_role=ssh-client-signer/sign/engineer"),
350 "serialized form missing vault_role: {}",
351 content
352 );
353
354 let reparsed = ProviderConfig::parse(&content);
355 assert_eq!(reparsed.sections.len(), 1);
356 assert_eq!(
357 reparsed.sections[0].vault_role,
358 "ssh-client-signer/sign/engineer"
359 );
360
361 let _ = std::fs::remove_file(&tmp);
362 }
363
364 #[test]
365 fn vault_role_invalid_skipped_on_write() {
366 let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
367 config.sections[0].vault_role = "bad role".to_string();
369 let mut out = String::new();
371 if !config.sections[0].vault_role.is_empty()
372 && crate::vault_ssh::is_valid_role(&config.sections[0].vault_role)
373 {
374 out.push_str("vault_role=");
375 }
376 assert!(out.is_empty(), "invalid role must be skipped on write");
377 }
378
379 #[test]
380 fn test_parse_empty() {
381 let config = ProviderConfig::parse("");
382 assert!(config.sections.is_empty());
383 }
384
385 #[test]
386 fn test_parse_single_section() {
387 let content = "\
388[digitalocean]
389token=dop_v1_abc123
390alias_prefix=do
391user=root
392key=~/.ssh/id_ed25519
393";
394 let config = ProviderConfig::parse(content);
395 assert_eq!(config.sections.len(), 1);
396 let s = &config.sections[0];
397 assert_eq!(s.provider, "digitalocean");
398 assert_eq!(s.token, "dop_v1_abc123");
399 assert_eq!(s.alias_prefix, "do");
400 assert_eq!(s.user, "root");
401 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
402 }
403
404 #[test]
405 fn test_parse_multiple_sections() {
406 let content = "\
407[digitalocean]
408token=abc
409
410[vultr]
411token=xyz
412user=deploy
413";
414 let config = ProviderConfig::parse(content);
415 assert_eq!(config.sections.len(), 2);
416 assert_eq!(config.sections[0].provider, "digitalocean");
417 assert_eq!(config.sections[1].provider, "vultr");
418 assert_eq!(config.sections[1].user, "deploy");
419 }
420
421 #[test]
422 fn test_parse_comments_and_blanks() {
423 let content = "\
424# Provider config
425
426[linode]
427# API token
428token=mytoken
429";
430 let config = ProviderConfig::parse(content);
431 assert_eq!(config.sections.len(), 1);
432 assert_eq!(config.sections[0].token, "mytoken");
433 }
434
435 #[test]
436 fn test_set_section_add() {
437 let mut config = ProviderConfig::default();
438 config.set_section(ProviderSection {
439 provider: "vultr".to_string(),
440 token: "abc".to_string(),
441 alias_prefix: "vultr".to_string(),
442 user: "root".to_string(),
443 identity_file: String::new(),
444 url: String::new(),
445 verify_tls: true,
446 auto_sync: true,
447 profile: String::new(),
448 regions: String::new(),
449 project: String::new(),
450 compartment: String::new(),
451 vault_role: String::new(),
452 vault_addr: String::new(),
453 });
454 assert_eq!(config.sections.len(), 1);
455 }
456
457 #[test]
458 fn test_set_section_replace() {
459 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
460 config.set_section(ProviderSection {
461 provider: "vultr".to_string(),
462 token: "new".to_string(),
463 alias_prefix: "vultr".to_string(),
464 user: "root".to_string(),
465 identity_file: String::new(),
466 url: String::new(),
467 verify_tls: true,
468 auto_sync: true,
469 profile: String::new(),
470 regions: String::new(),
471 project: String::new(),
472 compartment: String::new(),
473 vault_role: String::new(),
474 vault_addr: String::new(),
475 });
476 assert_eq!(config.sections.len(), 1);
477 assert_eq!(config.sections[0].token, "new");
478 }
479
480 #[test]
481 fn test_remove_section() {
482 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
483 config.remove_section("vultr");
484 assert_eq!(config.sections.len(), 1);
485 assert_eq!(config.sections[0].provider, "linode");
486 }
487
488 #[test]
489 fn test_section_lookup() {
490 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
491 assert!(config.section("digitalocean").is_some());
492 assert!(config.section("vultr").is_none());
493 }
494
495 #[test]
496 fn test_parse_duplicate_sections_first_wins() {
497 let content = "\
498[digitalocean]
499token=first
500
501[digitalocean]
502token=second
503";
504 let config = ProviderConfig::parse(content);
505 assert_eq!(config.sections.len(), 1);
506 assert_eq!(config.sections[0].token, "first");
507 }
508
509 #[test]
510 fn test_parse_duplicate_sections_trailing() {
511 let content = "\
512[vultr]
513token=abc
514
515[linode]
516token=xyz
517
518[vultr]
519token=dup
520";
521 let config = ProviderConfig::parse(content);
522 assert_eq!(config.sections.len(), 2);
523 assert_eq!(config.sections[0].provider, "vultr");
524 assert_eq!(config.sections[0].token, "abc");
525 assert_eq!(config.sections[1].provider, "linode");
526 }
527
528 #[test]
529 fn test_defaults_applied() {
530 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
531 let s = &config.sections[0];
532 assert_eq!(s.user, "root");
533 assert_eq!(s.alias_prefix, "hetzner");
534 assert!(s.identity_file.is_empty());
535 assert!(s.url.is_empty());
536 assert!(s.verify_tls);
537 assert!(s.auto_sync);
538 }
539
540 #[test]
541 fn test_parse_url_and_verify_tls() {
542 let content = "\
543[proxmox]
544token=user@pam!purple=secret
545url=https://pve.example.com:8006
546verify_tls=false
547";
548 let config = ProviderConfig::parse(content);
549 assert_eq!(config.sections.len(), 1);
550 let s = &config.sections[0];
551 assert_eq!(s.url, "https://pve.example.com:8006");
552 assert!(!s.verify_tls);
553 }
554
555 #[test]
556 fn test_url_and_verify_tls_round_trip() {
557 let content = "\
558[proxmox]
559token=tok
560alias_prefix=pve
561user=root
562url=https://pve.local:8006
563verify_tls=false
564";
565 let config = ProviderConfig::parse(content);
566 let s = &config.sections[0];
567 assert_eq!(s.url, "https://pve.local:8006");
568 assert!(!s.verify_tls);
569 }
570
571 #[test]
572 fn test_verify_tls_default_true() {
573 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
575 assert!(config.sections[0].verify_tls);
576 }
577
578 #[test]
579 fn test_verify_tls_false_variants() {
580 for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
581 let content = format!(
582 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
583 value
584 );
585 let config = ProviderConfig::parse(&content);
586 assert!(
587 !config.sections[0].verify_tls,
588 "verify_tls={} should be false",
589 value
590 );
591 }
592 }
593
594 #[test]
595 fn test_verify_tls_true_variants() {
596 for value in &["true", "True", "1", "yes"] {
597 let content = format!(
598 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n",
599 value
600 );
601 let config = ProviderConfig::parse(&content);
602 assert!(
603 config.sections[0].verify_tls,
604 "verify_tls={} should be true",
605 value
606 );
607 }
608 }
609
610 #[test]
611 fn test_non_proxmox_url_not_written() {
612 let section = ProviderSection {
614 provider: "digitalocean".to_string(),
615 token: "tok".to_string(),
616 alias_prefix: "do".to_string(),
617 user: "root".to_string(),
618 identity_file: String::new(),
619 url: String::new(), verify_tls: true, auto_sync: true, profile: String::new(),
623 regions: String::new(),
624 project: String::new(),
625 compartment: String::new(),
626 vault_role: String::new(),
627 vault_addr: String::new(),
628 };
629 let mut config = ProviderConfig::default();
630 config.set_section(section);
631 let s = &config.sections[0];
633 assert!(s.url.is_empty());
634 assert!(s.verify_tls);
635 }
636
637 #[test]
638 fn test_proxmox_url_fallback_in_section() {
639 let existing = ProviderConfig::parse(
641 "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
642 );
643 let existing_url = existing
644 .section("proxmox")
645 .map(|s| s.url.clone())
646 .unwrap_or_default();
647 assert_eq!(existing_url, "https://pve.local:8006");
648
649 let mut config = existing;
650 config.set_section(ProviderSection {
651 provider: "proxmox".to_string(),
652 token: "new".to_string(),
653 alias_prefix: "pve".to_string(),
654 user: "root".to_string(),
655 identity_file: String::new(),
656 url: existing_url,
657 verify_tls: true,
658 auto_sync: false,
659 profile: String::new(),
660 regions: String::new(),
661 project: String::new(),
662 compartment: String::new(),
663 vault_role: String::new(),
664 vault_addr: String::new(),
665 });
666 assert_eq!(config.sections[0].token, "new");
667 assert_eq!(config.sections[0].url, "https://pve.local:8006");
668 }
669
670 #[test]
671 fn test_auto_sync_default_true_for_non_proxmox() {
672 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
673 assert!(config.sections[0].auto_sync);
674 }
675
676 #[test]
677 fn test_auto_sync_default_false_for_proxmox() {
678 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
679 assert!(!config.sections[0].auto_sync);
680 }
681
682 #[test]
683 fn test_auto_sync_explicit_true() {
684 let config =
685 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
686 assert!(config.sections[0].auto_sync);
687 }
688
689 #[test]
690 fn test_auto_sync_explicit_false_non_proxmox() {
691 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
692 assert!(!config.sections[0].auto_sync);
693 }
694
695 #[test]
696 fn test_auto_sync_not_written_when_default() {
697 let mut config = ProviderConfig::default();
699 config.set_section(ProviderSection {
700 provider: "digitalocean".to_string(),
701 token: "tok".to_string(),
702 alias_prefix: "do".to_string(),
703 user: "root".to_string(),
704 identity_file: String::new(),
705 url: String::new(),
706 verify_tls: true,
707 auto_sync: true,
708 profile: String::new(),
709 regions: String::new(),
710 project: String::new(),
711 compartment: String::new(),
712 vault_role: String::new(),
713 vault_addr: String::new(),
714 });
715 assert!(config.sections[0].auto_sync);
717
718 let mut config2 = ProviderConfig::default();
720 config2.set_section(ProviderSection {
721 provider: "proxmox".to_string(),
722 token: "tok".to_string(),
723 alias_prefix: "pve".to_string(),
724 user: "root".to_string(),
725 identity_file: String::new(),
726 url: "https://pve:8006".to_string(),
727 verify_tls: true,
728 auto_sync: false,
729 profile: String::new(),
730 regions: String::new(),
731 project: String::new(),
732 compartment: String::new(),
733 vault_role: String::new(),
734 vault_addr: String::new(),
735 });
736 assert!(!config2.sections[0].auto_sync);
737 }
738
739 #[test]
740 fn test_auto_sync_false_variants() {
741 for value in &["false", "False", "FALSE", "0", "no"] {
742 let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
743 let config = ProviderConfig::parse(&content);
744 assert!(
745 !config.sections[0].auto_sync,
746 "auto_sync={} should be false",
747 value
748 );
749 }
750 }
751
752 #[test]
753 fn test_auto_sync_true_variants() {
754 for value in &["true", "True", "TRUE", "1", "yes"] {
755 let content = format!(
757 "[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n",
758 value
759 );
760 let config = ProviderConfig::parse(&content);
761 assert!(
762 config.sections[0].auto_sync,
763 "auto_sync={} should be true",
764 value
765 );
766 }
767 }
768
769 #[test]
770 fn test_auto_sync_malformed_value_treated_as_true() {
771 let config =
773 ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
774 assert!(config.sections[0].auto_sync);
775 }
776
777 #[test]
778 fn test_auto_sync_written_only_when_non_default() {
779 let mut config = ProviderConfig::default();
781 config.set_section(ProviderSection {
782 provider: "proxmox".to_string(),
783 token: "tok".to_string(),
784 alias_prefix: "pve".to_string(),
785 user: "root".to_string(),
786 identity_file: String::new(),
787 url: "https://pve:8006".to_string(),
788 verify_tls: true,
789 auto_sync: true, profile: String::new(),
791 regions: String::new(),
792 project: String::new(),
793 compartment: String::new(),
794 vault_role: String::new(),
795 vault_addr: String::new(),
796 });
797 let content =
799 "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
800 .to_string();
801 let reparsed = ProviderConfig::parse(&content);
802 assert!(reparsed.sections[0].auto_sync);
803
804 let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
806 let reparsed2 = ProviderConfig::parse(content2);
807 assert!(!reparsed2.sections[0].auto_sync);
808 }
809
810 #[test]
815 fn test_configured_providers_empty() {
816 let config = ProviderConfig::default();
817 assert!(config.configured_providers().is_empty());
818 }
819
820 #[test]
821 fn test_configured_providers_returns_all() {
822 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
823 let config = ProviderConfig::parse(content);
824 assert_eq!(config.configured_providers().len(), 2);
825 }
826
827 #[test]
832 fn test_parse_unknown_keys_ignored() {
833 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
834 let config = ProviderConfig::parse(content);
835 assert_eq!(config.sections.len(), 1);
836 assert_eq!(config.sections[0].token, "abc");
837 }
838
839 #[test]
840 fn test_parse_unknown_provider_still_parsed() {
841 let content = "[aws]\ntoken=secret\n";
842 let config = ProviderConfig::parse(content);
843 assert_eq!(config.sections.len(), 1);
844 assert_eq!(config.sections[0].provider, "aws");
845 }
846
847 #[test]
848 fn test_parse_whitespace_in_section_name() {
849 let content = "[ digitalocean ]\ntoken=abc\n";
850 let config = ProviderConfig::parse(content);
851 assert_eq!(config.sections.len(), 1);
852 assert_eq!(config.sections[0].provider, "digitalocean");
853 }
854
855 #[test]
856 fn test_parse_value_with_equals() {
857 let content = "[digitalocean]\ntoken=abc=def==\n";
859 let config = ProviderConfig::parse(content);
860 assert_eq!(config.sections[0].token, "abc=def==");
861 }
862
863 #[test]
864 fn test_parse_whitespace_around_key_value() {
865 let content = "[digitalocean]\n token = my-token \n";
866 let config = ProviderConfig::parse(content);
867 assert_eq!(config.sections[0].token, "my-token");
868 }
869
870 #[test]
871 fn test_parse_key_field_sets_identity_file() {
872 let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
873 let config = ProviderConfig::parse(content);
874 assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
875 }
876
877 #[test]
878 fn test_section_lookup_missing() {
879 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
880 assert!(config.section("vultr").is_none());
881 }
882
883 #[test]
884 fn test_section_lookup_found() {
885 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
886 let section = config.section("digitalocean").unwrap();
887 assert_eq!(section.token, "abc");
888 }
889
890 #[test]
891 fn test_remove_nonexistent_section_noop() {
892 let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
893 config.remove_section("vultr");
894 assert_eq!(config.sections.len(), 1);
895 }
896
897 #[test]
902 fn test_default_alias_prefix_digitalocean() {
903 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
904 assert_eq!(config.sections[0].alias_prefix, "do");
905 }
906
907 #[test]
908 fn test_default_alias_prefix_upcloud() {
909 let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
910 assert_eq!(config.sections[0].alias_prefix, "uc");
911 }
912
913 #[test]
914 fn test_default_alias_prefix_proxmox() {
915 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
916 assert_eq!(config.sections[0].alias_prefix, "pve");
917 }
918
919 #[test]
920 fn test_alias_prefix_override() {
921 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
922 assert_eq!(config.sections[0].alias_prefix, "ocean");
923 }
924
925 #[test]
930 fn test_default_user_is_root() {
931 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
932 assert_eq!(config.sections[0].user, "root");
933 }
934
935 #[test]
936 fn test_user_override() {
937 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
938 assert_eq!(config.sections[0].user, "admin");
939 }
940
941 #[test]
946 fn test_proxmox_url_parsed() {
947 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
948 assert_eq!(config.sections[0].url, "https://pve.local:8006");
949 }
950
951 #[test]
952 fn test_non_proxmox_url_parsed_but_ignored() {
953 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
955 assert_eq!(config.sections[0].url, "https://api.do.com");
956 }
957
958 #[test]
963 fn test_duplicate_section_first_wins() {
964 let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
965 let config = ProviderConfig::parse(content);
966 assert_eq!(config.sections.len(), 1);
967 assert_eq!(config.sections[0].token, "first");
968 }
969
970 #[test]
979 fn test_auto_sync_default_proxmox_false() {
980 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
981 assert!(!config.sections[0].auto_sync);
982 }
983
984 #[test]
985 fn test_auto_sync_default_all_others_true() {
986 for provider in &[
987 "digitalocean",
988 "vultr",
989 "linode",
990 "hetzner",
991 "upcloud",
992 "aws",
993 "scaleway",
994 "gcp",
995 "azure",
996 "tailscale",
997 "oracle",
998 "ovh",
999 ] {
1000 let content = format!("[{}]\ntoken=abc\n", provider);
1001 let config = ProviderConfig::parse(&content);
1002 assert!(
1003 config.sections[0].auto_sync,
1004 "auto_sync should default to true for {}",
1005 provider
1006 );
1007 }
1008 }
1009
1010 #[test]
1011 fn test_auto_sync_override_proxmox_to_true() {
1012 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
1013 assert!(config.sections[0].auto_sync);
1014 }
1015
1016 #[test]
1017 fn test_auto_sync_override_do_to_false() {
1018 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
1019 assert!(!config.sections[0].auto_sync);
1020 }
1021
1022 #[test]
1027 fn test_set_section_adds_new() {
1028 let mut config = ProviderConfig::default();
1029 let section = ProviderSection {
1030 provider: "vultr".to_string(),
1031 token: "tok".to_string(),
1032 alias_prefix: "vultr".to_string(),
1033 user: "root".to_string(),
1034 identity_file: String::new(),
1035 url: String::new(),
1036 verify_tls: true,
1037 auto_sync: true,
1038 profile: String::new(),
1039 regions: String::new(),
1040 project: String::new(),
1041 compartment: String::new(),
1042 vault_role: String::new(),
1043 vault_addr: String::new(),
1044 };
1045 config.set_section(section);
1046 assert_eq!(config.sections.len(), 1);
1047 assert_eq!(config.sections[0].provider, "vultr");
1048 }
1049
1050 #[test]
1051 fn test_set_section_replaces_existing() {
1052 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
1053 assert_eq!(config.sections[0].token, "old");
1054 let section = ProviderSection {
1055 provider: "vultr".to_string(),
1056 token: "new".to_string(),
1057 alias_prefix: "vultr".to_string(),
1058 user: "root".to_string(),
1059 identity_file: String::new(),
1060 url: String::new(),
1061 verify_tls: true,
1062 auto_sync: true,
1063 profile: String::new(),
1064 regions: String::new(),
1065 project: String::new(),
1066 compartment: String::new(),
1067 vault_role: String::new(),
1068 vault_addr: String::new(),
1069 };
1070 config.set_section(section);
1071 assert_eq!(config.sections.len(), 1);
1072 assert_eq!(config.sections[0].token, "new");
1073 }
1074
1075 #[test]
1076 fn test_remove_section_keeps_others() {
1077 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
1078 assert_eq!(config.sections.len(), 2);
1079 config.remove_section("vultr");
1080 assert_eq!(config.sections.len(), 1);
1081 assert_eq!(config.sections[0].provider, "linode");
1082 }
1083
1084 #[test]
1089 fn test_comments_ignored() {
1090 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
1091 let config = ProviderConfig::parse(content);
1092 assert_eq!(config.sections.len(), 1);
1093 assert_eq!(config.sections[0].token, "abc");
1094 }
1095
1096 #[test]
1097 fn test_blank_lines_ignored() {
1098 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
1099 let config = ProviderConfig::parse(content);
1100 assert_eq!(config.sections.len(), 1);
1101 assert_eq!(config.sections[0].token, "abc");
1102 }
1103
1104 #[test]
1109 fn test_multiple_providers() {
1110 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
1111 let config = ProviderConfig::parse(content);
1112 assert_eq!(config.sections.len(), 3);
1113 assert_eq!(config.sections[0].provider, "digitalocean");
1114 assert_eq!(config.sections[1].provider, "vultr");
1115 assert_eq!(config.sections[2].provider, "proxmox");
1116 assert_eq!(config.sections[2].url, "https://pve:8006");
1117 }
1118
1119 #[test]
1124 fn test_token_with_equals_sign() {
1125 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
1127 let config = ProviderConfig::parse(content);
1128 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
1130 }
1131
1132 #[test]
1133 fn test_proxmox_token_with_exclamation() {
1134 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
1135 let config = ProviderConfig::parse(content);
1136 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
1137 }
1138
1139 #[test]
1144 fn test_serialize_roundtrip_single_provider() {
1145 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
1146 let config = ProviderConfig::parse(content);
1147 let mut serialized = String::new();
1148 for section in &config.sections {
1149 serialized.push_str(&format!("[{}]\n", section.provider));
1150 serialized.push_str(&format!("token={}\n", section.token));
1151 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
1152 serialized.push_str(&format!("user={}\n", section.user));
1153 }
1154 let reparsed = ProviderConfig::parse(&serialized);
1155 assert_eq!(reparsed.sections.len(), 1);
1156 assert_eq!(reparsed.sections[0].token, "abc");
1157 assert_eq!(reparsed.sections[0].alias_prefix, "do");
1158 assert_eq!(reparsed.sections[0].user, "root");
1159 }
1160
1161 #[test]
1166 fn test_verify_tls_values() {
1167 for (val, expected) in [
1168 ("false", false),
1169 ("False", false),
1170 ("FALSE", false),
1171 ("0", false),
1172 ("no", false),
1173 ("No", false),
1174 ("NO", false),
1175 ("true", true),
1176 ("True", true),
1177 ("1", true),
1178 ("yes", true),
1179 ("anything", true), ] {
1181 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1182 let config = ProviderConfig::parse(&content);
1183 assert_eq!(
1184 config.sections[0].verify_tls, expected,
1185 "verify_tls={} should be {}",
1186 val, expected
1187 );
1188 }
1189 }
1190
1191 #[test]
1196 fn test_auto_sync_values() {
1197 for (val, expected) in [
1198 ("false", false),
1199 ("False", false),
1200 ("FALSE", false),
1201 ("0", false),
1202 ("no", false),
1203 ("No", false),
1204 ("true", true),
1205 ("1", true),
1206 ("yes", true),
1207 ] {
1208 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1209 let config = ProviderConfig::parse(&content);
1210 assert_eq!(
1211 config.sections[0].auto_sync, expected,
1212 "auto_sync={} should be {}",
1213 val, expected
1214 );
1215 }
1216 }
1217
1218 #[test]
1223 fn test_default_user_root_when_not_specified() {
1224 let content = "[digitalocean]\ntoken=abc\n";
1225 let config = ProviderConfig::parse(content);
1226 assert_eq!(config.sections[0].user, "root");
1227 }
1228
1229 #[test]
1230 fn test_default_alias_prefix_from_short_label() {
1231 let content = "[digitalocean]\ntoken=abc\n";
1233 let config = ProviderConfig::parse(content);
1234 assert_eq!(config.sections[0].alias_prefix, "do");
1235 }
1236
1237 #[test]
1238 fn test_default_alias_prefix_unknown_provider() {
1239 let content = "[unknown_cloud]\ntoken=abc\n";
1241 let config = ProviderConfig::parse(content);
1242 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1243 }
1244
1245 #[test]
1246 fn test_default_identity_file_empty() {
1247 let content = "[digitalocean]\ntoken=abc\n";
1248 let config = ProviderConfig::parse(content);
1249 assert!(config.sections[0].identity_file.is_empty());
1250 }
1251
1252 #[test]
1253 fn test_default_url_empty() {
1254 let content = "[digitalocean]\ntoken=abc\n";
1255 let config = ProviderConfig::parse(content);
1256 assert!(config.sections[0].url.is_empty());
1257 }
1258
1259 #[test]
1264 fn test_gcp_project_parsed() {
1265 let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1266 assert_eq!(config.sections[0].project, "my-gcp-project");
1267 }
1268
1269 #[test]
1270 fn test_gcp_project_default_empty() {
1271 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1272 assert!(config.sections[0].project.is_empty());
1273 }
1274
1275 #[test]
1276 fn test_gcp_project_roundtrip() {
1277 let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1278 let config = ProviderConfig::parse(content);
1279 assert_eq!(config.sections[0].project, "my-project");
1280 assert_eq!(config.sections[0].regions, "us-central1-a");
1281 let serialized = format!(
1283 "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1284 config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1285 );
1286 let reparsed = ProviderConfig::parse(&serialized);
1287 assert_eq!(reparsed.sections[0].project, "my-project");
1288 assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1289 }
1290
1291 #[test]
1292 fn test_default_alias_prefix_gcp() {
1293 let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1294 assert_eq!(config.sections[0].alias_prefix, "gcp");
1295 }
1296
1297 #[test]
1302 fn test_configured_providers_returns_all_sections() {
1303 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1304 let config = ProviderConfig::parse(content);
1305 assert_eq!(config.configured_providers().len(), 2);
1306 }
1307
1308 #[test]
1309 fn test_section_by_name() {
1310 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1311 let config = ProviderConfig::parse(content);
1312 let do_section = config.section("digitalocean").unwrap();
1313 assert_eq!(do_section.token, "do-tok");
1314 let vultr_section = config.section("vultr").unwrap();
1315 assert_eq!(vultr_section.token, "vultr-tok");
1316 }
1317
1318 #[test]
1319 fn test_section_not_found() {
1320 let config = ProviderConfig::parse("");
1321 assert!(config.section("nonexistent").is_none());
1322 }
1323
1324 #[test]
1329 fn test_line_without_equals_ignored() {
1330 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1331 let config = ProviderConfig::parse(content);
1332 assert_eq!(config.sections[0].token, "abc");
1333 assert_eq!(config.sections[0].user, "admin");
1334 }
1335
1336 #[test]
1337 fn test_unknown_key_ignored() {
1338 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1339 let config = ProviderConfig::parse(content);
1340 assert_eq!(config.sections[0].token, "abc");
1341 assert_eq!(config.sections[0].user, "admin");
1342 }
1343
1344 #[test]
1349 fn test_whitespace_around_section_name() {
1350 let content = "[ digitalocean ]\ntoken=abc\n";
1351 let config = ProviderConfig::parse(content);
1352 assert_eq!(config.sections[0].provider, "digitalocean");
1353 }
1354
1355 #[test]
1356 fn test_whitespace_around_key_value() {
1357 let content = "[digitalocean]\n token = abc \n user = admin \n";
1358 let config = ProviderConfig::parse(content);
1359 assert_eq!(config.sections[0].token, "abc");
1360 assert_eq!(config.sections[0].user, "admin");
1361 }
1362
1363 #[test]
1368 fn test_set_section_multiple_adds() {
1369 let mut config = ProviderConfig::default();
1370 for name in ["digitalocean", "vultr", "hetzner"] {
1371 config.set_section(ProviderSection {
1372 provider: name.to_string(),
1373 token: format!("{}-tok", name),
1374 alias_prefix: name.to_string(),
1375 user: "root".to_string(),
1376 identity_file: String::new(),
1377 url: String::new(),
1378 verify_tls: true,
1379 auto_sync: true,
1380 profile: String::new(),
1381 regions: String::new(),
1382 project: String::new(),
1383 compartment: String::new(),
1384 vault_role: String::new(),
1385 vault_addr: String::new(),
1386 });
1387 }
1388 assert_eq!(config.sections.len(), 3);
1389 }
1390
1391 #[test]
1392 fn test_remove_section_all() {
1393 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1394 let mut config = ProviderConfig::parse(content);
1395 config.remove_section("digitalocean");
1396 config.remove_section("vultr");
1397 assert!(config.sections.is_empty());
1398 }
1399
1400 #[test]
1405 fn test_compartment_field_round_trip() {
1406 use std::path::PathBuf;
1407 let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1408 let config = ProviderConfig::parse(content);
1409 assert_eq!(
1410 config.sections[0].compartment,
1411 "ocid1.compartment.oc1..example"
1412 );
1413
1414 let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1416 let mut cfg = config;
1417 cfg.path_override = Some(PathBuf::from(&tmp));
1418 cfg.save().expect("save failed");
1419 let saved = std::fs::read_to_string(&tmp).expect("read failed");
1420 let _ = std::fs::remove_file(&tmp);
1421 let reparsed = ProviderConfig::parse(&saved);
1422 assert_eq!(
1423 reparsed.sections[0].compartment,
1424 "ocid1.compartment.oc1..example"
1425 );
1426 }
1427
1428 #[test]
1429 fn test_auto_sync_default_true_for_oracle() {
1430 let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1431 assert!(config.sections[0].auto_sync);
1432 }
1433
1434 #[test]
1435 fn test_sanitize_value_strips_control_chars() {
1436 assert_eq!(ProviderConfig::sanitize_value("clean"), "clean");
1437 assert_eq!(ProviderConfig::sanitize_value("has\nnewline"), "hasnewline");
1438 assert_eq!(ProviderConfig::sanitize_value("has\ttab"), "hastab");
1439 assert_eq!(
1440 ProviderConfig::sanitize_value("has\rcarriage"),
1441 "hascarriage"
1442 );
1443 assert_eq!(ProviderConfig::sanitize_value("has\x00null"), "hasnull");
1444 assert_eq!(ProviderConfig::sanitize_value(""), "");
1445 }
1446
1447 #[test]
1448 fn test_save_sanitizes_token_with_newline() {
1449 let path = std::env::temp_dir().join(format!(
1450 "__purple_test_config_sanitize_{}.ini",
1451 std::process::id()
1452 ));
1453 let config = ProviderConfig {
1454 sections: vec![ProviderSection {
1455 provider: "digitalocean".to_string(),
1456 token: "abc\ndef".to_string(),
1457 alias_prefix: "do".to_string(),
1458 user: "root".to_string(),
1459 identity_file: String::new(),
1460 url: String::new(),
1461 verify_tls: true,
1462 auto_sync: true,
1463 profile: String::new(),
1464 regions: String::new(),
1465 project: String::new(),
1466 compartment: String::new(),
1467 vault_role: String::new(),
1468 vault_addr: String::new(),
1469 }],
1470 path_override: Some(path.clone()),
1471 };
1472 config.save().unwrap();
1473 let content = std::fs::read_to_string(&path).unwrap();
1474 let _ = std::fs::remove_file(&path);
1475 assert!(content.contains("token=abcdef\n"));
1477 assert!(!content.contains("token=abc\ndef"));
1478 }
1479
1480 #[test]
1481 fn provider_vault_role_invalid_characters_rejected_on_parse() {
1482 let cases = [
1486 "[aws]\ntoken=abc\nvault_role=bad role\n",
1487 "[aws]\ntoken=abc\nvault_role=role;rm\n",
1488 "[aws]\ntoken=abc\nvault_role=role$(x)\n",
1489 "[aws]\ntoken=abc\nvault_role=role|cat\n",
1490 ];
1491 for input in &cases {
1492 let config = ProviderConfig::parse(input);
1493 assert!(
1494 config.sections[0].vault_role.is_empty(),
1495 "expected empty vault_role for input: {:?}",
1496 input
1497 );
1498 }
1499 }
1500
1501 #[test]
1502 fn test_vault_role_default_empty() {
1503 let config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1504 assert!(config.sections[0].vault_role.is_empty());
1505 }
1506
1507 #[test]
1508 fn test_vault_role_not_written_when_empty() {
1509 let tmpdir = std::env::temp_dir();
1510 let path = tmpdir.join("purple_test_vault_role_empty.ini");
1511 let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1512 config.path_override = Some(path.clone());
1513 config.save().unwrap();
1514 let content = std::fs::read_to_string(&path).unwrap();
1515 let _ = std::fs::remove_file(&path);
1516 assert!(!content.contains("vault_role"));
1517 }
1518
1519 #[test]
1520 fn test_vault_role_round_trip() {
1521 let tmpdir = std::env::temp_dir();
1522 let path = tmpdir.join("purple_test_vault_role_rt.ini");
1523 let mut config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_role=ssh/sign/engineer\n");
1524 config.path_override = Some(path.clone());
1525 config.save().unwrap();
1526 let content = std::fs::read_to_string(&path).unwrap();
1527 let _ = std::fs::remove_file(&path);
1528 assert!(content.contains("vault_role=ssh/sign/engineer"));
1529 }
1530
1531 #[test]
1534 fn vault_addr_default_empty() {
1535 let config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1536 assert!(config.sections[0].vault_addr.is_empty());
1537 }
1538
1539 #[test]
1540 fn vault_addr_parsed() {
1541 let config = ProviderConfig::parse("[aws]\ntoken=abc\nvault_addr=http://127.0.0.1:8200\n");
1542 assert_eq!(config.sections[0].vault_addr, "http://127.0.0.1:8200");
1543 }
1544
1545 #[test]
1546 fn vault_addr_invalid_dropped_on_parse() {
1547 for input in [
1549 "[aws]\ntoken=abc\nvault_addr=has space\n",
1550 "[aws]\ntoken=abc\nvault_addr=\n",
1551 ] {
1552 let config = ProviderConfig::parse(input);
1553 assert!(
1554 config.sections[0].vault_addr.is_empty(),
1555 "expected empty vault_addr for input: {:?}",
1556 input
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 fn vault_addr_round_trip() {
1563 let tmpdir = std::env::temp_dir();
1564 let path = tmpdir.join("purple_test_vault_addr_rt.ini");
1565 let mut config =
1566 ProviderConfig::parse("[aws]\ntoken=abc\nvault_addr=http://127.0.0.1:8200\n");
1567 config.path_override = Some(path.clone());
1568 config.save().unwrap();
1569 let content = std::fs::read_to_string(&path).unwrap();
1570 let _ = std::fs::remove_file(&path);
1571 assert!(content.contains("vault_addr=http://127.0.0.1:8200"));
1572 }
1573
1574 #[test]
1575 fn vault_addr_not_written_when_empty() {
1576 let tmpdir = std::env::temp_dir();
1577 let path = tmpdir.join("purple_test_vault_addr_empty.ini");
1578 let mut config = ProviderConfig::parse("[aws]\ntoken=abc\n");
1579 config.path_override = Some(path.clone());
1580 config.save().unwrap();
1581 let content = std::fs::read_to_string(&path).unwrap();
1582 let _ = std::fs::remove_file(&path);
1583 assert!(!content.contains("vault_addr"));
1584 }
1585}