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 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 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 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
122 self.sections.iter().find(|s| s.provider == provider)
123 }
124
125 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 pub fn remove_section(&mut self, provider: &str) {
136 self.sections.retain(|s| s.provider != provider);
137 }
138
139 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}