agent_framework_core/
settings.rs1use std::collections::HashMap;
31use std::fmt;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35
36const MASK: &str = "***";
39
40#[derive(Clone, Serialize, Deserialize)]
56#[serde(transparent)]
57pub struct SecretString(String);
58
59impl SecretString {
60 pub fn new(value: impl Into<String>) -> Self {
62 Self(value.into())
63 }
64
65 pub fn expose_secret(&self) -> &str {
71 &self.0
72 }
73}
74
75impl From<String> for SecretString {
76 fn from(value: String) -> Self {
77 Self(value)
78 }
79}
80
81impl From<&str> for SecretString {
82 fn from(value: &str) -> Self {
83 Self(value.to_string())
84 }
85}
86
87impl PartialEq for SecretString {
88 fn eq(&self, other: &Self) -> bool {
89 self.0 == other.0
90 }
91}
92
93impl Eq for SecretString {}
94
95impl fmt::Debug for SecretString {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(f, "SecretString({MASK:?})")
98 }
99}
100
101impl fmt::Display for SecretString {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(f, "{MASK}")
104 }
105}
106
107fn parse_dotenv(contents: &str) -> HashMap<String, String> {
115 let mut map = HashMap::new();
116 for line in contents.lines() {
117 let line = line.trim();
118 if line.is_empty() || line.starts_with('#') {
119 continue;
120 }
121 let line = line.strip_prefix("export ").unwrap_or(line);
122 let Some((key, value)) = line.split_once('=') else {
123 continue;
124 };
125 let key = key.trim();
126 if key.is_empty() {
127 continue;
128 }
129 let mut value = value.trim();
130 if !(value.starts_with('"') || value.starts_with('\'')) {
132 if let Some(idx) = value.find(" #") {
133 value = value[..idx].trim();
134 }
135 }
136 let value = if (value.starts_with('"') && value.ends_with('"') && value.len() >= 2)
137 || (value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2)
138 {
139 &value[1..value.len() - 1]
140 } else {
141 value
142 };
143 map.insert(key.to_string(), value.to_string());
144 }
145 map
146}
147
148fn load_dotenv_file(path: &Path) -> HashMap<String, String> {
152 std::fs::read_to_string(path)
153 .map(|contents| parse_dotenv(&contents))
154 .unwrap_or_default()
155}
156
157pub fn load_setting(
173 key: &str,
174 override_value: Option<String>,
175 default: Option<String>,
176) -> Option<String> {
177 if let Some(value) = override_value {
178 return Some(value);
179 }
180
181 let dotenv = load_dotenv_file(Path::new(".env"));
182 if let Some(value) = dotenv.get(key) {
183 return Some(value.clone());
184 }
185
186 if let Ok(value) = std::env::var(key) {
187 return Some(value);
188 }
189
190 default
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::sync::Mutex;
197
198 static ENV_LOCK: Mutex<()> = Mutex::new(());
201
202 #[test]
203 fn secret_string_masks_debug_and_display() {
204 let secret = SecretString::new("sk-super-secret");
205 assert_eq!(format!("{secret:?}"), "SecretString(\"***\")");
206 assert_eq!(format!("{secret}"), "***");
207 assert!(!format!("{secret:?}").contains("sk-super-secret"));
209 assert!(!format!("{secret}").contains("sk-super-secret"));
210 }
211
212 #[test]
213 fn secret_string_expose_secret_returns_real_value() {
214 let secret = SecretString::new("sk-super-secret");
215 assert_eq!(secret.expose_secret(), "sk-super-secret");
216 }
217
218 #[test]
219 fn secret_string_from_conversions() {
220 let a: SecretString = "abc".into();
221 let b: SecretString = String::from("abc").into();
222 assert_eq!(a, b);
223 assert_eq!(a.expose_secret(), "abc");
224 }
225
226 #[test]
227 fn secret_string_equality_compares_real_values() {
228 let a = SecretString::new("same");
229 let b = SecretString::new("same");
230 let c = SecretString::new("different");
231 assert_eq!(a, b);
232 assert_ne!(a, c);
233 }
234
235 #[test]
236 fn secret_string_clone_preserves_value() {
237 let a = SecretString::new("clone-me");
238 let b = a.clone();
239 assert_eq!(a, b);
240 assert_eq!(b.expose_secret(), "clone-me");
241 }
242
243 #[test]
244 fn secret_string_serde_round_trip_preserves_real_value() {
245 let secret = SecretString::new("sk-super-secret");
246 let json = serde_json::to_string(&secret).expect("serialize");
247 assert_eq!(json, "\"sk-super-secret\"");
250 let round_tripped: SecretString = serde_json::from_str(&json).expect("deserialize");
251 assert_eq!(round_tripped, secret);
252 assert_eq!(round_tripped.expose_secret(), "sk-super-secret");
253 }
254
255 #[test]
256 fn parse_dotenv_handles_comments_blanks_and_quotes() {
257 let contents = r#"
258# a comment
259FOO=bar
260
261export BAZ=qux
262QUOTED="hello world"
263SINGLE='single quoted'
264INLINE=value # trailing comment
265"#;
266 let map = parse_dotenv(contents);
267 assert_eq!(map.get("FOO").map(String::as_str), Some("bar"));
268 assert_eq!(map.get("BAZ").map(String::as_str), Some("qux"));
269 assert_eq!(map.get("QUOTED").map(String::as_str), Some("hello world"));
270 assert_eq!(map.get("SINGLE").map(String::as_str), Some("single quoted"));
271 assert_eq!(map.get("INLINE").map(String::as_str), Some("value"));
272 }
273
274 #[test]
275 fn load_setting_override_wins_over_everything() {
276 let _guard = ENV_LOCK.lock().unwrap();
277 let key = "AF_SETTINGS_TEST_OVERRIDE_WINS";
278 std::env::set_var(key, "from-env");
279
280 let result = load_setting(
281 key,
282 Some("from-override".to_string()),
283 Some("from-default".to_string()),
284 );
285
286 std::env::remove_var(key);
287 assert_eq!(result, Some("from-override".to_string()));
288 }
289
290 #[test]
291 fn load_setting_env_wins_over_default() {
292 let _guard = ENV_LOCK.lock().unwrap();
293 let key = "AF_SETTINGS_TEST_ENV_WINS";
294 std::env::set_var(key, "from-env");
295
296 let result = load_setting(key, None, Some("from-default".to_string()));
297
298 std::env::remove_var(key);
299 assert_eq!(result, Some("from-env".to_string()));
300 }
301
302 #[test]
303 fn load_setting_falls_back_to_default_when_nothing_else_set() {
304 let _guard = ENV_LOCK.lock().unwrap();
305 let key = "AF_SETTINGS_TEST_DEFAULT_FALLBACK";
306 std::env::remove_var(key); let result = load_setting(key, None, Some("from-default".to_string()));
309 assert_eq!(result, Some("from-default".to_string()));
310 }
311
312 #[test]
313 fn load_setting_returns_none_when_nothing_resolves() {
314 let _guard = ENV_LOCK.lock().unwrap();
315 let key = "AF_SETTINGS_TEST_NOTHING_RESOLVES";
316 std::env::remove_var(key); let result = load_setting(key, None, None);
319 assert_eq!(result, None);
320 }
321
322 #[test]
323 fn load_setting_dotenv_file_beats_env_but_not_override() {
324 let _guard = ENV_LOCK.lock().unwrap();
325 let dir = std::env::temp_dir().join(format!(
326 "af-settings-test-{}-{}",
327 std::process::id(),
328 std::time::SystemTime::now()
329 .duration_since(std::time::UNIX_EPOCH)
330 .unwrap()
331 .as_nanos()
332 ));
333 std::fs::create_dir_all(&dir).unwrap();
334 let dotenv_path = dir.join(".env");
335 std::fs::write(&dotenv_path, "AF_SETTINGS_TEST_DOTENV=from-dotenv\n").unwrap();
336
337 let key = "AF_SETTINGS_TEST_DOTENV";
338 std::env::set_var(key, "from-env");
339
340 let original_cwd = std::env::current_dir().unwrap();
341 std::env::set_current_dir(&dir).unwrap();
342
343 let dotenv_result = load_setting(key, None, None);
344 let override_result = load_setting(key, Some("from-override".to_string()), None);
345
346 std::env::set_current_dir(&original_cwd).unwrap();
347 std::env::remove_var(key);
348 let _ = std::fs::remove_dir_all(&dir);
349
350 assert_eq!(dotenv_result, Some("from-dotenv".to_string()));
351 assert_eq!(override_result, Some("from-override".to_string()));
352 }
353}