1use std::path::PathBuf;
17
18use anyhow::{anyhow, 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
41const KEYRING_SERVICE: &str = "romm-cli";
46
47pub fn keyring_store(key: &str, value: &str) -> Result<()> {
49 let entry = keyring::Entry::new(KEYRING_SERVICE, key)
50 .map_err(|e| anyhow!("keyring entry error: {e}"))?;
51 entry
52 .set_password(value)
53 .map_err(|e| anyhow!("keyring set error: {e}"))
54}
55
56fn keyring_get(key: &str) -> Option<String> {
58 let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
59 entry.get_password().ok()
60}
61
62pub fn user_config_dir() -> Option<PathBuf> {
68 #[cfg(test)]
69 if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
70 return Some(PathBuf::from(dir));
71 }
72 dirs::config_dir().map(|d| d.join("romm-cli"))
73}
74
75pub fn user_config_env_path() -> Option<PathBuf> {
77 user_config_dir().map(|d| d.join(".env"))
78}
79
80pub fn load_layered_env() {
87 let _ = dotenvy::dotenv();
88 if let Some(path) = user_config_env_path() {
89 if path.is_file() {
90 let _ = dotenvy::from_path(path);
91 }
92 }
93}
94
95fn env_or_keyring(key: &str) -> Option<String> {
97 std::env::var(key).ok().or_else(|| keyring_get(key))
98}
99
100pub fn load_config() -> Result<Config> {
101 let base_url = std::env::var("API_BASE_URL").map_err(|_| {
102 anyhow!(
103 "API_BASE_URL is not set. Set it in the environment, a .env file, or run: romm-cli init"
104 )
105 })?;
106
107 let username = std::env::var("API_USERNAME").ok();
108 let password = env_or_keyring("API_PASSWORD");
109 let token = env_or_keyring("API_TOKEN").or_else(|| env_or_keyring("API_KEY"));
110 let api_key = env_or_keyring("API_KEY");
111 let api_key_header = std::env::var("API_KEY_HEADER").ok();
112
113 let auth = if let (Some(user), Some(pass)) = (username, password) {
114 Some(AuthConfig::Basic {
116 username: user,
117 password: pass,
118 })
119 } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
120 if !is_placeholder(&key) {
122 Some(AuthConfig::ApiKey { header, key })
123 } else {
124 None
125 }
126 } else if let Some(tok) = token {
127 if !is_placeholder(&tok) {
129 Some(AuthConfig::Bearer { token: tok })
130 } else {
131 None
132 }
133 } else {
134 None
135 };
136
137 Ok(Config { base_url, auth })
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::sync::{Mutex, OnceLock};
144
145 fn env_lock() -> &'static Mutex<()> {
146 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
147 LOCK.get_or_init(|| Mutex::new(()))
148 }
149
150 fn clear_auth_env() {
151 for key in [
152 "API_BASE_URL",
153 "API_USERNAME",
154 "API_PASSWORD",
155 "API_TOKEN",
156 "API_KEY",
157 "API_KEY_HEADER",
158 ] {
159 std::env::remove_var(key);
160 }
161 }
162
163 #[test]
164 fn prefers_basic_auth_over_other_modes() {
165 let _guard = env_lock().lock().expect("env lock");
166 clear_auth_env();
167 std::env::set_var("API_BASE_URL", "http://example.test");
168 std::env::set_var("API_USERNAME", "user");
169 std::env::set_var("API_PASSWORD", "pass");
170 std::env::set_var("API_TOKEN", "token");
171 std::env::set_var("API_KEY", "apikey");
172 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
173
174 let cfg = load_config().expect("config should load");
175 match cfg.auth {
176 Some(AuthConfig::Basic { username, password }) => {
177 assert_eq!(username, "user");
178 assert_eq!(password, "pass");
179 }
180 _ => panic!("expected basic auth"),
181 }
182 }
183
184 #[test]
185 fn uses_api_key_header_when_token_missing() {
186 let _guard = env_lock().lock().expect("env lock");
187 clear_auth_env();
188 std::env::set_var("API_BASE_URL", "http://example.test");
189 std::env::set_var("API_KEY", "real-key");
190 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
191
192 let cfg = load_config().expect("config should load");
193 match cfg.auth {
194 Some(AuthConfig::ApiKey { header, key }) => {
195 assert_eq!(header, "X-Api-Key");
196 assert_eq!(key, "real-key");
197 }
198 _ => panic!("expected api key auth"),
199 }
200 }
201
202 #[test]
203 fn ignores_placeholder_bearer_token() {
204 let _guard = env_lock().lock().expect("env lock");
205 clear_auth_env();
206 std::env::set_var("API_BASE_URL", "http://example.test");
207 std::env::set_var("API_TOKEN", "your-bearer-token-here");
208
209 let cfg = load_config().expect("config should load");
210 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
211 }
212
213 #[test]
214 fn layered_env_applies_user_file_for_unset_keys() {
215 let _guard = env_lock().lock().expect("env lock");
216 clear_auth_env();
217 std::env::remove_var("API_BASE_URL");
218
219 let ts = std::time::SystemTime::now()
220 .duration_since(std::time::UNIX_EPOCH)
221 .unwrap()
222 .as_nanos();
223 let base = std::env::temp_dir().join(format!("romm-layered-{ts}"));
224 std::fs::create_dir_all(&base).unwrap();
225 let work = base.join("work");
226 std::fs::create_dir_all(&work).unwrap();
227 std::fs::write(
228 base.join(".env"),
229 "API_BASE_URL=http://from-user-file.test\n",
230 )
231 .unwrap();
232
233 std::env::set_var("ROMM_TEST_CONFIG_DIR", base.as_os_str());
234 let old_cwd = std::env::current_dir().unwrap();
235 std::env::set_current_dir(&work).unwrap();
236
237 load_layered_env();
238 let cfg = load_config().expect("load from user .env");
239 assert_eq!(cfg.base_url, "http://from-user-file.test");
240
241 std::env::set_current_dir(old_cwd).unwrap();
242 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
243 std::env::remove_var("API_BASE_URL");
244 let _ = std::fs::remove_dir_all(&base);
245 }
246}