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