1use reqwest::header::HeaderMap;
2use reqwest::header::HeaderValue;
3use secrecy::ExposeSecret;
4use secrecy::SecretString;
5
6pub const EXA_DEFAULT_BASE: &str = "https://api.exa.ai";
8pub const HDR_X_API_KEY: &str = "x-api-key";
10
11#[derive(Clone, Debug)]
15pub struct ExaConfig {
16 api_base: String,
17 api_key: Option<SecretString>,
18}
19
20impl Default for ExaConfig {
21 fn default() -> Self {
22 let api_key = std::env::var("EXA_API_KEY")
23 .ok()
24 .map(|v| v.trim().to_string())
25 .filter(|v| !v.is_empty())
26 .map(SecretString::from);
27
28 let api_base = std::env::var("EXA_BASE_URL")
29 .ok()
30 .map(|v| v.trim().to_string())
31 .filter(|v| !v.is_empty())
32 .unwrap_or_else(|| EXA_DEFAULT_BASE.into());
33
34 Self { api_base, api_key }
35 }
36}
37
38impl ExaConfig {
39 #[must_use]
45 pub fn new() -> Self {
46 Self::default()
47 }
48
49 #[must_use]
51 pub fn with_api_base(mut self, base: impl Into<String>) -> Self {
52 self.api_base = base.into();
53 self
54 }
55
56 #[must_use]
58 pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
59 self.api_key = Some(SecretString::from(key.into()));
60 self
61 }
62
63 #[must_use]
65 pub fn api_base(&self) -> &str {
66 &self.api_base
67 }
68}
69
70pub trait Config: Send + Sync {
74 fn headers(&self) -> Result<HeaderMap, crate::error::ExaError>;
80
81 fn url(&self, path: &str) -> String;
83
84 fn query(&self) -> Vec<(&str, &str)>;
86
87 fn validate_auth(&self) -> Result<(), crate::error::ExaError>;
93}
94
95impl Config for ExaConfig {
96 fn headers(&self) -> Result<HeaderMap, crate::error::ExaError> {
97 use crate::error::ExaError;
98
99 let mut h = HeaderMap::new();
100
101 if let Some(secret) = &self.api_key {
102 let key = secret.expose_secret().trim();
103 if !key.is_empty() {
104 h.insert(
105 HDR_X_API_KEY,
106 HeaderValue::from_str(key)
107 .map_err(|_| ExaError::Config("Invalid x-api-key value".into()))?,
108 );
109 }
110 }
111
112 Ok(h)
113 }
114
115 fn url(&self, path: &str) -> String {
116 let base = self.api_base.trim_end_matches('/');
117 let path = path.trim_start_matches('/');
118 format!("{base}/{path}")
119 }
120
121 fn query(&self) -> Vec<(&str, &str)> {
122 vec![]
123 }
124
125 fn validate_auth(&self) -> Result<(), crate::error::ExaError> {
126 match &self.api_key {
127 Some(secret) if !secret.expose_secret().trim().is_empty() => Ok(()),
128 _ => Err(crate::error::ExaError::Config(
129 "Missing Exa credentials: set EXA_API_KEY environment variable".into(),
130 )),
131 }
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use crate::test_support::EnvGuard;
139 use serial_test::serial;
140
141 #[test]
142 #[serial(env)]
143 fn config_reads_env_vars() {
144 let _key = EnvGuard::set("EXA_API_KEY", "test-key-123");
145 let _base = EnvGuard::set("EXA_BASE_URL", "https://custom.exa.ai");
146
147 let cfg = ExaConfig::new();
148 assert_eq!(cfg.api_base(), "https://custom.exa.ai");
149
150 let h = cfg.headers().unwrap();
151 assert_eq!(
152 h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(),
153 "test-key-123"
154 );
155 }
156
157 #[test]
158 #[serial(env)]
159 fn config_defaults_base_url() {
160 let _key = EnvGuard::set("EXA_API_KEY", "k");
161 let _base = EnvGuard::remove("EXA_BASE_URL");
162
163 let cfg = ExaConfig::new();
164 assert_eq!(cfg.api_base(), EXA_DEFAULT_BASE);
165 }
166
167 #[test]
168 #[serial(env)]
169 fn validate_auth_missing_key() {
170 let _key = EnvGuard::remove("EXA_API_KEY");
171
172 let cfg = ExaConfig::new();
173 assert!(cfg.validate_auth().is_err());
174 }
175
176 #[test]
177 #[serial(env)]
178 fn validate_auth_with_key() {
179 let _key = EnvGuard::set("EXA_API_KEY", "test-key");
180
181 let cfg = ExaConfig::new();
182 assert!(cfg.validate_auth().is_ok());
183 }
184
185 #[test]
186 fn builder_methods() {
187 let cfg = ExaConfig::new()
188 .with_api_base("https://test.exa.ai")
189 .with_api_key("my-key");
190
191 assert_eq!(cfg.api_base(), "https://test.exa.ai");
192 assert!(cfg.validate_auth().is_ok());
193
194 let h = cfg.headers().unwrap();
195 assert_eq!(h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(), "my-key");
196 }
197
198 #[test]
199 fn debug_output_redacts_api_key() {
200 let cfg = ExaConfig::new().with_api_key("super-secret-key-12345");
201 let debug_str = format!("{cfg:?}");
202
203 assert!(
204 !debug_str.contains("super-secret-key-12345"),
205 "Debug output should not contain the API key"
206 );
207 assert!(
209 debug_str.contains("[REDACTED]"),
210 "Debug output should contain '[REDACTED]', got: {debug_str}"
211 );
212 }
213
214 #[test]
215 fn validate_auth_rejects_empty_or_whitespace() {
216 let cfg = ExaConfig::new().with_api_key("");
218 assert!(cfg.validate_auth().is_err());
219
220 let cfg = ExaConfig::new().with_api_key(" ");
222 assert!(cfg.validate_auth().is_err());
223
224 let cfg = ExaConfig::new().with_api_key("\n");
226 assert!(cfg.validate_auth().is_err());
227
228 let cfg = ExaConfig::new().with_api_key(" valid-key ");
230 assert!(cfg.validate_auth().is_ok());
231 }
232
233 #[test]
234 #[serial(env)]
235 fn config_trims_whitespace_padded_env_key() {
236 let _key = EnvGuard::set("EXA_API_KEY", " trimmed-key \n");
237 let _base = EnvGuard::remove("EXA_BASE_URL");
238
239 let cfg = ExaConfig::new();
240 assert!(cfg.validate_auth().is_ok());
241
242 let h = cfg.headers().unwrap();
243 assert_eq!(
244 h.get(HDR_X_API_KEY).unwrap().to_str().unwrap(),
245 "trimmed-key",
246 "Headers should contain the trimmed key"
247 );
248 }
249
250 #[test]
251 #[serial(env)]
252 fn config_rejects_whitespace_only_env_key() {
253 let _key = EnvGuard::set("EXA_API_KEY", " ");
254 let _base = EnvGuard::remove("EXA_BASE_URL");
255
256 let cfg = ExaConfig::new();
257 assert!(cfg.validate_auth().is_err());
258 }
259}