purple_ssh/providers/
config.rs1use std::io;
2use std::path::PathBuf;
3
4use crate::fs_util;
5
6#[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#[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 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 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 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 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
130 self.sections.iter().find(|s| s.provider == provider)
131 }
132
133 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 pub fn remove_section(&mut self, provider: &str) {
144 self.sections.retain(|s| s.provider != provider);
145 }
146
147 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}