Skip to main content

purple_ssh/providers/
config.rs

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