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        ] {
861            let content = format!("[{}]\ntoken=abc\n", provider);
862            let config = ProviderConfig::parse(&content);
863            assert!(
864                config.sections[0].auto_sync,
865                "auto_sync should default to true for {}",
866                provider
867            );
868        }
869    }
870
871    #[test]
872    fn test_auto_sync_override_proxmox_to_true() {
873        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nauto_sync=true\n");
874        assert!(config.sections[0].auto_sync);
875    }
876
877    #[test]
878    fn test_auto_sync_override_do_to_false() {
879        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
880        assert!(!config.sections[0].auto_sync);
881    }
882
883    // =========================================================================
884    // set_section and remove_section
885    // =========================================================================
886
887    #[test]
888    fn test_set_section_adds_new() {
889        let mut config = ProviderConfig::default();
890        let section = ProviderSection {
891            provider: "vultr".to_string(),
892            token: "tok".to_string(),
893            alias_prefix: "vultr".to_string(),
894            user: "root".to_string(),
895            identity_file: String::new(),
896            url: String::new(),
897            verify_tls: true,
898            auto_sync: true,
899            profile: String::new(),
900            regions: String::new(),
901            project: String::new(),
902            compartment: String::new(),
903        };
904        config.set_section(section);
905        assert_eq!(config.sections.len(), 1);
906        assert_eq!(config.sections[0].provider, "vultr");
907    }
908
909    #[test]
910    fn test_set_section_replaces_existing() {
911        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
912        assert_eq!(config.sections[0].token, "old");
913        let section = ProviderSection {
914            provider: "vultr".to_string(),
915            token: "new".to_string(),
916            alias_prefix: "vultr".to_string(),
917            user: "root".to_string(),
918            identity_file: String::new(),
919            url: String::new(),
920            verify_tls: true,
921            auto_sync: true,
922            profile: String::new(),
923            regions: String::new(),
924            project: String::new(),
925            compartment: String::new(),
926        };
927        config.set_section(section);
928        assert_eq!(config.sections.len(), 1);
929        assert_eq!(config.sections[0].token, "new");
930    }
931
932    #[test]
933    fn test_remove_section_keeps_others() {
934        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n\n[linode]\ntoken=def\n");
935        assert_eq!(config.sections.len(), 2);
936        config.remove_section("vultr");
937        assert_eq!(config.sections.len(), 1);
938        assert_eq!(config.sections[0].provider, "linode");
939    }
940
941    // =========================================================================
942    // Comments and blank lines
943    // =========================================================================
944
945    #[test]
946    fn test_comments_ignored() {
947        let content = "# This is a comment\n[digitalocean]\n# Another comment\ntoken=abc\n";
948        let config = ProviderConfig::parse(content);
949        assert_eq!(config.sections.len(), 1);
950        assert_eq!(config.sections[0].token, "abc");
951    }
952
953    #[test]
954    fn test_blank_lines_ignored() {
955        let content = "\n\n[digitalocean]\n\ntoken=abc\n\n";
956        let config = ProviderConfig::parse(content);
957        assert_eq!(config.sections.len(), 1);
958        assert_eq!(config.sections[0].token, "abc");
959    }
960
961    // =========================================================================
962    // Multiple providers
963    // =========================================================================
964
965    #[test]
966    fn test_multiple_providers() {
967        let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n\n[proxmox]\ntoken=pve-tok\nurl=https://pve:8006\n";
968        let config = ProviderConfig::parse(content);
969        assert_eq!(config.sections.len(), 3);
970        assert_eq!(config.sections[0].provider, "digitalocean");
971        assert_eq!(config.sections[1].provider, "vultr");
972        assert_eq!(config.sections[2].provider, "proxmox");
973        assert_eq!(config.sections[2].url, "https://pve:8006");
974    }
975
976    // =========================================================================
977    // Token with special characters
978    // =========================================================================
979
980    #[test]
981    fn test_token_with_equals_sign() {
982        // API tokens can contain = signs (e.g., base64)
983        let content = "[digitalocean]\ntoken=dop_v1_abc123==\n";
984        let config = ProviderConfig::parse(content);
985        // split_once('=') splits at first =, so "dop_v1_abc123==" is preserved
986        assert_eq!(config.sections[0].token, "dop_v1_abc123==");
987    }
988
989    #[test]
990    fn test_proxmox_token_with_exclamation() {
991        let content = "[proxmox]\ntoken=user@pam!api-token=12345678-abcd\nurl=https://pve:8006\n";
992        let config = ProviderConfig::parse(content);
993        assert_eq!(config.sections[0].token, "user@pam!api-token=12345678-abcd");
994    }
995
996    // =========================================================================
997    // Parse serialization roundtrip
998    // =========================================================================
999
1000    #[test]
1001    fn test_serialize_roundtrip_single_provider() {
1002        let content = "[digitalocean]\ntoken=abc\nalias_prefix=do\nuser=root\n";
1003        let config = ProviderConfig::parse(content);
1004        let mut serialized = String::new();
1005        for section in &config.sections {
1006            serialized.push_str(&format!("[{}]\n", section.provider));
1007            serialized.push_str(&format!("token={}\n", section.token));
1008            serialized.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
1009            serialized.push_str(&format!("user={}\n", section.user));
1010        }
1011        let reparsed = ProviderConfig::parse(&serialized);
1012        assert_eq!(reparsed.sections.len(), 1);
1013        assert_eq!(reparsed.sections[0].token, "abc");
1014        assert_eq!(reparsed.sections[0].alias_prefix, "do");
1015        assert_eq!(reparsed.sections[0].user, "root");
1016    }
1017
1018    // =========================================================================
1019    // verify_tls parsing variants
1020    // =========================================================================
1021
1022    #[test]
1023    fn test_verify_tls_values() {
1024        for (val, expected) in [
1025            ("false", false),
1026            ("False", false),
1027            ("FALSE", false),
1028            ("0", false),
1029            ("no", false),
1030            ("No", false),
1031            ("NO", false),
1032            ("true", true),
1033            ("True", true),
1034            ("1", true),
1035            ("yes", true),
1036            ("anything", true), // any unrecognized value defaults to true
1037        ] {
1038            let content = format!("[digitalocean]\ntoken=t\nverify_tls={}\n", val);
1039            let config = ProviderConfig::parse(&content);
1040            assert_eq!(
1041                config.sections[0].verify_tls, expected,
1042                "verify_tls={} should be {}",
1043                val, expected
1044            );
1045        }
1046    }
1047
1048    // =========================================================================
1049    // auto_sync parsing variants
1050    // =========================================================================
1051
1052    #[test]
1053    fn test_auto_sync_values() {
1054        for (val, expected) in [
1055            ("false", false),
1056            ("False", false),
1057            ("FALSE", false),
1058            ("0", false),
1059            ("no", false),
1060            ("No", false),
1061            ("true", true),
1062            ("1", true),
1063            ("yes", true),
1064        ] {
1065            let content = format!("[digitalocean]\ntoken=t\nauto_sync={}\n", val);
1066            let config = ProviderConfig::parse(&content);
1067            assert_eq!(
1068                config.sections[0].auto_sync, expected,
1069                "auto_sync={} should be {}",
1070                val, expected
1071            );
1072        }
1073    }
1074
1075    // =========================================================================
1076    // Default values
1077    // =========================================================================
1078
1079    #[test]
1080    fn test_default_user_root_when_not_specified() {
1081        let content = "[digitalocean]\ntoken=abc\n";
1082        let config = ProviderConfig::parse(content);
1083        assert_eq!(config.sections[0].user, "root");
1084    }
1085
1086    #[test]
1087    fn test_default_alias_prefix_from_short_label() {
1088        // DigitalOcean short_label is "do"
1089        let content = "[digitalocean]\ntoken=abc\n";
1090        let config = ProviderConfig::parse(content);
1091        assert_eq!(config.sections[0].alias_prefix, "do");
1092    }
1093
1094    #[test]
1095    fn test_default_alias_prefix_unknown_provider() {
1096        // Unknown provider uses the section name as default prefix
1097        let content = "[unknown_cloud]\ntoken=abc\n";
1098        let config = ProviderConfig::parse(content);
1099        assert_eq!(config.sections[0].alias_prefix, "unknown_cloud");
1100    }
1101
1102    #[test]
1103    fn test_default_identity_file_empty() {
1104        let content = "[digitalocean]\ntoken=abc\n";
1105        let config = ProviderConfig::parse(content);
1106        assert!(config.sections[0].identity_file.is_empty());
1107    }
1108
1109    #[test]
1110    fn test_default_url_empty() {
1111        let content = "[digitalocean]\ntoken=abc\n";
1112        let config = ProviderConfig::parse(content);
1113        assert!(config.sections[0].url.is_empty());
1114    }
1115
1116    // =========================================================================
1117    // GCP project field
1118    // =========================================================================
1119
1120    #[test]
1121    fn test_gcp_project_parsed() {
1122        let config = ProviderConfig::parse("[gcp]\ntoken=abc\nproject=my-gcp-project\n");
1123        assert_eq!(config.sections[0].project, "my-gcp-project");
1124    }
1125
1126    #[test]
1127    fn test_gcp_project_default_empty() {
1128        let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1129        assert!(config.sections[0].project.is_empty());
1130    }
1131
1132    #[test]
1133    fn test_gcp_project_roundtrip() {
1134        let content = "[gcp]\ntoken=sa.json\nproject=my-project\nregions=us-central1-a\n";
1135        let config = ProviderConfig::parse(content);
1136        assert_eq!(config.sections[0].project, "my-project");
1137        assert_eq!(config.sections[0].regions, "us-central1-a");
1138        // Re-serialize and parse
1139        let serialized = format!(
1140            "[gcp]\ntoken={}\nproject={}\nregions={}\n",
1141            config.sections[0].token, config.sections[0].project, config.sections[0].regions,
1142        );
1143        let reparsed = ProviderConfig::parse(&serialized);
1144        assert_eq!(reparsed.sections[0].project, "my-project");
1145        assert_eq!(reparsed.sections[0].regions, "us-central1-a");
1146    }
1147
1148    #[test]
1149    fn test_default_alias_prefix_gcp() {
1150        let config = ProviderConfig::parse("[gcp]\ntoken=abc\n");
1151        assert_eq!(config.sections[0].alias_prefix, "gcp");
1152    }
1153
1154    // =========================================================================
1155    // configured_providers and section methods
1156    // =========================================================================
1157
1158    #[test]
1159    fn test_configured_providers_returns_all_sections() {
1160        let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1161        let config = ProviderConfig::parse(content);
1162        assert_eq!(config.configured_providers().len(), 2);
1163    }
1164
1165    #[test]
1166    fn test_section_by_name() {
1167        let content = "[digitalocean]\ntoken=do-tok\n\n[vultr]\ntoken=vultr-tok\n";
1168        let config = ProviderConfig::parse(content);
1169        let do_section = config.section("digitalocean").unwrap();
1170        assert_eq!(do_section.token, "do-tok");
1171        let vultr_section = config.section("vultr").unwrap();
1172        assert_eq!(vultr_section.token, "vultr-tok");
1173    }
1174
1175    #[test]
1176    fn test_section_not_found() {
1177        let config = ProviderConfig::parse("");
1178        assert!(config.section("nonexistent").is_none());
1179    }
1180
1181    // =========================================================================
1182    // Key without value
1183    // =========================================================================
1184
1185    #[test]
1186    fn test_line_without_equals_ignored() {
1187        let content = "[digitalocean]\ntoken=abc\ngarbage_line\nuser=admin\n";
1188        let config = ProviderConfig::parse(content);
1189        assert_eq!(config.sections[0].token, "abc");
1190        assert_eq!(config.sections[0].user, "admin");
1191    }
1192
1193    #[test]
1194    fn test_unknown_key_ignored() {
1195        let content = "[digitalocean]\ntoken=abc\nfoo=bar\nbaz=qux\nuser=admin\n";
1196        let config = ProviderConfig::parse(content);
1197        assert_eq!(config.sections[0].token, "abc");
1198        assert_eq!(config.sections[0].user, "admin");
1199    }
1200
1201    // =========================================================================
1202    // Whitespace handling
1203    // =========================================================================
1204
1205    #[test]
1206    fn test_whitespace_around_section_name() {
1207        let content = "[  digitalocean  ]\ntoken=abc\n";
1208        let config = ProviderConfig::parse(content);
1209        assert_eq!(config.sections[0].provider, "digitalocean");
1210    }
1211
1212    #[test]
1213    fn test_whitespace_around_key_value() {
1214        let content = "[digitalocean]\n  token  =  abc  \n  user  =  admin  \n";
1215        let config = ProviderConfig::parse(content);
1216        assert_eq!(config.sections[0].token, "abc");
1217        assert_eq!(config.sections[0].user, "admin");
1218    }
1219
1220    // =========================================================================
1221    // set_section edge cases
1222    // =========================================================================
1223
1224    #[test]
1225    fn test_set_section_multiple_adds() {
1226        let mut config = ProviderConfig::default();
1227        for name in ["digitalocean", "vultr", "hetzner"] {
1228            config.set_section(ProviderSection {
1229                provider: name.to_string(),
1230                token: format!("{}-tok", name),
1231                alias_prefix: name.to_string(),
1232                user: "root".to_string(),
1233                identity_file: String::new(),
1234                url: String::new(),
1235                verify_tls: true,
1236                auto_sync: true,
1237                profile: String::new(),
1238                regions: String::new(),
1239                project: String::new(),
1240                compartment: String::new(),
1241            });
1242        }
1243        assert_eq!(config.sections.len(), 3);
1244    }
1245
1246    #[test]
1247    fn test_remove_section_all() {
1248        let content = "[digitalocean]\ntoken=a\n\n[vultr]\ntoken=b\n";
1249        let mut config = ProviderConfig::parse(content);
1250        config.remove_section("digitalocean");
1251        config.remove_section("vultr");
1252        assert!(config.sections.is_empty());
1253    }
1254
1255    // =========================================================================
1256    // Oracle / compartment field
1257    // =========================================================================
1258
1259    #[test]
1260    fn test_compartment_field_round_trip() {
1261        use std::path::PathBuf;
1262        let content = "[oracle]\ntoken=~/.oci/config\ncompartment=ocid1.compartment.oc1..example\n";
1263        let config = ProviderConfig::parse(content);
1264        assert_eq!(
1265            config.sections[0].compartment,
1266            "ocid1.compartment.oc1..example"
1267        );
1268
1269        // Save to a temp file and re-parse
1270        let tmp = std::env::temp_dir().join("purple_test_compartment_round_trip");
1271        let mut cfg = config;
1272        cfg.path_override = Some(PathBuf::from(&tmp));
1273        cfg.save().expect("save failed");
1274        let saved = std::fs::read_to_string(&tmp).expect("read failed");
1275        let _ = std::fs::remove_file(&tmp);
1276        let reparsed = ProviderConfig::parse(&saved);
1277        assert_eq!(
1278            reparsed.sections[0].compartment,
1279            "ocid1.compartment.oc1..example"
1280        );
1281    }
1282
1283    #[test]
1284    fn test_auto_sync_default_true_for_oracle() {
1285        let config = ProviderConfig::parse("[oracle]\ntoken=~/.oci/config\n");
1286        assert!(config.sections[0].auto_sync);
1287    }
1288
1289    #[test]
1290    fn test_sanitize_value_strips_control_chars() {
1291        assert_eq!(ProviderConfig::sanitize_value("clean"), "clean");
1292        assert_eq!(ProviderConfig::sanitize_value("has\nnewline"), "hasnewline");
1293        assert_eq!(ProviderConfig::sanitize_value("has\ttab"), "hastab");
1294        assert_eq!(
1295            ProviderConfig::sanitize_value("has\rcarriage"),
1296            "hascarriage"
1297        );
1298        assert_eq!(ProviderConfig::sanitize_value("has\x00null"), "hasnull");
1299        assert_eq!(ProviderConfig::sanitize_value(""), "");
1300    }
1301
1302    #[test]
1303    fn test_save_sanitizes_token_with_newline() {
1304        let path = std::env::temp_dir().join(format!(
1305            "__purple_test_config_sanitize_{}.ini",
1306            std::process::id()
1307        ));
1308        let config = ProviderConfig {
1309            sections: vec![ProviderSection {
1310                provider: "digitalocean".to_string(),
1311                token: "abc\ndef".to_string(),
1312                alias_prefix: "do".to_string(),
1313                user: "root".to_string(),
1314                identity_file: String::new(),
1315                url: String::new(),
1316                verify_tls: true,
1317                auto_sync: true,
1318                profile: String::new(),
1319                regions: String::new(),
1320                project: String::new(),
1321                compartment: String::new(),
1322            }],
1323            path_override: Some(path.clone()),
1324        };
1325        config.save().unwrap();
1326        let content = std::fs::read_to_string(&path).unwrap();
1327        let _ = std::fs::remove_file(&path);
1328        // Token should be on a single line with newline stripped
1329        assert!(content.contains("token=abcdef\n"));
1330        assert!(!content.contains("token=abc\ndef"));
1331    }
1332}