1use 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 pub url: String,
15 pub verify_tls: bool,
16 pub auto_sync: bool,
17}
18
19fn default_auto_sync(provider: &str) -> bool {
21 provider != "proxmox"
22}
23
24#[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 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 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 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(§ion.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 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
158 self.sections.iter().find(|s| s.provider == provider)
159 }
160
161 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 pub fn remove_section(&mut self, provider: &str) {
172 self.sections.retain(|s| s.provider != provider);
173 }
174
175 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 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 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(), verify_tls: true, auto_sync: true, };
403 let mut config = ProviderConfig::default();
404 config.set_section(section);
405 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 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 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 assert!(config.sections[0].auto_sync);
475
476 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 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 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 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, });
531 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 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}