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