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