Skip to main content

purple_ssh/providers/
config.rs

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