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