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