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