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