Skip to main content

reinhardt_conf/settings/
env_parser.rs

1//! Environment variable parsing utilities
2//!
3//! Provides parsers for various data types including database URLs,
4//! booleans, lists, and more.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use url::Url;
9/// Parse a boolean value from a string
10///
11/// Accepts: "true", "false", "1", "0", "yes", "no", "on", "off", "ok", "y", "n"
12///
13/// # Examples
14///
15/// ```
16/// use reinhardt_conf::settings::env_parser::parse_bool;
17///
18/// assert!(parse_bool("true").unwrap());
19/// assert!(parse_bool("1").unwrap());
20/// assert!(parse_bool("yes").unwrap());
21/// assert!(!parse_bool("false").unwrap());
22/// assert!(!parse_bool("0").unwrap());
23/// assert!(parse_bool("invalid").is_err());
24/// ```
25pub fn parse_bool(value: &str) -> Result<bool, String> {
26	let normalized = value.trim().to_lowercase();
27
28	match normalized.as_str() {
29		"true" | "1" | "yes" | "on" | "ok" | "y" => Ok(true),
30		"false" | "0" | "no" | "off" | "n" => Ok(false),
31		_ => Err(format!("Invalid boolean value: {}", value)),
32	}
33}
34/// Parse a comma-separated list
35///
36/// # Examples
37///
38/// ```
39/// use reinhardt_conf::settings::env_parser::parse_list;
40///
41/// let list = parse_list("apple,banana,cherry");
42/// assert_eq!(list, vec!["apple", "banana", "cherry"]);
43///
44/// let list_with_spaces = parse_list("foo, bar, baz");
45/// assert_eq!(list_with_spaces, vec!["foo", "bar", "baz"]);
46/// ```
47pub fn parse_list(value: &str) -> Vec<String> {
48	value
49		.split(',')
50		.map(|s| s.trim().to_string())
51		.filter(|s| !s.is_empty())
52		.collect()
53}
54/// Parse a dictionary-like string (key=value,key2=value2)
55///
56/// # Examples
57///
58/// ```
59/// use reinhardt_conf::settings::env_parser::parse_dict;
60///
61/// let dict = parse_dict("host=localhost,port=5432,user=admin");
62/// assert_eq!(dict.get("host").unwrap(), "localhost");
63/// assert_eq!(dict.get("port").unwrap(), "5432");
64/// assert_eq!(dict.get("user").unwrap(), "admin");
65/// ```
66pub fn parse_dict(value: &str) -> HashMap<String, String> {
67	let mut map = HashMap::new();
68
69	for pair in value.split(',') {
70		if let Some((key, val)) = pair.split_once('=') {
71			let key = key.trim().to_string();
72			let val = val.trim().to_string();
73
74			// Skip entries where key or value is empty
75			if !key.is_empty() && !val.is_empty() {
76				map.insert(key, val);
77			}
78		}
79	}
80
81	map
82}
83
84/// Database URL configuration parsed from a connection string
85#[derive(Clone, Serialize, Deserialize, PartialEq)]
86pub struct DatabaseUrl {
87	/// Database engine (postgresql, mysql, sqlite, etc.)
88	pub engine: String,
89
90	/// Database name
91	pub name: String,
92
93	/// Username (optional)
94	pub user: Option<String>,
95
96	/// Password (optional)
97	pub password: Option<String>,
98
99	/// Host (optional)
100	pub host: Option<String>,
101
102	/// Port (optional)
103	pub port: Option<u16>,
104
105	/// Query parameters as options
106	pub options: HashMap<String, String>,
107
108	/// Original URL string
109	pub url: String,
110}
111
112impl std::fmt::Debug for DatabaseUrl {
113	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114		f.debug_struct("DatabaseUrl")
115			.field("engine", &self.engine)
116			.field("name", &self.name)
117			.field("user", &self.user)
118			.field(
119				"password",
120				if self.password.is_some() {
121					&"Some([REDACTED])"
122				} else {
123					&"None"
124				},
125			)
126			.field("host", &self.host)
127			.field("port", &self.port)
128			.field("options", &self.options)
129			.field("url", &"[REDACTED]")
130			.finish()
131	}
132}
133
134/// Parse a database URL
135///
136/// Supports formats:
137/// - sqlite:///path/to/db.sqlite3
138/// - sqlite::memory:
139/// - postgresql://user:pass@host:port/dbname
140/// - mysql://user:pass@host:port/dbname
141///
142/// # Examples
143///
144/// ```
145/// use reinhardt_conf::settings::env_parser::parse_database_url;
146///
147/// let db = parse_database_url("sqlite::memory:").unwrap();
148/// assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
149/// assert_eq!(db.name, ":memory:");
150///
151/// let db = parse_database_url("postgresql://user:pass@localhost:5432/mydb").unwrap();
152/// assert_eq!(db.engine, "reinhardt.db.backends.postgresql");
153/// assert_eq!(db.name, "mydb");
154/// assert_eq!(db.user.unwrap(), "user");
155/// ```
156pub fn parse_database_url(url_str: &str) -> Result<DatabaseUrl, String> {
157	// Handle SQLite special cases
158	if url_str.starts_with("sqlite:") {
159		return parse_sqlite_url(url_str);
160	}
161
162	let url = Url::parse(url_str).map_err(|e| format!("Invalid URL: {}", e))?;
163
164	let scheme = url.scheme();
165	let engine = match scheme {
166		"postgresql" | "postgres" => "reinhardt.db.backends.postgresql",
167		"mysql" | "mariadb" => "reinhardt.db.backends.mysql",
168		"sqlite" => "reinhardt.db.backends.sqlite3",
169		other => return Err(format!("Unsupported database scheme: {}", other)),
170	};
171
172	let name = url.path().trim_start_matches('/').to_string();
173
174	if name.is_empty() && scheme != "sqlite" {
175		return Err("Database name is required".to_string());
176	}
177
178	let user = if url.username().is_empty() {
179		None
180	} else {
181		Some(url.username().to_string())
182	};
183
184	let password = url.password().map(|p| p.to_string());
185	let host = url.host_str().map(|h| h.to_string());
186	let port = url.port();
187
188	// Parse query parameters
189	let mut options = HashMap::new();
190	for (key, value) in url.query_pairs() {
191		options.insert(key.to_string(), value.to_string());
192	}
193
194	Ok(DatabaseUrl {
195		engine: engine.to_string(),
196		name,
197		user,
198		password,
199		host,
200		port,
201		options,
202		url: url_str.to_string(),
203	})
204}
205
206/// Parse SQLite URL
207fn parse_sqlite_url(url_str: &str) -> Result<DatabaseUrl, String> {
208	let name = if url_str == "sqlite::memory:" || url_str == "sqlite://:memory:" {
209		":memory:".to_string()
210	} else if url_str.starts_with("sqlite:///") {
211		url_str.trim_start_matches("sqlite:///").to_string()
212	} else if url_str.starts_with("sqlite://") {
213		url_str.trim_start_matches("sqlite://").to_string()
214	} else if url_str.starts_with("sqlite:") {
215		// Handle sqlite:db.sqlite3 format (single colon for relative paths)
216		url_str.trim_start_matches("sqlite:").to_string()
217	} else {
218		return Err("Invalid SQLite URL format".to_string());
219	};
220
221	Ok(DatabaseUrl {
222		engine: "reinhardt.db.backends.sqlite3".to_string(),
223		name,
224		user: None,
225		password: None,
226		host: None,
227		port: None,
228		options: HashMap::new(),
229		url: url_str.to_string(),
230	})
231}
232
233/// Cache URL configuration
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct CacheUrl {
236	/// Cache backend type derived from the URL scheme (e.g., `"redis"`, `"locmem"`).
237	pub backend: String,
238	/// Backend-specific connection location parsed from the URL.
239	pub location: Option<String>,
240	/// Additional configuration options from URL query parameters.
241	pub options: HashMap<String, String>,
242}
243/// Parse a cache URL
244///
245/// Supports:
246/// - locmem://
247/// - redis://host:port/db
248/// - memcached://host:port
249///
250/// # Examples
251///
252/// ```
253/// use reinhardt_conf::settings::env_parser::parse_cache_url;
254///
255/// let cache = parse_cache_url("locmem://").unwrap();
256/// assert_eq!(cache.backend, "reinhardt.cache.backends.locmem.LocMemCache");
257///
258/// let cache = parse_cache_url("redis://localhost:6379/0").unwrap();
259/// assert_eq!(cache.backend, "reinhardt.cache.backends.redis.RedisCache");
260/// assert!(cache.location.is_some());
261/// ```
262pub fn parse_cache_url(url_str: &str) -> Result<CacheUrl, String> {
263	if url_str == "locmem://" || url_str.starts_with("locmem://") {
264		return Ok(CacheUrl {
265			backend: "reinhardt.cache.backends.locmem.LocMemCache".to_string(),
266			location: None,
267			options: HashMap::new(),
268		});
269	}
270
271	let url = Url::parse(url_str).map_err(|e| format!("Invalid cache URL: {}", e))?;
272
273	let (backend, location) = match url.scheme() {
274		"redis" => (
275			"reinhardt.cache.backends.redis.RedisCache",
276			Some(url_str.to_string()),
277		),
278		"memcached" => (
279			"reinhardt.cache.backends.memcached.PyMemcacheCache",
280			Some(format!(
281				"{}:{}",
282				url.host_str().unwrap_or("localhost"),
283				url.port().unwrap_or(11211)
284			)),
285		),
286		other => return Err(format!("Unsupported cache scheme: {}", other)),
287	};
288
289	let mut options = HashMap::new();
290	for (key, value) in url.query_pairs() {
291		options.insert(key.to_string(), value.to_string());
292	}
293
294	Ok(CacheUrl {
295		backend: backend.to_string(),
296		location,
297		options,
298	})
299}
300
301#[cfg(test)]
302mod tests {
303	use super::*;
304
305	#[test]
306	fn test_parse_bool() {
307		assert!(parse_bool("true").unwrap());
308		assert!(parse_bool("True").unwrap());
309		assert!(parse_bool("1").unwrap());
310		assert!(parse_bool("yes").unwrap());
311		assert!(parse_bool("on").unwrap());
312
313		assert!(!parse_bool("false").unwrap());
314		assert!(!parse_bool("False").unwrap());
315		assert!(!parse_bool("0").unwrap());
316		assert!(!parse_bool("no").unwrap());
317		assert!(!parse_bool("off").unwrap());
318
319		assert!(parse_bool("invalid").is_err());
320	}
321
322	#[test]
323	fn test_parse_list() {
324		assert_eq!(parse_list("a,b,c"), vec!["a", "b", "c"]);
325		assert_eq!(parse_list("a, b, c"), vec!["a", "b", "c"]);
326		assert_eq!(parse_list(""), Vec::<String>::new());
327		assert_eq!(parse_list("single"), vec!["single"]);
328	}
329
330	#[test]
331	fn test_parse_dict() {
332		let dict = parse_dict("key1=value1,key2=value2");
333		assert_eq!(dict.get("key1").unwrap(), "value1");
334		assert_eq!(dict.get("key2").unwrap(), "value2");
335	}
336
337	#[test]
338	fn test_parse_dict_skips_empty_key_or_value() {
339		// Empty key ("=value") should be skipped
340		let dict = parse_dict("=value");
341		assert!(dict.is_empty());
342
343		// Empty value ("key=") should be skipped
344		let dict = parse_dict("key=");
345		assert!(dict.is_empty());
346
347		// Both empty ("=") should be skipped
348		let dict = parse_dict("=");
349		assert!(dict.is_empty());
350
351		// Mixed: valid pair alongside empty key/value entries
352		let dict = parse_dict("=value,key=,valid=entry,=");
353		assert_eq!(dict.len(), 1);
354		assert_eq!(dict.get("valid").unwrap(), "entry");
355	}
356
357	#[test]
358	fn test_parse_sqlite_memory() {
359		let db = parse_database_url("sqlite::memory:").unwrap();
360		assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
361		assert_eq!(db.name, ":memory:");
362		assert!(db.user.is_none());
363	}
364
365	#[test]
366	fn test_parse_sqlite_file() {
367		let db = parse_database_url("sqlite:///path/to/db.sqlite3").unwrap();
368		assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
369		assert_eq!(db.name, "path/to/db.sqlite3");
370	}
371
372	#[test]
373	fn test_parse_postgresql() {
374		let db = parse_database_url("postgresql://user:pass@localhost:5432/mydb").unwrap();
375		assert_eq!(db.engine, "reinhardt.db.backends.postgresql");
376		assert_eq!(db.name, "mydb");
377		assert_eq!(db.user.unwrap(), "user");
378		assert_eq!(db.password.unwrap(), "pass");
379		assert_eq!(db.host.unwrap(), "localhost");
380		assert_eq!(db.port.unwrap(), 5432);
381	}
382
383	#[test]
384	fn test_parse_mysql() {
385		let db = parse_database_url("mysql://root:secret@127.0.0.1:3306/testdb").unwrap();
386		assert_eq!(db.engine, "reinhardt.db.backends.mysql");
387		assert_eq!(db.name, "testdb");
388		assert_eq!(db.user.unwrap(), "root");
389		assert_eq!(db.password.unwrap(), "secret");
390		assert_eq!(db.host.unwrap(), "127.0.0.1");
391		assert_eq!(db.port.unwrap(), 3306);
392	}
393
394	#[test]
395	fn test_parse_mariadb() {
396		let db = parse_database_url("mariadb://root:secret@127.0.0.1:3306/testdb").unwrap();
397		assert_eq!(db.engine, "reinhardt.db.backends.mysql");
398		assert_eq!(db.name, "testdb");
399		assert_eq!(db.user.unwrap(), "root");
400		assert_eq!(db.password.unwrap(), "secret");
401		assert_eq!(db.host.unwrap(), "127.0.0.1");
402		assert_eq!(db.port.unwrap(), 3306);
403	}
404
405	#[test]
406	fn test_parse_cache_locmem() {
407		let cache = parse_cache_url("locmem://").unwrap();
408		assert_eq!(cache.backend, "reinhardt.cache.backends.locmem.LocMemCache");
409		assert!(cache.location.is_none());
410	}
411
412	#[test]
413	fn test_parse_cache_redis() {
414		let cache = parse_cache_url("redis://localhost:6379/0").unwrap();
415		assert_eq!(cache.backend, "reinhardt.cache.backends.redis.RedisCache");
416		assert_eq!(cache.location.unwrap(), "redis://localhost:6379/0");
417	}
418}