Skip to main content

purple_ssh/providers/
config.rs

1use std::io;
2use std::path::PathBuf;
3
4/// A configured provider section from ~/.purple/providers.
5#[derive(Debug, Clone)]
6pub struct ProviderSection {
7    pub provider: String,
8    pub token: String,
9    pub alias_prefix: String,
10    pub user: String,
11    pub identity_file: String,
12}
13
14/// Parsed provider configuration from ~/.purple/providers.
15#[derive(Debug, Clone, Default)]
16pub struct ProviderConfig {
17    pub sections: Vec<ProviderSection>,
18}
19
20fn config_path() -> Option<PathBuf> {
21    dirs::home_dir().map(|h| h.join(".purple/providers"))
22}
23
24impl ProviderConfig {
25    /// Load provider config from ~/.purple/providers.
26    /// Returns empty config if file doesn't exist (normal first-use).
27    /// Prints a warning to stderr on real IO errors (permissions, etc.).
28    pub fn load() -> Self {
29        let path = match config_path() {
30            Some(p) => p,
31            None => return Self::default(),
32        };
33        let content = match std::fs::read_to_string(&path) {
34            Ok(c) => c,
35            Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
36            Err(e) => {
37                eprintln!("! Could not read {}: {}", path.display(), e);
38                return Self::default();
39            }
40        };
41        Self::parse(&content)
42    }
43
44    /// Parse INI-style provider config.
45    fn parse(content: &str) -> Self {
46        let mut sections = Vec::new();
47        let mut current: Option<ProviderSection> = None;
48
49        for line in content.lines() {
50            let trimmed = line.trim();
51            if trimmed.is_empty() || trimmed.starts_with('#') {
52                continue;
53            }
54            if trimmed.starts_with('[') && trimmed.ends_with(']') {
55                if let Some(section) = current.take() {
56                    sections.push(section);
57                }
58                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
59                let short_label = super::get_provider(&name)
60                    .map(|p| p.short_label().to_string())
61                    .unwrap_or_else(|| name.clone());
62                current = Some(ProviderSection {
63                    provider: name,
64                    token: String::new(),
65                    alias_prefix: short_label,
66                    user: "root".to_string(),
67                    identity_file: String::new(),
68                });
69            } else if let Some(ref mut section) = current {
70                if let Some((key, value)) = trimmed.split_once('=') {
71                    let key = key.trim();
72                    let value = value.trim().to_string();
73                    match key {
74                        "token" => section.token = value,
75                        "alias_prefix" => section.alias_prefix = value,
76                        "user" => section.user = value,
77                        "key" => section.identity_file = value,
78                        _ => {}
79                    }
80                }
81            }
82        }
83        if let Some(section) = current {
84            sections.push(section);
85        }
86        Self { sections }
87    }
88
89    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
90    pub fn save(&self) -> io::Result<()> {
91        let path = match config_path() {
92            Some(p) => p,
93            None => return Ok(()),
94        };
95
96        if let Some(parent) = path.parent() {
97            std::fs::create_dir_all(parent)?;
98        }
99
100        let mut content = String::new();
101        for (i, section) in self.sections.iter().enumerate() {
102            if i > 0 {
103                content.push('\n');
104            }
105            content.push_str(&format!("[{}]\n", section.provider));
106            content.push_str(&format!("token={}\n", section.token));
107            content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
108            content.push_str(&format!("user={}\n", section.user));
109            if !section.identity_file.is_empty() {
110                content.push_str(&format!("key={}\n", section.identity_file));
111            }
112        }
113
114        let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
115
116        #[cfg(unix)]
117        {
118            use std::fs::OpenOptions;
119            use std::io::Write;
120            use std::os::unix::fs::OpenOptionsExt;
121            let mut file = OpenOptions::new()
122                .write(true)
123                .create(true)
124                .truncate(true)
125                .mode(0o600)
126                .open(&tmp_path)?;
127            file.write_all(content.as_bytes())?;
128        }
129
130        #[cfg(not(unix))]
131        std::fs::write(&tmp_path, &content)?;
132
133        let result = std::fs::rename(&tmp_path, &path);
134        if result.is_err() {
135            let _ = std::fs::remove_file(&tmp_path);
136        }
137        result?;
138        Ok(())
139    }
140
141    /// Get a configured provider section by name.
142    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
143        self.sections.iter().find(|s| s.provider == provider)
144    }
145
146    /// Add or replace a provider section.
147    pub fn set_section(&mut self, section: ProviderSection) {
148        if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
149            *existing = section;
150        } else {
151            self.sections.push(section);
152        }
153    }
154
155    /// Remove a provider section.
156    pub fn remove_section(&mut self, provider: &str) {
157        self.sections.retain(|s| s.provider != provider);
158    }
159
160    /// Get all configured provider sections.
161    pub fn configured_providers(&self) -> &[ProviderSection] {
162        &self.sections
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_parse_empty() {
172        let config = ProviderConfig::parse("");
173        assert!(config.sections.is_empty());
174    }
175
176    #[test]
177    fn test_parse_single_section() {
178        let content = "\
179[digitalocean]
180token=dop_v1_abc123
181alias_prefix=do
182user=root
183key=~/.ssh/id_ed25519
184";
185        let config = ProviderConfig::parse(content);
186        assert_eq!(config.sections.len(), 1);
187        let s = &config.sections[0];
188        assert_eq!(s.provider, "digitalocean");
189        assert_eq!(s.token, "dop_v1_abc123");
190        assert_eq!(s.alias_prefix, "do");
191        assert_eq!(s.user, "root");
192        assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
193    }
194
195    #[test]
196    fn test_parse_multiple_sections() {
197        let content = "\
198[digitalocean]
199token=abc
200
201[vultr]
202token=xyz
203user=deploy
204";
205        let config = ProviderConfig::parse(content);
206        assert_eq!(config.sections.len(), 2);
207        assert_eq!(config.sections[0].provider, "digitalocean");
208        assert_eq!(config.sections[1].provider, "vultr");
209        assert_eq!(config.sections[1].user, "deploy");
210    }
211
212    #[test]
213    fn test_parse_comments_and_blanks() {
214        let content = "\
215# Provider config
216
217[linode]
218# API token
219token=mytoken
220";
221        let config = ProviderConfig::parse(content);
222        assert_eq!(config.sections.len(), 1);
223        assert_eq!(config.sections[0].token, "mytoken");
224    }
225
226    #[test]
227    fn test_set_section_add() {
228        let mut config = ProviderConfig::default();
229        config.set_section(ProviderSection {
230            provider: "vultr".to_string(),
231            token: "abc".to_string(),
232            alias_prefix: "vultr".to_string(),
233            user: "root".to_string(),
234            identity_file: String::new(),
235        });
236        assert_eq!(config.sections.len(), 1);
237    }
238
239    #[test]
240    fn test_set_section_replace() {
241        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
242        config.set_section(ProviderSection {
243            provider: "vultr".to_string(),
244            token: "new".to_string(),
245            alias_prefix: "vultr".to_string(),
246            user: "root".to_string(),
247            identity_file: String::new(),
248        });
249        assert_eq!(config.sections.len(), 1);
250        assert_eq!(config.sections[0].token, "new");
251    }
252
253    #[test]
254    fn test_remove_section() {
255        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
256        config.remove_section("vultr");
257        assert_eq!(config.sections.len(), 1);
258        assert_eq!(config.sections[0].provider, "linode");
259    }
260
261    #[test]
262    fn test_section_lookup() {
263        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
264        assert!(config.section("digitalocean").is_some());
265        assert!(config.section("vultr").is_none());
266    }
267
268    #[test]
269    fn test_defaults_applied() {
270        let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
271        let s = &config.sections[0];
272        assert_eq!(s.user, "root");
273        assert_eq!(s.alias_prefix, "hetzner");
274        assert!(s.identity_file.is_empty());
275    }
276}