1use std::path::PathBuf;
17
18use anyhow::{anyhow, Context, Result};
19
20#[derive(Debug, Clone)]
25pub enum AuthConfig {
26 Basic { username: String, password: String },
27 Bearer { token: String },
28 ApiKey { header: String, key: String },
29}
30
31#[derive(Debug, Clone)]
32pub struct Config {
33 pub base_url: String,
34 pub download_dir: String,
35 pub use_https: bool,
36 pub auth: Option<AuthConfig>,
37}
38
39fn is_placeholder(value: &str) -> bool {
40 value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
41}
42
43pub fn normalize_romm_origin(url: &str) -> String {
48 let mut s = url.trim().trim_end_matches('/').to_string();
49 if s.ends_with("/api") {
50 s.truncate(s.len() - 4);
51 }
52 s.trim_end_matches('/').to_string()
53}
54
55const KEYRING_SERVICE: &str = "romm-cli";
60
61pub fn keyring_store(key: &str, value: &str) -> Result<()> {
63 let entry = keyring::Entry::new(KEYRING_SERVICE, key)
64 .map_err(|e| anyhow!("keyring entry error: {e}"))?;
65 entry
66 .set_password(value)
67 .map_err(|e| anyhow!("keyring set error: {e}"))
68}
69
70fn keyring_get(key: &str) -> Option<String> {
72 let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
73 entry.get_password().ok()
74}
75
76pub fn user_config_dir() -> Option<PathBuf> {
82 #[cfg(test)]
83 if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
84 return Some(PathBuf::from(dir));
85 }
86 dirs::config_dir().map(|d| d.join("romm-cli"))
87}
88
89pub fn user_config_env_path() -> Option<PathBuf> {
91 user_config_dir().map(|d| d.join(".env"))
92}
93
94pub fn openapi_cache_path() -> Result<PathBuf> {
98 if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
99 return Ok(PathBuf::from(p));
100 }
101 let dir = user_config_dir().ok_or_else(|| {
102 anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
103 })?;
104 Ok(dir.join("openapi.json"))
105}
106
107pub fn load_layered_env() {
114 let _ = dotenvy::dotenv();
115 if let Some(path) = user_config_env_path() {
116 if path.is_file() {
117 let _ = dotenvy::from_path(path);
118 }
119 }
120}
121
122fn env_or_keyring(key: &str) -> Option<String> {
127 match std::env::var(key) {
128 Ok(s) if !s.trim().is_empty() => Some(s),
129 Ok(_) => keyring_get(key),
130 Err(_) => keyring_get(key),
131 }
132}
133
134fn env_nonempty(key: &str) -> Option<String> {
135 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
136}
137
138pub fn load_config() -> Result<Config> {
139 let base_raw = std::env::var("API_BASE_URL").map_err(|_| {
140 anyhow!(
141 "API_BASE_URL is not set. Set it in the environment, a .env file, or run: romm-cli init"
142 )
143 })?;
144 let mut base_url = normalize_romm_origin(&base_raw);
145
146 let download_dir = env_nonempty("ROMM_DOWNLOAD_DIR").unwrap_or_else(|| {
147 dirs::download_dir()
148 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
149 .join("romm-cli")
150 .display()
151 .to_string()
152 });
153
154 let use_https = std::env::var("API_USE_HTTPS")
155 .map(|s| s.to_lowercase() == "true")
156 .unwrap_or(true);
157
158 if use_https && base_url.starts_with("http://") {
159 base_url = base_url.replace("http://", "https://");
160 }
161
162 let username = env_nonempty("API_USERNAME");
163 let password = env_or_keyring("API_PASSWORD");
164 let token = env_or_keyring("API_TOKEN").or_else(|| env_or_keyring("API_KEY"));
165 let api_key = env_or_keyring("API_KEY");
166 let api_key_header = env_nonempty("API_KEY_HEADER");
167
168 let auth = if let (Some(user), Some(pass)) = (username, password) {
169 Some(AuthConfig::Basic {
171 username: user,
172 password: pass,
173 })
174 } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
175 if !is_placeholder(&key) {
177 Some(AuthConfig::ApiKey { header, key })
178 } else {
179 None
180 }
181 } else if let Some(tok) = token {
182 if !is_placeholder(&tok) {
184 Some(AuthConfig::Bearer { token: tok })
185 } else {
186 None
187 }
188 } else {
189 None
190 };
191
192 Ok(Config {
193 base_url,
194 download_dir,
195 use_https,
196 auth,
197 })
198}
199
200pub(crate) fn escape_env_value(s: &str) -> String {
202 let needs_quote = s.is_empty()
203 || s.chars()
204 .any(|c| c.is_whitespace() || c == '#' || c == '"' || c == '\'');
205 if needs_quote {
206 let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
207 format!("\"{}\"", escaped)
208 } else {
209 s.to_string()
210 }
211}
212
213pub fn persist_user_config(
216 base_url: &str,
217 download_dir: &str,
218 use_https: bool,
219 auth: Option<AuthConfig>,
220) -> Result<()> {
221 let Some(path) = user_config_env_path() else {
222 return Err(anyhow!(
223 "Could not determine config directory (no HOME / APPDATA?)."
224 ));
225 };
226 let dir = path
227 .parent()
228 .ok_or_else(|| anyhow!("invalid config path"))?;
229 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
230
231 let mut lines: Vec<String> = vec![
232 "# romm-cli user configuration".to_string(),
233 "# Secrets are stored in the OS keyring when available.".to_string(),
234 "# Applied after project .env: only fills variables not already set.".to_string(),
235 String::new(),
236 format!("API_BASE_URL={}", escape_env_value(base_url)),
237 format!("ROMM_DOWNLOAD_DIR={}", escape_env_value(download_dir)),
238 format!("API_USE_HTTPS={}", if use_https { "true" } else { "false" }),
239 String::new(),
240 ];
241
242 match &auth {
243 None => {
244 lines.push("# No auth variables set.".to_string());
245 }
246 Some(AuthConfig::Basic { username, password }) => {
247 lines.push("# Basic auth (password stored in OS keyring)".to_string());
248 lines.push(format!("API_USERNAME={}", escape_env_value(username)));
249 if let Err(e) = keyring_store("API_PASSWORD", password) {
250 tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to .env");
251 lines.push(format!("API_PASSWORD={}", escape_env_value(password)));
252 }
253 }
254 Some(AuthConfig::Bearer { token }) => {
255 lines.push("# Bearer token (stored in OS keyring)".to_string());
256 if let Err(e) = keyring_store("API_TOKEN", token) {
257 tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to .env");
258 lines.push(format!("API_TOKEN={}", escape_env_value(token)));
259 }
260 }
261 Some(AuthConfig::ApiKey { header, key }) => {
262 lines.push("# Custom header API key (key stored in OS keyring)".to_string());
263 lines.push(format!("API_KEY_HEADER={}", escape_env_value(header)));
264 if let Err(e) = keyring_store("API_KEY", key) {
265 tracing::warn!("keyring store API_KEY: {e}; writing plaintext to .env");
266 lines.push(format!("API_KEY={}", escape_env_value(key)));
267 }
268 }
269 }
270
271 let content = lines.join("\n") + "\n";
272 {
273 use std::io::Write;
274 let mut f =
275 std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
276 f.write_all(content.as_bytes())?;
277 }
278
279 #[cfg(unix)]
280 {
281 use std::os::unix::fs::PermissionsExt;
282 let mut perms = std::fs::metadata(&path)?.permissions();
283 perms.set_mode(0o600);
284 std::fs::set_permissions(&path, perms)?;
285 }
286
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use std::sync::{Mutex, OnceLock};
294
295 fn env_lock() -> &'static Mutex<()> {
296 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
297 LOCK.get_or_init(|| Mutex::new(()))
298 }
299
300 fn clear_auth_env() {
301 for key in [
302 "API_BASE_URL",
303 "API_USERNAME",
304 "API_PASSWORD",
305 "API_TOKEN",
306 "API_KEY",
307 "API_KEY_HEADER",
308 "API_USE_HTTPS",
309 ] {
310 std::env::remove_var(key);
311 }
312 }
313
314 #[test]
315 fn prefers_basic_auth_over_other_modes() {
316 let _guard = env_lock().lock().expect("env lock");
317 clear_auth_env();
318 std::env::set_var("API_BASE_URL", "http://example.test");
319 std::env::set_var("API_USERNAME", "user");
320 std::env::set_var("API_PASSWORD", "pass");
321 std::env::set_var("API_TOKEN", "token");
322 std::env::set_var("API_KEY", "apikey");
323 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
324
325 let cfg = load_config().expect("config should load");
326 match cfg.auth {
327 Some(AuthConfig::Basic { username, password }) => {
328 assert_eq!(username, "user");
329 assert_eq!(password, "pass");
330 }
331 _ => panic!("expected basic auth"),
332 }
333 }
334
335 #[test]
336 fn uses_api_key_header_when_token_missing() {
337 let _guard = env_lock().lock().expect("env lock");
338 clear_auth_env();
339 std::env::set_var("API_BASE_URL", "http://example.test");
340 std::env::set_var("API_KEY", "real-key");
341 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
342
343 let cfg = load_config().expect("config should load");
344 match cfg.auth {
345 Some(AuthConfig::ApiKey { header, key }) => {
346 assert_eq!(header, "X-Api-Key");
347 assert_eq!(key, "real-key");
348 }
349 _ => panic!("expected api key auth"),
350 }
351 }
352
353 #[test]
354 fn normalizes_api_base_url_and_enforces_https_by_default() {
355 let _guard = env_lock().lock().expect("env lock");
356 clear_auth_env();
357 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
358 let cfg = load_config().expect("config");
359 assert_eq!(cfg.base_url, "https://romm.example");
361 }
362
363 #[test]
364 fn does_not_enforce_https_if_toggle_is_false() {
365 let _guard = env_lock().lock().expect("env lock");
366 clear_auth_env();
367 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
368 std::env::set_var("API_USE_HTTPS", "false");
369 let cfg = load_config().expect("config");
370 assert_eq!(cfg.base_url, "http://romm.example");
371 }
372
373 #[test]
374 fn normalize_romm_origin_trims_and_strips_api_suffix() {
375 assert_eq!(
376 normalize_romm_origin("http://localhost:8080/api/"),
377 "http://localhost:8080"
378 );
379 assert_eq!(
380 normalize_romm_origin("https://x.example"),
381 "https://x.example"
382 );
383 }
384
385 #[test]
386 fn empty_api_username_does_not_enable_basic() {
387 let _guard = env_lock().lock().expect("env lock");
388 clear_auth_env();
389 std::env::set_var("API_BASE_URL", "http://example.test");
390 std::env::set_var("API_USERNAME", "");
391 std::env::set_var("API_PASSWORD", "secret");
392
393 let cfg = load_config().expect("config should load");
394 assert!(
395 cfg.auth.is_none(),
396 "empty API_USERNAME should not pair with password for Basic"
397 );
398 }
399
400 #[test]
401 fn ignores_placeholder_bearer_token() {
402 let _guard = env_lock().lock().expect("env lock");
403 clear_auth_env();
404 std::env::set_var("API_BASE_URL", "http://example.test");
405 std::env::set_var("API_TOKEN", "your-bearer-token-here");
406
407 let cfg = load_config().expect("config should load");
408 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
409 }
410
411 #[test]
412 fn layered_env_applies_user_file_for_unset_keys() {
413 let _guard = env_lock().lock().expect("env lock");
414 clear_auth_env();
415 std::env::remove_var("API_BASE_URL");
416
417 let ts = std::time::SystemTime::now()
418 .duration_since(std::time::UNIX_EPOCH)
419 .unwrap()
420 .as_nanos();
421 let base = std::env::temp_dir().join(format!("romm-layered-{ts}"));
422 std::fs::create_dir_all(&base).unwrap();
423 let work = base.join("work");
424 std::fs::create_dir_all(&work).unwrap();
425 std::fs::write(
426 base.join(".env"),
427 "API_BASE_URL=http://from-user-file.test\n",
428 )
429 .unwrap();
430
431 std::env::set_var("ROMM_TEST_CONFIG_DIR", base.as_os_str());
432 let old_cwd = std::env::current_dir().unwrap();
433 std::env::set_current_dir(&work).unwrap();
434
435 load_layered_env();
436 std::env::set_var("API_USE_HTTPS", "false");
438 let cfg = load_config().expect("load from user .env");
439 assert_eq!(cfg.base_url, "http://from-user-file.test");
440
441 std::env::set_current_dir(old_cwd).unwrap();
442 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
443 std::env::remove_var("API_BASE_URL");
444 let _ = std::fs::remove_dir_all(&base);
445 }
446}