Skip to main content

purple_ssh/providers/
config.rs

1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6/// A configured provider section from ~/.purple/providers.
7#[derive(Debug, Clone)]
8pub struct ProviderSection {
9    pub provider: String,
10    pub token: String,
11    pub alias_prefix: String,
12    pub user: String,
13    pub identity_file: String,
14    pub url: String,
15    pub verify_tls: bool,
16    pub auto_sync: bool,
17}
18
19/// Default for auto_sync: false for proxmox (N+1 API calls), true for all others.
20fn default_auto_sync(provider: &str) -> bool {
21    provider != "proxmox"
22}
23
24/// Parsed provider configuration from ~/.purple/providers.
25#[derive(Debug, Clone, Default)]
26pub struct ProviderConfig {
27    pub sections: Vec<ProviderSection>,
28}
29
30fn config_path() -> Option<PathBuf> {
31    dirs::home_dir().map(|h| h.join(".purple/providers"))
32}
33
34impl ProviderConfig {
35    /// Load provider config from ~/.purple/providers.
36    /// Returns empty config if file doesn't exist (normal first-use).
37    /// Prints a warning to stderr on real IO errors (permissions, etc.).
38    pub fn load() -> Self {
39        let path = match config_path() {
40            Some(p) => p,
41            None => return Self::default(),
42        };
43        let content = match std::fs::read_to_string(&path) {
44            Ok(c) => c,
45            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
46            Err(e) => {
47                eprintln!("! Could not read {}: {}", path.display(), e);
48                return Self::default();
49            }
50        };
51        Self::parse(&content)
52    }
53
54    /// Parse INI-style provider config.
55    fn parse(content: &str) -> Self {
56        let mut sections = Vec::new();
57        let mut current: Option<ProviderSection> = None;
58
59        for line in content.lines() {
60            let trimmed = line.trim();
61            if trimmed.is_empty() || trimmed.starts_with('#') {
62                continue;
63            }
64            if trimmed.starts_with('[') && trimmed.ends_with(']') {
65                if let Some(section) = current.take() {
66                    if !sections.iter().any(|s: &ProviderSection| s.provider == section.provider) {
67                        sections.push(section);
68                    }
69                }
70                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
71                if sections.iter().any(|s| s.provider == name) {
72                    current = None;
73                    continue;
74                }
75                let short_label = super::get_provider(&name)
76                    .map(|p| p.short_label().to_string())
77                    .unwrap_or_else(|| name.clone());
78                let auto_sync_default = default_auto_sync(&name);
79                current = Some(ProviderSection {
80                    provider: name,
81                    token: String::new(),
82                    alias_prefix: short_label,
83                    user: "root".to_string(),
84                    identity_file: String::new(),
85                    url: String::new(),
86                    verify_tls: true,
87                    auto_sync: auto_sync_default,
88                });
89            } else if let Some(ref mut section) = current {
90                if let Some((key, value)) = trimmed.split_once('=') {
91                    let key = key.trim();
92                    let value = value.trim().to_string();
93                    match key {
94                        "token" => section.token = value,
95                        "alias_prefix" => section.alias_prefix = value,
96                        "user" => section.user = value,
97                        "key" => section.identity_file = value,
98                        "url" => section.url = value,
99                        "verify_tls" => section.verify_tls = !matches!(
100                            value.to_lowercase().as_str(), "false" | "0" | "no"
101                        ),
102                        "auto_sync" => section.auto_sync = !matches!(
103                            value.to_lowercase().as_str(), "false" | "0" | "no"
104                        ),
105                        _ => {}
106                    }
107                }
108            }
109        }
110        if let Some(section) = current {
111            if !sections.iter().any(|s| s.provider == section.provider) {
112                sections.push(section);
113            }
114        }
115        Self { sections }
116    }
117
118    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
119    pub fn save(&self) -> io::Result<()> {
120        let path = match config_path() {
121            Some(p) => p,
122            None => {
123                return Err(io::Error::new(
124                    io::ErrorKind::NotFound,
125                    "Could not determine home directory",
126                ))
127            }
128        };
129
130        let mut content = String::new();
131        for (i, section) in self.sections.iter().enumerate() {
132            if i > 0 {
133                content.push('\n');
134            }
135            content.push_str(&format!("[{}]\n", section.provider));
136            content.push_str(&format!("token={}\n", section.token));
137            content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
138            content.push_str(&format!("user={}\n", section.user));
139            if !section.identity_file.is_empty() {
140                content.push_str(&format!("key={}\n", section.identity_file));
141            }
142            if !section.url.is_empty() {
143                content.push_str(&format!("url={}\n", section.url));
144            }
145            if !section.verify_tls {
146                content.push_str("verify_tls=false\n");
147            }
148            if section.auto_sync != default_auto_sync(&section.provider) {
149                content.push_str(if section.auto_sync { "auto_sync=true\n" } else { "auto_sync=false\n" });
150            }
151        }
152
153        fs_util::atomic_write(&path, content.as_bytes())
154    }
155
156    /// Get a configured provider section by name.
157    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
158        self.sections.iter().find(|s| s.provider == provider)
159    }
160
161    /// Add or replace a provider section.
162    pub fn set_section(&mut self, section: ProviderSection) {
163        if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
164            *existing = section;
165        } else {
166            self.sections.push(section);
167        }
168    }
169
170    /// Remove a provider section.
171    pub fn remove_section(&mut self, provider: &str) {
172        self.sections.retain(|s| s.provider != provider);
173    }
174
175    /// Get all configured provider sections.
176    pub fn configured_providers(&self) -> &[ProviderSection] {
177        &self.sections
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn test_parse_empty() {
187        let config = ProviderConfig::parse("");
188        assert!(config.sections.is_empty());
189    }
190
191    #[test]
192    fn test_parse_single_section() {
193        let content = "\
194[digitalocean]
195token=dop_v1_abc123
196alias_prefix=do
197user=root
198key=~/.ssh/id_ed25519
199";
200        let config = ProviderConfig::parse(content);
201        assert_eq!(config.sections.len(), 1);
202        let s = &config.sections[0];
203        assert_eq!(s.provider, "digitalocean");
204        assert_eq!(s.token, "dop_v1_abc123");
205        assert_eq!(s.alias_prefix, "do");
206        assert_eq!(s.user, "root");
207        assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
208    }
209
210    #[test]
211    fn test_parse_multiple_sections() {
212        let content = "\
213[digitalocean]
214token=abc
215
216[vultr]
217token=xyz
218user=deploy
219";
220        let config = ProviderConfig::parse(content);
221        assert_eq!(config.sections.len(), 2);
222        assert_eq!(config.sections[0].provider, "digitalocean");
223        assert_eq!(config.sections[1].provider, "vultr");
224        assert_eq!(config.sections[1].user, "deploy");
225    }
226
227    #[test]
228    fn test_parse_comments_and_blanks() {
229        let content = "\
230# Provider config
231
232[linode]
233# API token
234token=mytoken
235";
236        let config = ProviderConfig::parse(content);
237        assert_eq!(config.sections.len(), 1);
238        assert_eq!(config.sections[0].token, "mytoken");
239    }
240
241    #[test]
242    fn test_set_section_add() {
243        let mut config = ProviderConfig::default();
244        config.set_section(ProviderSection {
245            provider: "vultr".to_string(),
246            token: "abc".to_string(),
247            alias_prefix: "vultr".to_string(),
248            user: "root".to_string(),
249            identity_file: String::new(),
250            url: String::new(),
251            verify_tls: true,
252            auto_sync: true,
253        });
254        assert_eq!(config.sections.len(), 1);
255    }
256
257    #[test]
258    fn test_set_section_replace() {
259        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
260        config.set_section(ProviderSection {
261            provider: "vultr".to_string(),
262            token: "new".to_string(),
263            alias_prefix: "vultr".to_string(),
264            user: "root".to_string(),
265            identity_file: String::new(),
266            url: String::new(),
267            verify_tls: true,
268            auto_sync: true,
269        });
270        assert_eq!(config.sections.len(), 1);
271        assert_eq!(config.sections[0].token, "new");
272    }
273
274    #[test]
275    fn test_remove_section() {
276        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
277        config.remove_section("vultr");
278        assert_eq!(config.sections.len(), 1);
279        assert_eq!(config.sections[0].provider, "linode");
280    }
281
282    #[test]
283    fn test_section_lookup() {
284        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
285        assert!(config.section("digitalocean").is_some());
286        assert!(config.section("vultr").is_none());
287    }
288
289    #[test]
290    fn test_parse_duplicate_sections_first_wins() {
291        let content = "\
292[digitalocean]
293token=first
294
295[digitalocean]
296token=second
297";
298        let config = ProviderConfig::parse(content);
299        assert_eq!(config.sections.len(), 1);
300        assert_eq!(config.sections[0].token, "first");
301    }
302
303    #[test]
304    fn test_parse_duplicate_sections_trailing() {
305        let content = "\
306[vultr]
307token=abc
308
309[linode]
310token=xyz
311
312[vultr]
313token=dup
314";
315        let config = ProviderConfig::parse(content);
316        assert_eq!(config.sections.len(), 2);
317        assert_eq!(config.sections[0].provider, "vultr");
318        assert_eq!(config.sections[0].token, "abc");
319        assert_eq!(config.sections[1].provider, "linode");
320    }
321
322    #[test]
323    fn test_defaults_applied() {
324        let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
325        let s = &config.sections[0];
326        assert_eq!(s.user, "root");
327        assert_eq!(s.alias_prefix, "hetzner");
328        assert!(s.identity_file.is_empty());
329        assert!(s.url.is_empty());
330        assert!(s.verify_tls);
331        assert!(s.auto_sync);
332    }
333
334    #[test]
335    fn test_parse_url_and_verify_tls() {
336        let content = "\
337[proxmox]
338token=user@pam!purple=secret
339url=https://pve.example.com:8006
340verify_tls=false
341";
342        let config = ProviderConfig::parse(content);
343        assert_eq!(config.sections.len(), 1);
344        let s = &config.sections[0];
345        assert_eq!(s.url, "https://pve.example.com:8006");
346        assert!(!s.verify_tls);
347    }
348
349    #[test]
350    fn test_url_and_verify_tls_round_trip() {
351        let content = "\
352[proxmox]
353token=tok
354alias_prefix=pve
355user=root
356url=https://pve.local:8006
357verify_tls=false
358";
359        let config = ProviderConfig::parse(content);
360        let s = &config.sections[0];
361        assert_eq!(s.url, "https://pve.local:8006");
362        assert!(!s.verify_tls);
363    }
364
365    #[test]
366    fn test_verify_tls_default_true() {
367        // verify_tls not present -> defaults to true
368        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
369        assert!(config.sections[0].verify_tls);
370    }
371
372    #[test]
373    fn test_verify_tls_false_variants() {
374        for value in &["false", "False", "FALSE", "0", "no", "No", "NO"] {
375            let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
376            let config = ProviderConfig::parse(&content);
377            assert!(!config.sections[0].verify_tls, "verify_tls={} should be false", value);
378        }
379    }
380
381    #[test]
382    fn test_verify_tls_true_variants() {
383        for value in &["true", "True", "1", "yes"] {
384            let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nverify_tls={}\n", value);
385            let config = ProviderConfig::parse(&content);
386            assert!(config.sections[0].verify_tls, "verify_tls={} should be true", value);
387        }
388    }
389
390    #[test]
391    fn test_non_proxmox_url_not_written() {
392        // url and verify_tls=false must not appear for non-Proxmox providers in saved config
393        let section = ProviderSection {
394            provider: "digitalocean".to_string(),
395            token: "tok".to_string(),
396            alias_prefix: "do".to_string(),
397            user: "root".to_string(),
398            identity_file: String::new(),
399            url: String::new(),     // empty: not written
400            verify_tls: true,       // default: not written
401            auto_sync: true,        // default for non-proxmox: not written
402        };
403        let mut config = ProviderConfig::default();
404        config.set_section(section);
405        // Parse it back: url and verify_tls should be at defaults
406        let s = &config.sections[0];
407        assert!(s.url.is_empty());
408        assert!(s.verify_tls);
409    }
410
411    #[test]
412    fn test_proxmox_url_fallback_in_section() {
413        // Simulates the update path: existing section has url, new section should preserve it
414        let existing = ProviderConfig::parse(
415            "[proxmox]\ntoken=old\nalias_prefix=pve\nuser=root\nurl=https://pve.local:8006\n",
416        );
417        let existing_url = existing.section("proxmox").map(|s| s.url.clone()).unwrap_or_default();
418        assert_eq!(existing_url, "https://pve.local:8006");
419
420        let mut config = existing;
421        config.set_section(ProviderSection {
422            provider: "proxmox".to_string(),
423            token: "new".to_string(),
424            alias_prefix: "pve".to_string(),
425            user: "root".to_string(),
426            identity_file: String::new(),
427            url: existing_url,
428            verify_tls: true,
429            auto_sync: false,
430        });
431        assert_eq!(config.sections[0].token, "new");
432        assert_eq!(config.sections[0].url, "https://pve.local:8006");
433    }
434
435    #[test]
436    fn test_auto_sync_default_true_for_non_proxmox() {
437        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
438        assert!(config.sections[0].auto_sync);
439    }
440
441    #[test]
442    fn test_auto_sync_default_false_for_proxmox() {
443        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\n");
444        assert!(!config.sections[0].auto_sync);
445    }
446
447    #[test]
448    fn test_auto_sync_explicit_true() {
449        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=true\n");
450        assert!(config.sections[0].auto_sync);
451    }
452
453    #[test]
454    fn test_auto_sync_explicit_false_non_proxmox() {
455        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\nauto_sync=false\n");
456        assert!(!config.sections[0].auto_sync);
457    }
458
459    #[test]
460    fn test_auto_sync_not_written_when_default() {
461        // non-proxmox with auto_sync=true (default) -> not written
462        let mut config = ProviderConfig::default();
463        config.set_section(ProviderSection {
464            provider: "digitalocean".to_string(),
465            token: "tok".to_string(),
466            alias_prefix: "do".to_string(),
467            user: "root".to_string(),
468            identity_file: String::new(),
469            url: String::new(),
470            verify_tls: true,
471            auto_sync: true,
472        });
473        // Re-parse: auto_sync should still be true (default)
474        assert!(config.sections[0].auto_sync);
475
476        // proxmox with auto_sync=false (default) -> not written
477        let mut config2 = ProviderConfig::default();
478        config2.set_section(ProviderSection {
479            provider: "proxmox".to_string(),
480            token: "tok".to_string(),
481            alias_prefix: "pve".to_string(),
482            user: "root".to_string(),
483            identity_file: String::new(),
484            url: "https://pve:8006".to_string(),
485            verify_tls: true,
486            auto_sync: false,
487        });
488        assert!(!config2.sections[0].auto_sync);
489    }
490
491    #[test]
492    fn test_auto_sync_false_variants() {
493        for value in &["false", "False", "FALSE", "0", "no"] {
494            let content = format!("[digitalocean]\ntoken=abc\nauto_sync={}\n", value);
495            let config = ProviderConfig::parse(&content);
496            assert!(!config.sections[0].auto_sync, "auto_sync={} should be false", value);
497        }
498    }
499
500    #[test]
501    fn test_auto_sync_true_variants() {
502        for value in &["true", "True", "TRUE", "1", "yes"] {
503            // Start from proxmox default=false, override to true via explicit value
504            let content = format!("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync={}\n", value);
505            let config = ProviderConfig::parse(&content);
506            assert!(config.sections[0].auto_sync, "auto_sync={} should be true", value);
507        }
508    }
509
510    #[test]
511    fn test_auto_sync_malformed_value_treated_as_true() {
512        // Unrecognised value is not "false"/"0"/"no", so treated as true (like verify_tls)
513        let config = ProviderConfig::parse("[proxmox]\ntoken=abc\nurl=https://pve:8006\nauto_sync=maybe\n");
514        assert!(config.sections[0].auto_sync);
515    }
516
517    #[test]
518    fn test_auto_sync_written_only_when_non_default() {
519        // proxmox defaults to false — setting it to true is non-default, so it IS written
520        let mut config = ProviderConfig::default();
521        config.set_section(ProviderSection {
522            provider: "proxmox".to_string(),
523            token: "tok".to_string(),
524            alias_prefix: "pve".to_string(),
525            user: "root".to_string(),
526            identity_file: String::new(),
527            url: "https://pve:8006".to_string(),
528            verify_tls: true,
529            auto_sync: true, // non-default for proxmox
530        });
531        // Simulate save by rebuilding content string (same logic as save())
532        let content = format!(
533            "[proxmox]\ntoken=tok\nalias_prefix=pve\nuser=root\nurl=https://pve:8006\nauto_sync=true\n"
534        );
535        let reparsed = ProviderConfig::parse(&content);
536        assert!(reparsed.sections[0].auto_sync);
537
538        // digitalocean defaults to true — setting it to false IS written
539        let content2 = "[digitalocean]\ntoken=tok\nalias_prefix=do\nuser=root\nauto_sync=false\n";
540        let reparsed2 = ProviderConfig::parse(content2);
541        assert!(!reparsed2.sections[0].auto_sync);
542    }
543}