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