Skip to main content

typesafe_system_one/
config.rs

1//! Configuration resolution: environment variables, defaults, and validation.
2//!
3//! Explicit options override environment variables, and environment
4//! variables override defaults. Environment values are trimmed, and an empty
5//! or whitespace-only value is ignored.
6
7use std::fmt;
8use std::time::Duration;
9
10use crate::errors::Error;
11
12/// The environment variable for the API key.
13pub(crate) const API_KEY_ENV: &str = "TYPESAFE_API_KEY";
14/// The environment variable for the base URL.
15pub(crate) const BASE_URL_ENV: &str = "TYPESAFE_BASE_URL";
16/// The environment variable for the default model.
17pub(crate) const DEFAULT_MODEL_ENV: &str = "TYPESAFE_DEFAULT_MODEL";
18/// The environment variable for the log level.
19pub(crate) const LOG_LEVEL_ENV: &str = "TYPESAFE_LOG_LEVEL";
20
21/// The logging verbosity of the client.
22///
23/// Logging is off unless configured. At [`LogLevel::Info`], the client logs
24/// one line per attempt result and per scheduled retry. At
25/// [`LogLevel::Debug`], it also logs headers and bodies. Credential headers
26/// are redacted; bodies are not.
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
28pub enum LogLevel {
29    /// Request/response headers and bodies, attempt lines, and retries.
30    Debug,
31    /// One line per attempt result and per scheduled retry.
32    Info,
33    /// Only warnings and errors.
34    Warn,
35    /// Only errors.
36    Error,
37    /// No logging. The default.
38    #[default]
39    Off,
40}
41
42impl LogLevel {
43    /// Parses a log level name, case-insensitively, ignoring surrounding
44    /// whitespace. `warning` is accepted as an alias for `warn`.
45    pub fn parse(name: &str) -> Option<Self> {
46        match name.trim().to_ascii_lowercase().as_str() {
47            "debug" => Some(Self::Debug),
48            "info" => Some(Self::Info),
49            "warn" | "warning" => Some(Self::Warn),
50            "error" => Some(Self::Error),
51            "off" => Some(Self::Off),
52            _ => None,
53        }
54    }
55
56    /// Resolves the level from an explicit setting or `TYPESAFE_LOG_LEVEL`.
57    pub(crate) fn resolve(explicit: Option<Self>) -> Self {
58        if let Some(level) = explicit {
59            return level;
60        }
61        env_trimmed(LOG_LEVEL_ENV)
62            .and_then(|value| Self::parse(&value))
63            .unwrap_or_default()
64    }
65}
66
67impl fmt::Display for LogLevel {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        let name = match self {
70            Self::Debug => "debug",
71            Self::Info => "info",
72            Self::Warn => "warn",
73            Self::Error => "error",
74            Self::Off => "off",
75        };
76        f.write_str(name)
77    }
78}
79
80/// Reads an environment variable, trimmed; `None` when unset or blank.
81pub(crate) fn env_trimmed(name: &str) -> Option<String> {
82    std::env::var(name)
83        .ok()
84        .map(|value| value.trim().to_owned())
85        .filter(|value| !value.is_empty())
86}
87
88/// Resolves a setting: explicit value, else environment, else `default`.
89fn resolve_string(value: Option<&str>, env: &str, default: &str) -> String {
90    if let Some(value) = value {
91        return value.trim().to_owned();
92    }
93    env_trimmed(env).unwrap_or_else(|| default.to_owned())
94}
95
96/// Resolves and validates the API key: explicit value or environment, trimmed.
97///
98/// Rejects empty keys and keys containing whitespace, control characters, or
99/// non-ASCII characters. The error never echoes the key.
100pub(crate) fn resolve_api_key(api_key: Option<&str>) -> Result<String, Error> {
101    let key = api_key
102        .map(str::trim)
103        .map(str::to_owned)
104        .or_else(|| env_trimmed(API_KEY_ENV))
105        .unwrap_or_default();
106    if key.is_empty() {
107        return Err(Error::Config(format!(
108            "No API key was provided. Pass api_key or set the {API_KEY_ENV} environment variable."
109        )));
110    }
111    if !key.is_ascii() {
112        return Err(Error::Config(
113            "API key must contain only printable ASCII characters without whitespace.".into(),
114        ));
115    }
116    if key.chars().any(|c| c.is_whitespace() || c.is_control()) {
117        return Err(Error::Config(
118            "API key must contain only printable ASCII characters without whitespace.".into(),
119        ));
120    }
121    if !key.chars().all(|c| {
122        let c = c as u8;
123        (0x21..=0x7e).contains(&c)
124    }) {
125        return Err(Error::Config(
126            "API key must contain only printable ASCII characters without whitespace.".into(),
127        ));
128    }
129    Ok(key)
130}
131
132/// Resolves the base URL, stripping a trailing `/`.
133pub(crate) fn resolve_base_url(base_url: Option<&str>) -> String {
134    resolve_string(base_url, BASE_URL_ENV, crate::DEFAULT_BASE_URL)
135        .trim_end_matches('/')
136        .to_owned()
137}
138
139/// Resolves the default model.
140pub(crate) fn resolve_default_model(default_model: Option<&str>) -> String {
141    resolve_string(default_model, DEFAULT_MODEL_ENV, crate::DEFAULT_MODEL)
142}
143
144/// Validates a per-attempt timeout: it must be greater than zero.
145pub(crate) fn validate_timeout(timeout: Duration) -> Result<Duration, Error> {
146    if timeout.is_zero() {
147        return Err(Error::Config(
148            "timeout must be a positive duration of time.".into(),
149        ));
150    }
151    Ok(timeout)
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    // Environment variables are process-global; these tests serialize on a
159    // shared lock. The parent `tests::env_lock` module holds it across all
160    // env-mutating tests (see tests/env.rs), and unit tests here use a local
161    // copy of the same discipline by only reading well-known-unset variables.
162    // Mutation-based tests live in tests/env.rs.
163
164    #[test]
165    fn parse_log_level() {
166        assert_eq!(LogLevel::parse("debug"), Some(LogLevel::Debug));
167        assert_eq!(LogLevel::parse("INFO"), Some(LogLevel::Info));
168        assert_eq!(LogLevel::parse(" warn "), Some(LogLevel::Warn));
169        assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warn));
170        assert_eq!(LogLevel::parse("error"), Some(LogLevel::Error));
171        assert_eq!(LogLevel::parse("off"), Some(LogLevel::Off));
172        assert_eq!(LogLevel::parse("nope"), None);
173        assert_eq!(LogLevel::parse(""), None);
174    }
175
176    #[test]
177    fn default_log_level_is_off() {
178        assert_eq!(LogLevel::default(), LogLevel::Off);
179    }
180
181    #[test]
182    fn api_key_validation() {
183        // Missing/empty.
184        let err = resolve_api_key(None).unwrap_err();
185        assert!(matches!(err, Error::Config(_)));
186        assert!(err.to_string().contains("No API key"));
187        // Whitespace inside.
188        for bad in ["key with space", "key\ttab", "key\nnewline", "ünïcödé"] {
189            let err = resolve_api_key(Some(bad)).unwrap_err();
190            assert!(matches!(err, Error::Config(_)));
191            assert!(!err.to_string().contains(bad), "leaked key: {err}");
192        }
193        // Trimmed valid key.
194        assert_eq!(
195            resolve_api_key(Some("  sk-live-abc123  ")).unwrap(),
196            "sk-live-abc123"
197        );
198    }
199
200    #[test]
201    fn timeout_validation() {
202        assert!(validate_timeout(Duration::from_millis(1)).is_ok());
203        let err = validate_timeout(Duration::ZERO).unwrap_err();
204        assert!(matches!(err, Error::Config(_)));
205    }
206
207    #[test]
208    fn base_url_strips_trailing_slash() {
209        assert_eq!(
210            resolve_base_url(Some("https://example.com/")),
211            "https://example.com"
212        );
213        assert_eq!(
214            resolve_base_url(Some("https://example.com//")),
215            "https://example.com"
216        );
217    }
218}