typesafe_system_one/
config.rs1use std::fmt;
8use std::time::Duration;
9
10use crate::errors::Error;
11
12pub(crate) const API_KEY_ENV: &str = "TYPESAFE_API_KEY";
14pub(crate) const BASE_URL_ENV: &str = "TYPESAFE_BASE_URL";
16pub(crate) const DEFAULT_MODEL_ENV: &str = "TYPESAFE_DEFAULT_MODEL";
18pub(crate) const LOG_LEVEL_ENV: &str = "TYPESAFE_LOG_LEVEL";
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
28pub enum LogLevel {
29 Debug,
31 Info,
33 Warn,
35 Error,
37 #[default]
39 Off,
40}
41
42impl LogLevel {
43 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 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
80pub(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
88fn 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
96pub(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
132pub(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
139pub(crate) fn resolve_default_model(default_model: Option<&str>) -> String {
141 resolve_string(default_model, DEFAULT_MODEL_ENV, crate::DEFAULT_MODEL)
142}
143
144pub(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 #[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 let err = resolve_api_key(None).unwrap_err();
185 assert!(matches!(err, Error::Config(_)));
186 assert!(err.to_string().contains("No API key"));
187 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 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}