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                    sections.push(section);
59                }
60                let name = trimmed[1..trimmed.len() - 1].trim().to_string();
61                let short_label = super::get_provider(&name)
62                    .map(|p| p.short_label().to_string())
63                    .unwrap_or_else(|| name.clone());
64                current = Some(ProviderSection {
65                    provider: name,
66                    token: String::new(),
67                    alias_prefix: short_label,
68                    user: "root".to_string(),
69                    identity_file: String::new(),
70                });
71            } else if let Some(ref mut section) = current {
72                if let Some((key, value)) = trimmed.split_once('=') {
73                    let key = key.trim();
74                    let value = value.trim().to_string();
75                    match key {
76                        "token" => section.token = value,
77                        "alias_prefix" => section.alias_prefix = value,
78                        "user" => section.user = value,
79                        "key" => section.identity_file = value,
80                        _ => {}
81                    }
82                }
83            }
84        }
85        if let Some(section) = current {
86            sections.push(section);
87        }
88        Self { sections }
89    }
90
91    /// Save provider config to ~/.purple/providers (atomic write, chmod 600).
92    pub fn save(&self) -> io::Result<()> {
93        let path = match config_path() {
94            Some(p) => p,
95            None => {
96                return Err(io::Error::new(
97                    io::ErrorKind::NotFound,
98                    "Could not determine home directory",
99                ))
100            }
101        };
102
103        let mut content = String::new();
104        for (i, section) in self.sections.iter().enumerate() {
105            if i > 0 {
106                content.push('\n');
107            }
108            content.push_str(&format!("[{}]\n", section.provider));
109            content.push_str(&format!("token={}\n", section.token));
110            content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
111            content.push_str(&format!("user={}\n", section.user));
112            if !section.identity_file.is_empty() {
113                content.push_str(&format!("key={}\n", section.identity_file));
114            }
115        }
116
117        fs_util::atomic_write(&path, content.as_bytes())
118    }
119
120    /// Get a configured provider section by name.
121    pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
122        self.sections.iter().find(|s| s.provider == provider)
123    }
124
125    /// Add or replace a provider section.
126    pub fn set_section(&mut self, section: ProviderSection) {
127        if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
128            *existing = section;
129        } else {
130            self.sections.push(section);
131        }
132    }
133
134    /// Remove a provider section.
135    pub fn remove_section(&mut self, provider: &str) {
136        self.sections.retain(|s| s.provider != provider);
137    }
138
139    /// Get all configured provider sections.
140    pub fn configured_providers(&self) -> &[ProviderSection] {
141        &self.sections
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_parse_empty() {
151        let config = ProviderConfig::parse("");
152        assert!(config.sections.is_empty());
153    }
154
155    #[test]
156    fn test_parse_single_section() {
157        let content = "\
158[digitalocean]
159token=dop_v1_abc123
160alias_prefix=do
161user=root
162key=~/.ssh/id_ed25519
163";
164        let config = ProviderConfig::parse(content);
165        assert_eq!(config.sections.len(), 1);
166        let s = &config.sections[0];
167        assert_eq!(s.provider, "digitalocean");
168        assert_eq!(s.token, "dop_v1_abc123");
169        assert_eq!(s.alias_prefix, "do");
170        assert_eq!(s.user, "root");
171        assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
172    }
173
174    #[test]
175    fn test_parse_multiple_sections() {
176        let content = "\
177[digitalocean]
178token=abc
179
180[vultr]
181token=xyz
182user=deploy
183";
184        let config = ProviderConfig::parse(content);
185        assert_eq!(config.sections.len(), 2);
186        assert_eq!(config.sections[0].provider, "digitalocean");
187        assert_eq!(config.sections[1].provider, "vultr");
188        assert_eq!(config.sections[1].user, "deploy");
189    }
190
191    #[test]
192    fn test_parse_comments_and_blanks() {
193        let content = "\
194# Provider config
195
196[linode]
197# API token
198token=mytoken
199";
200        let config = ProviderConfig::parse(content);
201        assert_eq!(config.sections.len(), 1);
202        assert_eq!(config.sections[0].token, "mytoken");
203    }
204
205    #[test]
206    fn test_set_section_add() {
207        let mut config = ProviderConfig::default();
208        config.set_section(ProviderSection {
209            provider: "vultr".to_string(),
210            token: "abc".to_string(),
211            alias_prefix: "vultr".to_string(),
212            user: "root".to_string(),
213            identity_file: String::new(),
214        });
215        assert_eq!(config.sections.len(), 1);
216    }
217
218    #[test]
219    fn test_set_section_replace() {
220        let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
221        config.set_section(ProviderSection {
222            provider: "vultr".to_string(),
223            token: "new".to_string(),
224            alias_prefix: "vultr".to_string(),
225            user: "root".to_string(),
226            identity_file: String::new(),
227        });
228        assert_eq!(config.sections.len(), 1);
229        assert_eq!(config.sections[0].token, "new");
230    }
231
232    #[test]
233    fn test_remove_section() {
234        let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
235        config.remove_section("vultr");
236        assert_eq!(config.sections.len(), 1);
237        assert_eq!(config.sections[0].provider, "linode");
238    }
239
240    #[test]
241    fn test_section_lookup() {
242        let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
243        assert!(config.section("digitalocean").is_some());
244        assert!(config.section("vultr").is_none());
245    }
246
247    #[test]
248    fn test_defaults_applied() {
249        let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
250        let s = &config.sections[0];
251        assert_eq!(s.user, "root");
252        assert_eq!(s.alias_prefix, "hetzner");
253        assert!(s.identity_file.is_empty());
254    }
255}