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