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}
18
19fn default_auto_sync(provider: &str) -> bool {
21 provider != "proxmox"
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct ProviderConfig {
27 pub sections: Vec<ProviderSection>,
28 pub path_override: Option<PathBuf>,
31}
32
33fn config_path() -> Option<PathBuf> {
34 dirs::home_dir().map(|h| h.join(".purple/providers"))
35}
36
37impl ProviderConfig {
38 pub fn load() -> Self {
42 let path = match config_path() {
43 Some(p) => p,
44 None => return Self::default(),
45 };
46 let content = match std::fs::read_to_string(&path) {
47 Ok(c) => c,
48 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
49 Err(e) => {
50 eprintln!("! Could not read {}: {}", path.display(), e);
51 return Self::default();
52 }
53 };
54 Self::parse(&content)
55 }
56
57 fn parse(content: &str) -> Self {
59 let mut sections = Vec::new();
60 let mut current: Option<ProviderSection> = None;
61
62 for line in content.lines() {
63 let trimmed = line.trim();
64 if trimmed.is_empty() || trimmed.starts_with('#') {
65 continue;
66 }
67 if trimmed.starts_with('[') && trimmed.ends_with(']') {
68 if let Some(section) = current.take() {
69 if !sections.iter().any(|s: &ProviderSection| s.provider == section.provider) {
70 sections.push(section);
71 }
72 }
73 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
74 if sections.iter().any(|s| s.provider == name) {
75 current = None;
76 continue;
77 }
78 let short_label = super::get_provider(&name)
79 .map(|p| p.short_label().to_string())
80 .unwrap_or_else(|| name.clone());
81 let auto_sync_default = default_auto_sync(&name);
82 current = Some(ProviderSection {
83 provider: name,
84 token: String::new(),
85 alias_prefix: short_label,
86 user: "root".to_string(),
87 identity_file: String::new(),
88 url: String::new(),
89 verify_tls: true,
90 auto_sync: auto_sync_default,
91 });
92 } else if let Some(ref mut section) = current {
93 if let Some((key, value)) = trimmed.split_once('=') {
94 let key = key.trim();
95 let value = value.trim().to_string();
96 match key {
97 "token" => section.token = value,
98 "alias_prefix" => section.alias_prefix = value,
99 "user" => section.user = value,
100 "key" => section.identity_file = value,
101 "url" => section.url = value,
102 "verify_tls" => section.verify_tls = !matches!(
103 value.to_lowercase().as_str(), "false" | "0" | "no"
104 ),
105 "auto_sync" => section.auto_sync = !matches!(
106 value.to_lowercase().as_str(), "false" | "0" | "no"
107 ),
108 _ => {}
109 }
110 }
111 }
112 }
113 if let Some(section) = current {
114 if !sections.iter().any(|s| s.provider == section.provider) {
115 sections.push(section);
116 }
117 }
118 Self { sections, path_override: None }
119 }
120
121 pub fn save(&self) -> io::Result<()> {
124 let path = match &self.path_override {
125 Some(p) => p.clone(),
126 None => match config_path() {
127 Some(p) => p,
128 None => {
129 return Err(io::Error::new(
130 io::ErrorKind::NotFound,
131 "Could not determine home directory",
132 ))
133 }
134 },
135 };
136
137 let mut content = String::new();
138 for (i, section) in self.sections.iter().enumerate() {
139 if i > 0 {
140 content.push('\n');
141 }
142 content.push_str(&format!("[{}]\n", section.provider));
143 content.push_str(&format!("token={}\n", section.token));
144 content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
145 content.push_str(&format!("user={}\n", section.user));
146 if !section.identity_file.is_empty() {
147 content.push_str(&format!("key={}\n", section.identity_file));
148 }
149 if !section.url.is_empty() {
150 content.push_str(&format!("url={}\n", section.url));
151 }
152 if !section.verify_tls {
153 content.push_str("verify_tls=false\n");
154 }
155 if section.auto_sync != default_auto_sync(§ion.provider) {
156 content.push_str(if section.auto_sync { "auto_sync=true\n" } else { "auto_sync=false\n" });
157 }
158 }
159
160 fs_util::atomic_write(&path, content.as_bytes())
161 }
162
163 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
165 self.sections.iter().find(|s| s.provider == provider)
166 }
167
168 pub fn set_section(&mut self, section: ProviderSection) {
170 if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
171 *existing = section;
172 } else {
173 self.sections.push(section);
174 }
175 }
176
177 pub fn remove_section(&mut self, provider: &str) {
179 self.sections.retain(|s| s.provider != provider);
180 }
181
182 pub fn configured_providers(&self) -> &[ProviderSection] {
184 &self.sections
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_parse_empty() {
194 let config = ProviderConfig::parse("");
195 assert!(config.sections.is_empty());
196 }
197
198 #[test]
199 fn test_parse_single_section() {
200 let content = "\
201[digitalocean]
202token=dop_v1_abc123
203alias_prefix=do
204user=root
205key=~/.ssh/id_ed25519
206";
207 let config = ProviderConfig::parse(content);
208 assert_eq!(config.sections.len(), 1);
209 let s = &config.sections[0];
210 assert_eq!(s.provider, "digitalocean");
211 assert_eq!(s.token, "dop_v1_abc123");
212 assert_eq!(s.alias_prefix, "do");
213 assert_eq!(s.user, "root");
214 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
215 }
216
217 #[test]
218 fn test_parse_multiple_sections() {
219 let content = "\
220[digitalocean]
221token=abc
222
223[vultr]
224token=xyz
225user=deploy
226";
227 let config = ProviderConfig::parse(content);
228 assert_eq!(config.sections.len(), 2);
229 assert_eq!(config.sections[0].provider, "digitalocean");
230 assert_eq!(config.sections[1].provider, "vultr");
231 assert_eq!(config.sections[1].user, "deploy");
232 }
233
234 #[test]
235 fn test_parse_comments_and_blanks() {
236 let content = "\
237# Provider config
238
239[linode]
240# API token
241token=mytoken
242";
243 let config = ProviderConfig::parse(content);
244 assert_eq!(config.sections.len(), 1);
245 assert_eq!(config.sections[0].token, "mytoken");
246 }
247
248 #[test]
249 fn test_set_section_add() {
250 let mut config = ProviderConfig::default();
251 config.set_section(ProviderSection {
252 provider: "vultr".to_string(),
253 token: "abc".to_string(),
254 alias_prefix: "vultr".to_string(),
255 user: "root".to_string(),
256 identity_file: String::new(),
257 url: String::new(),
258 verify_tls: true,
259 auto_sync: true,
260 });
261 assert_eq!(config.sections.len(), 1);
262 }
263
264 #[test]
265 fn test_set_section_replace() {
266 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
267 config.set_section(ProviderSection {
268 provider: "vultr".to_string(),
269 token: "new".to_string(),
270 alias_prefix: "vultr".to_string(),
271 user: "root".to_string(),
272 identity_file: String::new(),
273 url: String::new(),
274 verify_tls: true,
275 auto_sync: true,
276 });
277 assert_eq!(config.sections.len(), 1);
278 assert_eq!(config.sections[0].token, "new");
279 }
280
281 #[test]
282 fn test_remove_section() {
283 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
284 config.remove_section("vultr");
285 assert_eq!(config.sections.len(), 1);
286 assert_eq!(config.sections[0].provider, "linode");
287 }
288
289 #[test]
290 fn test_section_lookup() {
291 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
292 assert!(config.section("digitalocean").is_some());
293 assert!(config.section("vultr").is_none());
294 }
295
296 #[test]
297 fn test_parse_duplicate_sections_first_wins() {
298 let content = "\
299[digitalocean]
300token=first
301
302[digitalocean]
303token=second
304";
305 let config = ProviderConfig::parse(content);
306 assert_eq!(config.sections.len(), 1);
307 assert_eq!(config.sections[0].token, "first");
308 }
309
310 #[test]
311 fn test_parse_duplicate_sections_trailing() {
312 let content = "\
313[vultr]
314token=abc
315
316[linode]
317token=xyz
318
319[vultr]
320token=dup
321";
322 let config = ProviderConfig::parse(content);
323 assert_eq!(config.sections.len(), 2);
324 assert_eq!(config.sections[0].provider, "vultr");
325 assert_eq!(config.sections[0].token, "abc");
326 assert_eq!(config.sections[1].provider, "linode");
327 }
328
329 #[test]
330 fn test_defaults_applied() {
331 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
332 let s = &config.sections[0];
333 assert_eq!(s.user, "root");
334 assert_eq!(s.alias_prefix, "hetzner");
335 assert!(s.identity_file.is_empty());
336 assert!(s.url.is_empty());
337 assert!(s.verify_tls);
338 assert!(s.auto_sync);
339 }
340
341 #[test]
342 fn test_parse_url_and_verify_tls() {
343 let content = "\
344[proxmox]
345token=user@pam!purple=secret
346url=https://pve.example.com:8006
347verify_tls=false
348";
349 let config = ProviderConfig::parse(content);
350 assert_eq!(config.sections.len(), 1);
351 let s = &config.sections[0];
352 assert_eq!(s.url, "https://pve.example.com:8006");
353 assert!(!s.verify_tls);
354 }
355
356 #[test]
357 fn test_url_and_verify_tls_round_trip() {
358 let content = "\
359[proxmox]
360token=tok
361alias_prefix=pve
362user=root
363url=https://pve.local:8006
364verify_tls=false
365";
366 let config = ProviderConfig::parse(content);
367 let s = &config.sections[0];
368 assert_eq!(s.url, "https://pve.local:8006");
369 assert!(!s.verify_tls);
370 }
371
372 #[test]
373 fn test_verify_tls_default_true() {
374 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
376 assert!(config.sections[0].verify_tls);
377 }
378
379 #[test]
380 fn test_verify_tls_false_variants() {
381 for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
382 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
383 let config = ProviderConfig::parse(&content);
384 assert!(!config.sections[0].verify_tls, "verify_tls={} should be false", value);
385 }
386 }
387
388 #[test]
389 fn test_verify_tls_true_variants() {
390 for value in &["true", "True", "1", "yes"] {
391 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
392 let config = ProviderConfig::parse(&content);
393 assert!(config.sections[0].verify_tls, "verify_tls={} should be true", value);
394 }
395 }
396
397 #[test]
398 fn test_non_proxmox_url_not_written() {
399 let section = ProviderSection {
401 provider: "digitalocean".to_string(),
402 token: "tok".to_string(),
403 alias_prefix: "do".to_string(),
404 user: "root".to_string(),
405 identity_file: String::new(),
406 url: String::new(), verify_tls: true, auto_sync: true, };
410 let mut config = ProviderConfig::default();
411 config.set_section(section);
412 let s = &config.sections[0];
414 assert!(s.url.is_empty());
415 assert!(s.verify_tls);
416 }
417
418 #[test]
419 fn test_proxmox_url_fallback_in_section() {
420 let existing = ProviderConfig::parse(
422 "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
423 );
424 let existing_url = existing.section("proxmox").map(|s| s.url.clone()).unwrap_or_default();
425 assert_eq!(existing_url, "https://pve.local:8006");
426
427 let mut config = existing;
428 config.set_section(ProviderSection {
429 provider: "proxmox".to_string(),
430 token: "new".to_string(),
431 alias_prefix: "pve".to_string(),
432 user: "root".to_string(),
433 identity_file: String::new(),
434 url: existing_url,
435 verify_tls: true,
436 auto_sync: false,
437 });
438 assert_eq!(config.sections[0].token, "new");
439 assert_eq!(config.sections[0].url, "https://pve.local:8006");
440 }
441
442 #[test]
443 fn test_auto_sync_default_true_for_non_proxmox() {
444 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
445 assert!(config.sections[0].auto_sync);
446 }
447
448 #[test]
449 fn test_auto_sync_default_false_for_proxmox() {
450 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
451 assert!(!config.sections[0].auto_sync);
452 }
453
454 #[test]
455 fn test_auto_sync_explicit_true() {
456 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
457 assert!(config.sections[0].auto_sync);
458 }
459
460 #[test]
461 fn test_auto_sync_explicit_false_non_proxmox() {
462 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
463 assert!(!config.sections[0].auto_sync);
464 }
465
466 #[test]
467 fn test_auto_sync_not_written_when_default() {
468 let mut config = ProviderConfig::default();
470 config.set_section(ProviderSection {
471 provider: "digitalocean".to_string(),
472 token: "tok".to_string(),
473 alias_prefix: "do".to_string(),
474 user: "root".to_string(),
475 identity_file: String::new(),
476 url: String::new(),
477 verify_tls: true,
478 auto_sync: true,
479 });
480 assert!(config.sections[0].auto_sync);
482
483 let mut config2 = ProviderConfig::default();
485 config2.set_section(ProviderSection {
486 provider: "proxmox".to_string(),
487 token: "tok".to_string(),
488 alias_prefix: "pve".to_string(),
489 user: "root".to_string(),
490 identity_file: String::new(),
491 url: "https://pve:8006".to_string(),
492 verify_tls: true,
493 auto_sync: false,
494 });
495 assert!(!config2.sections[0].auto_sync);
496 }
497
498 #[test]
499 fn test_auto_sync_false_variants() {
500 for value in &["false", "False", "FALSE", "0", "no"] {
501 let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
502 let config = ProviderConfig::parse(&content);
503 assert!(!config.sections[0].auto_sync, "auto_sync={} should be false", value);
504 }
505 }
506
507 #[test]
508 fn test_auto_sync_true_variants() {
509 for value in &["true", "True", "TRUE", "1", "yes"] {
510 let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n", value);
512 let config = ProviderConfig::parse(&content);
513 assert!(config.sections[0].auto_sync, "auto_sync={} should be true", value);
514 }
515 }
516
517 #[test]
518 fn test_auto_sync_malformed_value_treated_as_true() {
519 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
521 assert!(config.sections[0].auto_sync);
522 }
523
524 #[test]
525 fn test_auto_sync_written_only_when_non_default() {
526 let mut config = ProviderConfig::default();
528 config.set_section(ProviderSection {
529 provider: "proxmox".to_string(),
530 token: "tok".to_string(),
531 alias_prefix: "pve".to_string(),
532 user: "root".to_string(),
533 identity_file: String::new(),
534 url: "https://pve:8006".to_string(),
535 verify_tls: true,
536 auto_sync: true, });
538 let content =
540 "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
541 .to_string();
542 let reparsed = ProviderConfig::parse(&content);
543 assert!(reparsed.sections[0].auto_sync);
544
545 let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
547 let reparsed2 = ProviderConfig::parse(content2);
548 assert!(!reparsed2.sections[0].auto_sync);
549 }
550
551 #[test]
556 fn test_configured_providers_empty() {
557 let config = ProviderConfig::default();
558 assert!(config.configured_providers().is_empty());
559 }
560
561 #[test]
562 fn test_configured_providers_returns_all() {
563 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
564 let config = ProviderConfig::parse(content);
565 assert_eq!(config.configured_providers().len(), 2);
566 }
567
568 #[test]
573 fn test_parse_unknown_keys_ignored() {
574 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nunknown_key=value\n";
575 let config = ProviderConfig::parse(content);
576 assert_eq!(config.sections.len(), 1);
577 assert_eq!(config.sections[0].token, "abc");
578 }
579
580 #[test]
581 fn test_parse_unknown_provider_still_parsed() {
582 let content = "[aws]\ntoken=secret\n";
583 let config = ProviderConfig::parse(content);
584 assert_eq!(config.sections.len(), 1);
585 assert_eq!(config.sections[0].provider, "aws");
586 }
587
588 #[test]
589 fn test_parse_whitespace_in_section_name() {
590 let content = "[ digitalocean ]\ntoken=abc\n";
591 let config = ProviderConfig::parse(content);
592 assert_eq!(config.sections.len(), 1);
593 assert_eq!(config.sections[0].provider, "digitalocean");
594 }
595
596 #[test]
597 fn test_parse_value_with_equals() {
598 let content = "[digitalocean]\ntoken=abc=def==\n";
600 let config = ProviderConfig::parse(content);
601 assert_eq!(config.sections[0].token, "abc=def==");
602 }
603
604 #[test]
605 fn test_parse_whitespace_around_key_value() {
606 let content = "[digitalocean]\n token = my-token \n";
607 let config = ProviderConfig::parse(content);
608 assert_eq!(config.sections[0].token, "my-token");
609 }
610
611 #[test]
612 fn test_parse_key_field_sets_identity_file() {
613 let content = "[digitalocean]\ntoken=abc\nkey=~/.ssh/id_rsa\n";
614 let config = ProviderConfig::parse(content);
615 assert_eq!(config.sections[0].identity_file, "~/.ssh/id_rsa");
616 }
617
618 #[test]
619 fn test_section_lookup_missing() {
620 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
621 assert!(config.section("vultr").is_none());
622 }
623
624 #[test]
625 fn test_section_lookup_found() {
626 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
627 let section = config.section("digitalocean").unwrap();
628 assert_eq!(section.token, "abc");
629 }
630
631 #[test]
632 fn test_remove_nonexistent_section_noop() {
633 let mut config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
634 config.remove_section("vultr");
635 assert_eq!(config.sections.len(), 1);
636 }
637
638 #[test]
643 fn test_default_alias_prefix_digitalocean() {
644 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
645 assert_eq!(config.sections[0].alias_prefix, "do");
646 }
647
648 #[test]
649 fn test_default_alias_prefix_upcloud() {
650 let config = ProviderConfig::parse("[upcloud]\ntoken=abc\n");
651 assert_eq!(config.sections[0].alias_prefix, "uc");
652 }
653
654 #[test]
655 fn test_default_alias_prefix_proxmox() {
656 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
657 assert_eq!(config.sections[0].alias_prefix, "pve");
658 }
659
660 #[test]
661 fn test_alias_prefix_override() {
662 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nalias_prefix=ocean\n");
663 assert_eq!(config.sections[0].alias_prefix, "ocean");
664 }
665
666 #[test]
671 fn test_default_user_is_root() {
672 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
673 assert_eq!(config.sections[0].user, "root");
674 }
675
676 #[test]
677 fn test_user_override() {
678 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nuser=admin\n");
679 assert_eq!(config.sections[0].user, "admin");
680 }
681
682 #[test]
687 fn test_proxmox_url_parsed() {
688 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve.local:8006\n");
689 assert_eq!(config.sections[0].url, "https://pve.local:8006");
690 }
691
692 #[test]
693 fn test_non_proxmox_url_parsed_but_ignored() {
694 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nurl=https://api.do.com\n");
696 assert_eq!(config.sections[0].url, "https://api.do.com");
697 }
698
699 #[test]
704 fn test_duplicate_section_first_wins() {
705 let content = "[digitalocean]\ntoken=first\n\n[digitalocean]\ntoken=second\n";
706 let config = ProviderConfig::parse(content);
707 assert_eq!(config.sections.len(), 1);
708 assert_eq!(config.sections[0].token, "first");
709 }
710
711 #[test]
720 fn test_auto_sync_default_proxmox_false() {
721 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\n");
722 assert!(!config.sections[0].auto_sync);
723 }
724
725 #[test]
726 fn test_auto_sync_default_all_others_true() {
727 for provider in &["digitalocean", "vultr", "linode", "hetzner", "upcloud"] {
728 let content = format!("[{}]\ntoken=abc\n", provider);
729 let config = ProviderConfig::parse(&content);
730 assert!(config.sections[0].auto_sync, "auto_sync should default to true for {}", provider);
731 }
732 }
733
734 #[test]
735 fn test_auto_sync_override_proxmox_to_true() {
736 let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
737 assert!(config.sections[0].auto_sync);
738 }
739
740 #[test]
741 fn test_auto_sync_override_do_to_false() {
742 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
743 assert!(!config.sections[0].auto_sync);
744 }
745
746 #[test]
751 fn test_set_section_adds_new() {
752 let mut config = ProviderConfig::default();
753 let section = ProviderSection {
754 provider: "vultr".to_string(),
755 token: "tok".to_string(),
756 alias_prefix: "vultr".to_string(),
757 user: "root".to_string(),
758 identity_file: String::new(),
759 url: String::new(),
760 verify_tls: true,
761 auto_sync: true,
762 };
763 config.set_section(section);
764 assert_eq!(config.sections.len(), 1);
765 assert_eq!(config.sections[0].provider, "vultr");
766 }
767
768 #[test]
769 fn test_set_section_replaces_existing() {
770 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
771 assert_eq!(config.sections[0].token, "old");
772 let section = ProviderSection {
773 provider: "vultr".to_string(),
774 token: "new".to_string(),
775 alias_prefix: "vultr".to_string(),
776 user: "root".to_string(),
777 identity_file: String::new(),
778 url: String::new(),
779 verify_tls: true,
780 auto_sync: true,
781 };
782 config.set_section(section);
783 assert_eq!(config.sections.len(), 1);
784 assert_eq!(config.sections[0].token, "new");
785 }
786
787 #[test]
788 fn test_remove_section_keeps_others() {
789 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
790 assert_eq!(config.sections.len(), 2);
791 config.remove_section("vultr");
792 assert_eq!(config.sections.len(), 1);
793 assert_eq!(config.sections[0].provider, "linode");
794 }
795
796 #[test]
801 fn test_comments_ignored() {
802 let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
803 let config = ProviderConfig::parse(content);
804 assert_eq!(config.sections.len(), 1);
805 assert_eq!(config.sections[0].token, "abc");
806 }
807
808 #[test]
809 fn test_blank_lines_ignored() {
810 let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
811 let config = ProviderConfig::parse(content);
812 assert_eq!(config.sections.len(), 1);
813 assert_eq!(config.sections[0].token, "abc");
814 }
815
816 #[test]
821 fn test_multiple_providers() {
822 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
823 let config = ProviderConfig::parse(content);
824 assert_eq!(config.sections.len(), 3);
825 assert_eq!(config.sections[0].provider, "digitalocean");
826 assert_eq!(config.sections[1].provider, "vultr");
827 assert_eq!(config.sections[2].provider, "proxmox");
828 assert_eq!(config.sections[2].url, "https://pve:8006");
829 }
830
831 #[test]
836 fn test_token_with_equals_sign() {
837 let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
839 let config = ProviderConfig::parse(content);
840 assert_eq!(config.sections[0].token, "dop_v1_abc123==");
842 }
843
844 #[test]
845 fn test_proxmox_token_with_exclamation() {
846 let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
847 let config = ProviderConfig::parse(content);
848 assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
849 }
850
851 #[test]
856 fn test_serialize_roundtrip_single_provider() {
857 let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
858 let config = ProviderConfig::parse(content);
859 let mut serialized = String::new();
860 for section in &config.sections {
861 serialized.push_str(&format!("[{}]\n", section.provider));
862 serialized.push_str(&format!("token={}\n", section.token));
863 serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
864 serialized.push_str(&format!("user={}\n", section.user));
865 }
866 let reparsed = ProviderConfig::parse(&serialized);
867 assert_eq!(reparsed.sections.len(), 1);
868 assert_eq!(reparsed.sections[0].token, "abc");
869 assert_eq!(reparsed.sections[0].alias_prefix, "do");
870 assert_eq!(reparsed.sections[0].user, "root");
871 }
872
873 #[test]
878 fn test_verify_tls_values() {
879 for (val, expected) in [
880 ("false", false), ("False", false), ("FALSE", false),
881 ("0", false), ("no", false), ("No", false), ("NO", false),
882 ("true", true), ("True", true), ("1", true), ("yes", true),
883 ("anything", true), ] {
885 let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
886 let config = ProviderConfig::parse(&content);
887 assert_eq!(
888 config.sections[0].verify_tls, expected,
889 "verify_tls={} should be {}",
890 val, expected
891 );
892 }
893 }
894
895 #[test]
900 fn test_auto_sync_values() {
901 for (val, expected) in [
902 ("false", false), ("False", false), ("FALSE", false),
903 ("0", false), ("no", false), ("No", false),
904 ("true", true), ("1", true), ("yes", true),
905 ] {
906 let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
907 let config = ProviderConfig::parse(&content);
908 assert_eq!(
909 config.sections[0].auto_sync, expected,
910 "auto_sync={} should be {}",
911 val, expected
912 );
913 }
914 }
915
916 #[test]
921 fn test_default_user_root_when_not_specified() {
922 let content = "[digitalocean]\ntoken=abc\n";
923 let config = ProviderConfig::parse(content);
924 assert_eq!(config.sections[0].user, "root");
925 }
926
927 #[test]
928 fn test_default_alias_prefix_from_short_label() {
929 let content = "[digitalocean]\ntoken=abc\n";
931 let config = ProviderConfig::parse(content);
932 assert_eq!(config.sections[0].alias_prefix, "do");
933 }
934
935 #[test]
936 fn test_default_alias_prefix_unknown_provider() {
937 let content = "[unknown_cloud]\ntoken=abc\n";
939 let config = ProviderConfig::parse(content);
940 assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
941 }
942
943 #[test]
944 fn test_default_identity_file_empty() {
945 let content = "[digitalocean]\ntoken=abc\n";
946 let config = ProviderConfig::parse(content);
947 assert!(config.sections[0].identity_file.is_empty());
948 }
949
950 #[test]
951 fn test_default_url_empty() {
952 let content = "[digitalocean]\ntoken=abc\n";
953 let config = ProviderConfig::parse(content);
954 assert!(config.sections[0].url.is_empty());
955 }
956
957 #[test]
962 fn test_configured_providers_returns_all_sections() {
963 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
964 let config = ProviderConfig::parse(content);
965 assert_eq!(config.configured_providers().len(), 2);
966 }
967
968 #[test]
969 fn test_section_by_name() {
970 let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
971 let config = ProviderConfig::parse(content);
972 let do_section = config.section("digitalocean").unwrap();
973 assert_eq!(do_section.token, "do-tok");
974 let vultr_section = config.section("vultr").unwrap();
975 assert_eq!(vultr_section.token, "vultr-tok");
976 }
977
978 #[test]
979 fn test_section_not_found() {
980 let config = ProviderConfig::parse("");
981 assert!(config.section("nonexistent").is_none());
982 }
983
984 #[test]
989 fn test_line_without_equals_ignored() {
990 let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
991 let config = ProviderConfig::parse(content);
992 assert_eq!(config.sections[0].token, "abc");
993 assert_eq!(config.sections[0].user, "admin");
994 }
995
996 #[test]
997 fn test_unknown_key_ignored() {
998 let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
999 let config = ProviderConfig::parse(content);
1000 assert_eq!(config.sections[0].token, "abc");
1001 assert_eq!(config.sections[0].user, "admin");
1002 }
1003
1004 #[test]
1009 fn test_whitespace_around_section_name() {
1010 let content = "[ digitalocean ]\ntoken=abc\n";
1011 let config = ProviderConfig::parse(content);
1012 assert_eq!(config.sections[0].provider, "digitalocean");
1013 }
1014
1015 #[test]
1016 fn test_whitespace_around_key_value() {
1017 let content = "[digitalocean]\n token = abc \n user = admin \n";
1018 let config = ProviderConfig::parse(content);
1019 assert_eq!(config.sections[0].token, "abc");
1020 assert_eq!(config.sections[0].user, "admin");
1021 }
1022
1023 #[test]
1028 fn test_set_section_multiple_adds() {
1029 let mut config = ProviderConfig::default();
1030 for name in ["digitalocean", "vultr", "hetzner"] {
1031 config.set_section(ProviderSection {
1032 provider: name.to_string(),
1033 token: format!("{}-tok", name),
1034 alias_prefix: name.to_string(),
1035 user: "root".to_string(),
1036 identity_file: String::new(),
1037 url: String::new(),
1038 verify_tls: true,
1039 auto_sync: true,
1040 });
1041 }
1042 assert_eq!(config.sections.len(), 3);
1043 }
1044
1045 #[test]
1046 fn test_remove_section_all() {
1047 let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1048 let mut config = ProviderConfig::parse(content);
1049 config.remove_section("digitalocean");
1050 config.remove_section("vultr");
1051 assert!(config.sections.is_empty());
1052 }
1053}