Skip to main content

romm_cli/
config.rs

1//! Configuration and authentication for the ROMM client.
2//!
3//! This module is deliberately independent of any particular frontend:
4//! both the TUI and the command-line subcommands share the same `Config`
5//! and `AuthConfig` types.
6//!
7//! ## Environment file precedence
8//!
9//! Call [`load_layered_env`] before reading config:
10//!
11//! 1. Variables already set in the process environment (highest priority).
12//! 2. Project `.env` in the current working directory (via `dotenvy`).
13//! 3. User config: `{config_dir}/romm-cli/.env` — fills keys not already set (so a repo `.env` wins over user defaults).
14//! 4. OS keyring — secrets stored by `romm-cli init` (lowest priority fallback).
15
16use std::path::PathBuf;
17
18use anyhow::{anyhow, Result};
19
20// ---------------------------------------------------------------------------
21// Types
22// ---------------------------------------------------------------------------
23
24#[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
41// ---------------------------------------------------------------------------
42// Keyring helpers
43// ---------------------------------------------------------------------------
44
45const KEYRING_SERVICE: &str = "romm-cli";
46
47/// Store a secret in the OS keyring under the `romm-cli` service name.
48pub 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
56/// Retrieve a secret from the OS keyring, returning `None` if not found.
57fn keyring_get(key: &str) -> Option<String> {
58    let entry = keyring::Entry::new(KEYRING_SERVICE, key).ok()?;
59    entry.get_password().ok()
60}
61
62// ---------------------------------------------------------------------------
63// Paths
64// ---------------------------------------------------------------------------
65
66/// Directory for user-level config (`romm-cli` under the OS config dir).
67pub 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
75/// Path to the user-level `.env` file (`.../romm-cli/.env`).
76pub fn user_config_env_path() -> Option<PathBuf> {
77    user_config_dir().map(|d| d.join(".env"))
78}
79
80// ---------------------------------------------------------------------------
81// Loading
82// ---------------------------------------------------------------------------
83
84/// Load env vars from `./.env` in cwd, then from the user config file.
85/// Later files only set variables not already set (env or earlier file), so a project `.env` overrides the same keys in the user file.
86pub 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
95/// Read an env var, falling back to the OS keyring if not set.
96fn 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        // Priority 1: Basic auth
115        Some(AuthConfig::Basic {
116            username: user,
117            password: pass,
118        })
119    } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
120        // Priority 2: API key in custom header (when both set, prefer over bearer)
121        if !is_placeholder(&key) {
122            Some(AuthConfig::ApiKey { header, key })
123        } else {
124            None
125        }
126    } else if let Some(tok) = token {
127        // Priority 3: Bearer token (skip placeholders)
128        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}