Skip to main content

agent_framework_core/
settings.rs

1//! Lightweight settings helpers: a secret-masking string newtype and a
2//! precedence-based value loader.
3//!
4//! This mirrors upstream's `_settings.py`, which replaced a
5//! `pydantic-settings`-based `AFBaseSettings` with a function-based loader
6//! (`load_settings`) plus a `repr`-masking `SecretString`. Rather than port
7//! the Python `TypedDict`-driven schema loader (which leans on runtime
8//! reflection that has no idiomatic Rust equivalent), this module provides
9//! the two reusable primitives:
10//!
11//! - [`SecretString`] — a `String` newtype whose [`Debug`]/[`Display`](std::fmt::Display) impls
12//!   mask the value so secrets never leak into logs, while still
13//!   (de)serializing to the real value and round-tripping through
14//!   `serde_json`.
15//! - [`load_setting`] — a single-value loader implementing the same
16//!   precedence as upstream's `load_settings`: explicit override, then a
17//!   `.env` file, then the process environment, then a default.
18//!
19//! ## Example
20//!
21//! ```
22//! use agent_framework_core::settings::SecretString;
23//!
24//! let key = SecretString::new("sk-super-secret");
25//! assert_eq!(format!("{key}"), "***");
26//! assert_eq!(format!("{key:?}"), "SecretString(\"***\")");
27//! assert_eq!(key.expose_secret(), "sk-super-secret");
28//! ```
29
30use std::collections::HashMap;
31use std::fmt;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35
36/// The literal used to mask a [`SecretString`]'s value in [`Debug`]/[`Display`]
37/// output.
38const MASK: &str = "***";
39
40/// A string wrapper that masks its value when printed via [`Debug`] or
41/// [`Display`](std::fmt::Display), to prevent secrets (API keys, tokens,
42/// passwords, ...) from
43/// accidentally ending up in logs or error messages.
44///
45/// The real value is still accessible via [`SecretString::expose_secret`],
46/// and is preserved (not masked) when (de)serialized with `serde`, since
47/// serialization is generally used to persist or transmit the value rather
48/// than to display it.
49///
50/// This is the Rust analogue of upstream's `SecretString(str)`, which masks
51/// its `repr()` but not its `str()`/`__format__` behavior. Rust has no
52/// implicit string-like coercions, so the equivalent masked-only surface is
53/// `Debug` and `Display`; call [`SecretString::expose_secret`] whenever the
54/// real value is needed (e.g. to authenticate a request).
55#[derive(Clone, Serialize, Deserialize)]
56#[serde(transparent)]
57pub struct SecretString(String);
58
59impl SecretString {
60    /// Wrap `value` as a [`SecretString`].
61    pub fn new(value: impl Into<String>) -> Self {
62        Self(value.into())
63    }
64
65    /// Return the real, unmasked value.
66    ///
67    /// Named to match upstream's `get_secret_value()` / the common Rust
68    /// `secrecy`-crate convention, and to make call sites grep-able for
69    /// audits of where secrets are actually exposed.
70    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
107/// Parse `.env`-style file contents into a key/value map.
108///
109/// Supports simple `KEY=VALUE` lines, blank lines, and `#`-prefixed
110/// comments. Surrounding single or double quotes on the value are stripped.
111/// This intentionally does not support the fuller dotenv syntax (multiline
112/// values, variable expansion, `export` prefixes, ...); it covers the common
113/// case without pulling in an extra dependency.
114fn 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        // Strip a trailing inline comment on unquoted values, e.g. `KEY=value # note`.
131        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
148/// Load the key/value pairs from a `.env` file at `path`, if it exists and is
149/// readable. Returns an empty map otherwise (missing/unreadable dotenv files
150/// are not an error — they simply contribute nothing to resolution).
151fn 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
157/// Resolve a single setting value using the same precedence as upstream's
158/// `load_settings`:
159///
160/// 1. `override_value` — an explicit value supplied by the caller (e.g. a
161///    constructor argument).
162/// 2. A `./.env` file in the current working directory, if present, looked
163///    up by `key`.
164/// 3. The `key` process environment variable.
165/// 4. `default`.
166///
167/// Returns `None` if none of the sources produced a value.
168///
169/// This is a dependency-free, single-key analogue of upstream's
170/// `TypedDict`-driven `load_settings()`; callers needing to resolve several
171/// related fields can call this once per field.
172pub 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    // `std::env::set_var`/`remove_var` mutate global process state, so tests
199    // that touch the environment must not run concurrently with each other.
200    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        // The mask must never contain the real value as a substring.
208        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        // The serialized form carries the real secret (serialization is not
248        // masking) — only Debug/Display mask.
249        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); // ensure absent
307
308        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); // ensure absent
317
318        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}