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 pub profile: String,
18 pub regions: String,
19 pub project: String,
20 pub compartment: String,
21 pub vault_role: String,
22 pub vault_addr: String,
26}
27
28impl Default for ProviderSection {
29 fn default() -> Self {
30 Self {
31 provider: String::new(),
32 token: String::new(),
33 alias_prefix: String::new(),
34 user: String::new(),
35 identity_file: String::new(),
36 url: String::new(),
37 verify_tls: true,
40 auto_sync: false,
41 profile: String::new(),
42 regions: String::new(),
43 project: String::new(),
44 compartment: String::new(),
45 vault_role: String::new(),
46 vault_addr: String::new(),
47 }
48 }
49}
50
51fn default_auto_sync(provider: &str) -> bool {
53 !matches!(provider, "proxmox")
54}
55
56#[derive(Debug, Clone, Default)]
58pub struct ProviderConfig {
59 pub sections: Vec<ProviderSection>,
60 pub path_override: Option<PathBuf>,
63}
64
65fn config_path() -> Option<PathBuf> {
66 dirs::home_dir().map(|h| h.join(".purple/providers"))
67}
68
69impl ProviderConfig {
70 pub fn load() -> Self {
74 let path = match config_path() {
75 Some(p) => p,
76 None => return Self::default(),
77 };
78 let content = match std::fs::read_to_string(&path) {
79 Ok(c) => c,
80 Err(e) if e.kind() == io::ErrorKind::NotFound => return Self::default(),
81 Err(e) => {
82 log::warn!("[config] Could not read {}: {}", path.display(), e);
83 return Self::default();
84 }
85 };
86 Self::parse(&content)
87 }
88
89 pub(crate) fn parse(content: &str) -> Self {
91 let mut sections = Vec::new();
92 let mut current: Option<ProviderSection> = None;
93
94 for line in content.lines() {
95 let trimmed = line.trim();
96 if trimmed.is_empty() || trimmed.starts_with('#') {
97 continue;
98 }
99 if trimmed.starts_with('[') && trimmed.ends_with(']') {
100 if let Some(section) = current.take() {
101 if !sections
102 .iter()
103 .any(|s: &ProviderSection| s.provider == section.provider)
104 {
105 sections.push(section);
106 }
107 }
108 let name = trimmed[1..trimmed.len() - 1].trim().to_string();
109 if sections.iter().any(|s| s.provider == name) {
110 current = None;
111 continue;
112 }
113 let short_label = super::get_provider(&name)
114 .map(|p| p.short_label().to_string())
115 .unwrap_or_else(|| name.clone());
116 let auto_sync_default = default_auto_sync(&name);
117 current = Some(ProviderSection {
118 provider: name,
119 token: String::new(),
120 alias_prefix: short_label,
121 user: "root".to_string(),
122 identity_file: String::new(),
123 url: String::new(),
124 verify_tls: true,
125 auto_sync: auto_sync_default,
126 profile: String::new(),
127 regions: String::new(),
128 project: String::new(),
129 compartment: String::new(),
130 vault_role: String::new(),
131 vault_addr: String::new(),
132 });
133 } else if let Some(ref mut section) = current {
134 if let Some((key, value)) = trimmed.split_once('=') {
135 let key = key.trim();
136 let value = value.trim().to_string();
137 match key {
138 "token" => section.token = value,
139 "alias_prefix" => section.alias_prefix = value,
140 "user" => section.user = value,
141 "key" => section.identity_file = value,
142 "url" => section.url = value,
143 "verify_tls" => {
144 section.verify_tls =
145 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
146 }
147 "auto_sync" => {
148 section.auto_sync =
149 !matches!(value.to_lowercase().as_str(), "false" | "0" | "no")
150 }
151 "profile" => section.profile = value,
152 "regions" => section.regions = value,
153 "project" => section.project = value,
154 "compartment" => section.compartment = value,
155 "vault_role" => {
156 section.vault_role = if crate::vault_ssh::is_valid_role(&value) {
158 value
159 } else {
160 String::new()
161 };
162 }
163 "vault_addr" => {
164 section.vault_addr = if crate::vault_ssh::is_valid_vault_addr(&value) {
168 value
169 } else {
170 String::new()
171 };
172 }
173 _ => {}
174 }
175 }
176 }
177 }
178 if let Some(section) = current {
179 if !sections.iter().any(|s| s.provider == section.provider) {
180 sections.push(section);
181 }
182 }
183 Self {
184 sections,
185 path_override: None,
186 }
187 }
188
189 fn sanitize_value(s: &str) -> String {
192 s.chars().filter(|c| !c.is_control()).collect()
193 }
194
195 pub fn save(&self) -> io::Result<()> {
198 if self.path_override.is_none() && crate::demo_flag::is_demo() {
201 return Ok(());
202 }
203 let path = match &self.path_override {
204 Some(p) => p.clone(),
205 None => match config_path() {
206 Some(p) => p,
207 None => {
208 return Err(io::Error::new(
209 io::ErrorKind::NotFound,
210 "Could not determine home directory",
211 ));
212 }
213 },
214 };
215
216 let mut content = String::new();
217 for (i, section) in self.sections.iter().enumerate() {
218 if i > 0 {
219 content.push('\n');
220 }
221 content.push_str(&format!("[{}]\n", Self::sanitize_value(§ion.provider)));
222 content.push_str(&format!("token={}\n", Self::sanitize_value(§ion.token)));
223 content.push_str(&format!(
224 "alias_prefix={}\n",
225 Self::sanitize_value(§ion.alias_prefix)
226 ));
227 content.push_str(&format!("user={}\n", Self::sanitize_value(§ion.user)));
228 if !section.identity_file.is_empty() {
229 content.push_str(&format!(
230 "key={}\n",
231 Self::sanitize_value(§ion.identity_file)
232 ));
233 }
234 if !section.url.is_empty() {
235 content.push_str(&format!("url={}\n", Self::sanitize_value(§ion.url)));
236 }
237 if !section.verify_tls {
238 content.push_str("verify_tls=false\n");
239 }
240 if !section.profile.is_empty() {
241 content.push_str(&format!(
242 "profile={}\n",
243 Self::sanitize_value(§ion.profile)
244 ));
245 }
246 if !section.regions.is_empty() {
247 content.push_str(&format!(
248 "regions={}\n",
249 Self::sanitize_value(§ion.regions)
250 ));
251 }
252 if !section.project.is_empty() {
253 content.push_str(&format!(
254 "project={}\n",
255 Self::sanitize_value(§ion.project)
256 ));
257 }
258 if !section.compartment.is_empty() {
259 content.push_str(&format!(
260 "compartment={}\n",
261 Self::sanitize_value(§ion.compartment)
262 ));
263 }
264 if !section.vault_role.is_empty()
265 && crate::vault_ssh::is_valid_role(§ion.vault_role)
266 {
267 content.push_str(&format!(
268 "vault_role={}\n",
269 Self::sanitize_value(§ion.vault_role)
270 ));
271 }
272 if !section.vault_addr.is_empty()
273 && crate::vault_ssh::is_valid_vault_addr(§ion.vault_addr)
274 {
275 content.push_str(&format!(
276 "vault_addr={}\n",
277 Self::sanitize_value(§ion.vault_addr)
278 ));
279 }
280 if section.auto_sync != default_auto_sync(§ion.provider) {
281 content.push_str(if section.auto_sync {
282 "auto_sync=true\n"
283 } else {
284 "auto_sync=false\n"
285 });
286 }
287 }
288
289 fs_util::atomic_write(&path, content.as_bytes())
290 }
291
292 pub fn section(&self, provider: &str) -> Option<&ProviderSection> {
294 self.sections.iter().find(|s| s.provider == provider)
295 }
296
297 pub fn set_section(&mut self, section: ProviderSection) {
299 if let Some(existing) = self
300 .sections
301 .iter_mut()
302 .find(|s| s.provider == section.provider)
303 {
304 *existing = section;
305 } else {
306 self.sections.push(section);
307 }
308 }
309
310 pub fn remove_section(&mut self, provider: &str) {
312 self.sections.retain(|s| s.provider != provider);
313 }
314
315 pub fn configured_providers(&self) -> &[ProviderSection] {
317 &self.sections
318 }
319}
320
321#[cfg(test)]
322#[path = "config_tests.rs"]
323mod tests;