purple_ssh/providers/
config.rs1use std::io;
2use std::path::PathBuf;
3
4#[derive(Debug, Clone)]
6pub struct ProviderSection {
7 pub provider: String,
8 pub token: String,
9 pub alias_prefix: String,
10 pub user: String,
11 pub identity_file: String,
12}
13
14#[derive(Debug, Clone, Default)]
16pub struct ProviderConfig {
17 pub sections: Vec<ProviderSection>,
18}
19
20fn config_path() -> Option<PathBuf> {
21 dirs::home_dir().map(|h| h.join(".purple/providers"))
22}
23
24impl ProviderConfig {
25 pub fn load() -> Self {
29 let path = match config_path() {
30 Some(p) => p,
31 None => return Self::default(),
32 };
33 let content = match std::fs::read_to_string(&path) {
34 Ok(c) => c,
35 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
36 Err(e) => {
37 eprintln!("! Could not read {}: {}", path.display(), e);
38 return Self::default();
39 }
40 };
41 Self::parse(&content)
42 }
43
44 fn parse(content: &str) -> Self {
46 let mut sections = Vec::new();
47 let mut current: Option<ProviderSection> = None;
48
49 for line in content.lines() {
50 let trimmed = line.trim();
51 if trimmed.is_empty() || trimmed.starts_with('#') {
52 continue;
53 }
54 if trimmed.starts_with('[') && trimmed.ends_with(']') {
55 if let Some(section) = current.take() {
56 sections.push(section);
57 }
58 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
59 let short_label = super::get_provider(&name)
60 .map(|p| p.short_label().to_string())
61 .unwrap_or_else(|| name.clone());
62 current = Some(ProviderSection {
63 provider: name,
64 token: String::new(),
65 alias_prefix: short_label,
66 user: "root".to_string(),
67 identity_file: String::new(),
68 });
69 } else if let Some(ref mut section) = current {
70 if let Some((key, value)) = trimmed.split_once('=') {
71 let key = key.trim();
72 let value = value.trim().to_string();
73 match key {
74 "token" => section.token = value,
75 "alias_prefix" => section.alias_prefix = value,
76 "user" => section.user = value,
77 "key" => section.identity_file = value,
78 _ => {}
79 }
80 }
81 }
82 }
83 if let Some(section) = current {
84 sections.push(section);
85 }
86 Self { sections }
87 }
88
89 pub fn save(&self) -> io::Result<()> {
91 let path = match config_path() {
92 Some(p) => p,
93 None => return Ok(()),
94 };
95
96 if let Some(parent) = path.parent() {
97 std::fs::create_dir_all(parent)?;
98 }
99
100 let mut content = String::new();
101 for (i, section) in self.sections.iter().enumerate() {
102 if i > 0 {
103 content.push('\n');
104 }
105 content.push_str(&format!("[{}]\n", section.provider));
106 content.push_str(&format!("token={}\n", section.token));
107 content.push_str(&format!("alias_prefix={}\n", section.alias_prefix));
108 content.push_str(&format!("user={}\n", section.user));
109 if !section.identity_file.is_empty() {
110 content.push_str(&format!("key={}\n", section.identity_file));
111 }
112 }
113
114 let tmp_path = path.with_extension(format!("tmp.{}", std::process::id()));
115
116 #[cfg(unix)]
117 {
118 use std::fs::OpenOptions;
119 use std::io::Write;
120 use std::os::unix::fs::OpenOptionsExt;
121 let mut file = OpenOptions::new()
122 .write(true)
123 .create(true)
124 .truncate(true)
125 .mode(0o600)
126 .open(&tmp_path)?;
127 file.write_all(content.as_bytes())?;
128 }
129
130 #[cfg(not(unix))]
131 std::fs::write(&tmp_path, &content)?;
132
133 let result = std::fs::rename(&tmp_path, &path);
134 if result.is_err() {
135 let _ = std::fs::remove_file(&tmp_path);
136 }
137 result?;
138 Ok(())
139 }
140
141 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
143 self.sections.iter().find(|s| s.provider == provider)
144 }
145
146 pub fn set_section(&mut self, section: ProviderSection) {
148 if let Some(existing) = self.sections.iter_mut().find(|s| s.provider == section.provider) {
149 *existing = section;
150 } else {
151 self.sections.push(section);
152 }
153 }
154
155 pub fn remove_section(&mut self, provider: &str) {
157 self.sections.retain(|s| s.provider != provider);
158 }
159
160 pub fn configured_providers(&self) -> &[ProviderSection] {
162 &self.sections
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn test_parse_empty() {
172 let config = ProviderConfig::parse("");
173 assert!(config.sections.is_empty());
174 }
175
176 #[test]
177 fn test_parse_single_section() {
178 let content = "\
179[digitalocean]
180token=dop_v1_abc123
181alias_prefix=do
182user=root
183key=~/.ssh/id_ed25519
184";
185 let config = ProviderConfig::parse(content);
186 assert_eq!(config.sections.len(), 1);
187 let s = &config.sections[0];
188 assert_eq!(s.provider, "digitalocean");
189 assert_eq!(s.token, "dop_v1_abc123");
190 assert_eq!(s.alias_prefix, "do");
191 assert_eq!(s.user, "root");
192 assert_eq!(s.identity_file, "~/.ssh/id_ed25519");
193 }
194
195 #[test]
196 fn test_parse_multiple_sections() {
197 let content = "\
198[digitalocean]
199token=abc
200
201[vultr]
202token=xyz
203user=deploy
204";
205 let config = ProviderConfig::parse(content);
206 assert_eq!(config.sections.len(), 2);
207 assert_eq!(config.sections[0].provider, "digitalocean");
208 assert_eq!(config.sections[1].provider, "vultr");
209 assert_eq!(config.sections[1].user, "deploy");
210 }
211
212 #[test]
213 fn test_parse_comments_and_blanks() {
214 let content = "\
215# Provider config
216
217[linode]
218# API token
219token=mytoken
220";
221 let config = ProviderConfig::parse(content);
222 assert_eq!(config.sections.len(), 1);
223 assert_eq!(config.sections[0].token, "mytoken");
224 }
225
226 #[test]
227 fn test_set_section_add() {
228 let mut config = ProviderConfig::default();
229 config.set_section(ProviderSection {
230 provider: "vultr".to_string(),
231 token: "abc".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 }
238
239 #[test]
240 fn test_set_section_replace() {
241 let mut config = ProviderConfig::parse("[vultr]\ntoken=old\n");
242 config.set_section(ProviderSection {
243 provider: "vultr".to_string(),
244 token: "new".to_string(),
245 alias_prefix: "vultr".to_string(),
246 user: "root".to_string(),
247 identity_file: String::new(),
248 });
249 assert_eq!(config.sections.len(), 1);
250 assert_eq!(config.sections[0].token, "new");
251 }
252
253 #[test]
254 fn test_remove_section() {
255 let mut config = ProviderConfig::parse("[vultr]\ntoken=abc\n[linode]\ntoken=xyz\n");
256 config.remove_section("vultr");
257 assert_eq!(config.sections.len(), 1);
258 assert_eq!(config.sections[0].provider, "linode");
259 }
260
261 #[test]
262 fn test_section_lookup() {
263 let config = ProviderConfig::parse("[digitalocean]\ntoken=abc\n");
264 assert!(config.section("digitalocean").is_some());
265 assert!(config.section("vultr").is_none());
266 }
267
268 #[test]
269 fn test_defaults_applied() {
270 let config = ProviderConfig::parse("[hetzner]\ntoken=abc\n");
271 let s = &config.sections[0];
272 assert_eq!(s.user, "root");
273 assert_eq!(s.alias_prefix, "hetzner");
274 assert!(s.identity_file.is_empty());
275 }
276}