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