Skip to main content

reinhardt_conf/settings/
advanced.rs

1//! Advanced settings and configuration
2//!
3//! This module provides a flexible configuration system inspired by Django's settings.
4//! Settings can be loaded from environment variables, configuration files, or code.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Main application settings
11#[deprecated(
12	since = "0.1.0-rc.16",
13	note = "use individual fragments (CacheSettings, SessionSettings, etc.) with ProjectSettings instead"
14)]
15#[non_exhaustive]
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AdvancedSettings {
18	/// Debug mode
19	#[serde(default)]
20	pub debug: bool,
21
22	/// Secret key for cryptographic signing
23	#[serde(default = "default_secret_key")]
24	pub secret_key: String,
25
26	/// Allowed hosts
27	#[serde(default)]
28	pub allowed_hosts: Vec<String>,
29
30	/// Database configuration
31	#[serde(default)]
32	pub database: DatabaseSettings,
33
34	/// Cache configuration
35	#[serde(default)]
36	pub cache: CacheSettings,
37
38	/// Session configuration
39	#[serde(default)]
40	pub session: SessionSettings,
41
42	/// CORS configuration
43	#[serde(default)]
44	pub cors: CorsSettings,
45
46	/// Static files configuration
47	#[serde(default)]
48	pub static_files: StaticSettings,
49
50	/// Media files configuration
51	#[serde(default)]
52	pub media: MediaSettings,
53
54	/// Email configuration
55	#[serde(default)]
56	pub email: EmailSettings,
57
58	/// Logging configuration
59	#[serde(default)]
60	pub logging: LoggingSettings,
61
62	/// Custom application-specific settings
63	#[serde(default)]
64	pub custom: HashMap<String, serde_json::Value>,
65}
66
67#[allow(deprecated)]
68impl Default for AdvancedSettings {
69	fn default() -> Self {
70		Self {
71			debug: false,
72			secret_key: "change-me-in-production".to_string(),
73			allowed_hosts: vec!["localhost".to_string(), "127.0.0.1".to_string()],
74			database: DatabaseSettings::default(),
75			cache: CacheSettings::default(),
76			session: SessionSettings::default(),
77			cors: CorsSettings::default(),
78			static_files: StaticSettings::default(),
79			media: MediaSettings::default(),
80			email: EmailSettings::default(),
81			logging: LoggingSettings::default(),
82			custom: HashMap::new(),
83		}
84	}
85}
86
87#[allow(deprecated)]
88impl AdvancedSettings {
89	/// Create new settings with defaults
90	pub fn new() -> Self {
91		Self::default()
92	}
93	/// Validate settings
94	///
95	pub fn validate(&self) -> Result<(), SettingsError> {
96		if self.secret_key == "change-me-in-production" && !self.debug {
97			return Err(SettingsError::ValidationError(
98				"SECRET_KEY must be changed in production".to_string(),
99			));
100		}
101
102		if self.secret_key.len() < 32 {
103			return Err(SettingsError::ValidationError(
104				"SECRET_KEY must be at least 32 characters".to_string(),
105			));
106		}
107
108		if self.allowed_hosts.is_empty() && !self.debug {
109			return Err(SettingsError::ValidationError(
110				"ALLOWED_HOSTS must not be empty in production".to_string(),
111			));
112		}
113
114		Ok(())
115	}
116	/// Load settings from environment variables
117	///
118	pub fn from_env() -> Result<Self, SettingsError> {
119		let mut settings = Self::default();
120
121		if let Ok(debug) = std::env::var("REINHARDT_DEBUG") {
122			settings.debug = debug.to_lowercase() == "true" || debug == "1";
123		}
124
125		if let Ok(secret) = std::env::var("REINHARDT_SECRET_KEY") {
126			settings.secret_key = secret;
127		}
128
129		if let Ok(hosts) = std::env::var("REINHARDT_ALLOWED_HOSTS") {
130			settings.allowed_hosts = hosts.split(',').map(|s| s.trim().to_string()).collect();
131		}
132
133		// Database
134		if let Ok(url) = std::env::var("DATABASE_URL") {
135			settings.database.url = url;
136		}
137
138		// Cache
139		if let Ok(backend) = std::env::var("CACHE_BACKEND") {
140			settings.cache.backend = backend;
141		}
142
143		Ok(settings)
144	}
145	/// Load settings from a configuration file
146	///
147	pub fn from_file(path: impl Into<PathBuf>) -> Result<Self, SettingsError> {
148		let path = path.into();
149		let contents = std::fs::read_to_string(&path).map_err(|e| {
150			SettingsError::FileError(format!("Failed to read {}: {}", path.display(), e))
151		})?;
152
153		let settings: AdvancedSettings =
154			if path.extension().and_then(|s| s.to_str()) == Some("toml") {
155				toml::from_str(&contents)
156					.map_err(|e| SettingsError::ParseError(format!("TOML parse error: {}", e)))?
157			} else if path.extension().and_then(|s| s.to_str()) == Some("json") {
158				serde_json::from_str(&contents)
159					.map_err(|e| SettingsError::ParseError(format!("JSON parse error: {}", e)))?
160			} else {
161				return Err(SettingsError::UnsupportedFormat(
162					"Supported formats: .toml, .json".to_string(),
163				));
164			};
165
166		Ok(settings)
167	}
168	/// Set a custom setting
169	///
170	pub fn set<T: Serialize>(
171		&mut self,
172		key: impl Into<String>,
173		value: T,
174	) -> Result<(), SettingsError> {
175		let json_value = serde_json::to_value(value)
176			.map_err(|e| SettingsError::SerializationError(e.to_string()))?;
177		self.custom.insert(key.into(), json_value);
178		Ok(())
179	}
180	/// Get a custom setting
181	///
182	pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Option<T> {
183		self.custom
184			.get(key)
185			.and_then(|v| serde_json::from_value(v.clone()).ok())
186	}
187}
188
189/// Database settings
190#[non_exhaustive]
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct DatabaseSettings {
193	/// Database connection URL (e.g., `"sqlite::memory:"`, `"postgres://..."`)
194	pub url: String,
195	/// Maximum number of connections in the pool.
196	pub max_connections: u32,
197	/// Minimum number of idle connections to maintain.
198	pub min_connections: u32,
199	/// Connection timeout in seconds.
200	pub connect_timeout: u64,
201	/// Idle connection timeout in seconds before eviction.
202	pub idle_timeout: u64,
203}
204
205impl Default for DatabaseSettings {
206	fn default() -> Self {
207		Self {
208			url: "sqlite::memory:".to_string(),
209			max_connections: 10,
210			min_connections: 1,
211			connect_timeout: 30,
212			idle_timeout: 600,
213		}
214	}
215}
216
217/// Cache settings
218#[non_exhaustive]
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct CacheSettings {
221	/// Cache backend type (e.g., `"memory"`, `"redis"`, `"database"`).
222	pub backend: String,
223	/// Backend-specific connection location or URL.
224	pub location: Option<String>,
225	/// Default cache entry timeout in seconds.
226	pub timeout: u64,
227}
228
229impl Default for CacheSettings {
230	fn default() -> Self {
231		Self {
232			backend: "memory".to_string(),
233			location: None,
234			timeout: 300,
235		}
236	}
237}
238
239/// Session settings
240#[non_exhaustive]
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct SessionSettings {
243	/// Session storage engine (e.g., `"cookie"`, `"database"`, `"redis"`).
244	pub engine: String,
245	/// Name of the session cookie.
246	pub cookie_name: String,
247	/// Maximum age of the session cookie in seconds.
248	pub cookie_age: u64,
249	/// Whether to set the `Secure` flag on the session cookie.
250	pub cookie_secure: bool,
251	/// Whether to set the `HttpOnly` flag on the session cookie.
252	pub cookie_httponly: bool,
253	/// `SameSite` attribute for the session cookie (e.g., `"lax"`, `"strict"`, `"none"`).
254	pub cookie_samesite: String,
255}
256
257impl Default for SessionSettings {
258	fn default() -> Self {
259		Self {
260			engine: "cookie".to_string(),
261			cookie_name: "sessionid".to_string(),
262			cookie_age: 1209600, // 2 weeks
263			cookie_secure: false,
264			cookie_httponly: true,
265			cookie_samesite: "lax".to_string(),
266		}
267	}
268}
269
270/// CORS settings
271#[non_exhaustive]
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct CorsSettings {
274	/// Allowed origin domains (use `"*"` for any origin).
275	pub allow_origins: Vec<String>,
276	/// Allowed HTTP methods.
277	pub allow_methods: Vec<String>,
278	/// Allowed HTTP request headers.
279	pub allow_headers: Vec<String>,
280	/// Whether to allow credentials (cookies, authorization headers).
281	pub allow_credentials: bool,
282	/// Maximum age (in seconds) for preflight response caching.
283	pub max_age: u64,
284}
285
286impl Default for CorsSettings {
287	fn default() -> Self {
288		Self {
289			allow_origins: vec!["*".to_string()],
290			allow_methods: vec![
291				"GET".to_string(),
292				"POST".to_string(),
293				"PUT".to_string(),
294				"PATCH".to_string(),
295				"DELETE".to_string(),
296			],
297			allow_headers: vec!["*".to_string()],
298			allow_credentials: false,
299			max_age: 3600,
300		}
301	}
302}
303
304/// Static files settings
305#[non_exhaustive]
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct StaticSettings {
308	/// URL prefix for serving static files (e.g., `"/static/"`).
309	pub url: String,
310	/// Root directory for collected static files.
311	pub root: PathBuf,
312}
313
314impl Default for StaticSettings {
315	fn default() -> Self {
316		Self {
317			url: "/static/".to_string(),
318			root: PathBuf::from("static"),
319		}
320	}
321}
322
323/// Media files settings
324#[non_exhaustive]
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct MediaSettings {
327	/// URL prefix for serving user-uploaded media files (e.g., `"/media/"`).
328	pub url: String,
329	/// Root directory for user-uploaded media files.
330	pub root: PathBuf,
331}
332
333impl Default for MediaSettings {
334	fn default() -> Self {
335		Self {
336			url: "/media/".to_string(),
337			root: PathBuf::from("media"),
338		}
339	}
340}
341
342/// Email settings
343#[non_exhaustive]
344#[derive(Debug, Clone, Serialize, Deserialize)]
345pub struct EmailSettings {
346	/// Email backend type (e.g., `"smtp"`, `"console"`, `"file"`, `"memory"`).
347	pub backend: String,
348	/// SMTP server hostname.
349	pub host: String,
350	/// SMTP server port number.
351	pub port: u16,
352	/// Optional SMTP authentication username.
353	pub username: Option<String>,
354	/// Optional SMTP authentication password.
355	pub password: Option<String>,
356	/// Whether to use STARTTLS for the SMTP connection.
357	pub use_tls: bool,
358	/// Whether to use direct TLS/SSL for the SMTP connection.
359	pub use_ssl: bool,
360	/// Default sender email address for outgoing emails.
361	pub from_email: String,
362
363	/// List of (name, email) tuples for site administrators
364	/// Used by mail_admins() helper
365	#[serde(default)]
366	pub admins: Vec<(String, String)>,
367
368	/// List of (name, email) tuples for site managers
369	/// Used by mail_managers() helper
370	#[serde(default)]
371	pub managers: Vec<(String, String)>,
372
373	/// Email address for server error notifications
374	#[serde(default = "default_server_email")]
375	pub server_email: String,
376
377	/// Prefix for email subjects (e.g., `"[Django]"`)
378	#[serde(default)]
379	pub subject_prefix: String,
380
381	/// Connection timeout in seconds
382	pub timeout: Option<u64>,
383
384	/// Path to SSL certificate file
385	pub ssl_certfile: Option<PathBuf>,
386
387	/// Path to SSL key file
388	pub ssl_keyfile: Option<PathBuf>,
389
390	/// Directory path for file-based email backend.
391	/// Required when backend is "file".
392	#[serde(default)]
393	pub file_path: Option<PathBuf>,
394}
395
396fn default_secret_key() -> String {
397	"change-me-in-production".to_string()
398}
399
400fn default_server_email() -> String {
401	"root@localhost".to_string()
402}
403
404impl Default for EmailSettings {
405	fn default() -> Self {
406		Self {
407			backend: "console".to_string(),
408			host: "localhost".to_string(),
409			port: 25,
410			username: None,
411			password: None,
412			use_tls: false,
413			use_ssl: false,
414			from_email: "noreply@example.com".to_string(),
415			admins: Vec::new(),
416			managers: Vec::new(),
417			server_email: default_server_email(),
418			subject_prefix: String::new(),
419			timeout: None,
420			ssl_certfile: None,
421			ssl_keyfile: None,
422			file_path: None,
423		}
424	}
425}
426
427/// Logging settings
428#[non_exhaustive]
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct LoggingSettings {
431	/// Log level (e.g., `"trace"`, `"debug"`, `"info"`, `"warn"`, `"error"`).
432	pub level: String,
433	/// Log output format (e.g., `"text"`, `"json"`).
434	pub format: String,
435}
436
437impl Default for LoggingSettings {
438	fn default() -> Self {
439		Self {
440			level: "info".to_string(),
441			format: "text".to_string(),
442		}
443	}
444}
445
446/// Settings error
447#[non_exhaustive]
448#[derive(Debug, thiserror::Error)]
449pub enum SettingsError {
450	/// An error occurred reading or writing a configuration file.
451	#[error("File error: {0}")]
452	FileError(String),
453
454	/// An error occurred parsing configuration content.
455	#[error("Parse error: {0}")]
456	ParseError(String),
457
458	/// A configuration value failed validation.
459	#[error("Validation error: {0}")]
460	ValidationError(String),
461
462	/// The configuration file format is not supported.
463	#[error("Unsupported format: {0}")]
464	UnsupportedFormat(String),
465
466	/// An error occurred during serialization or deserialization.
467	#[error("Serialization error: {0}")]
468	SerializationError(String),
469}
470
471#[cfg(test)]
472#[allow(deprecated)]
473mod tests {
474	use super::*;
475
476	#[test]
477	fn test_default_settings() {
478		let settings = AdvancedSettings::default();
479		assert!(!settings.debug);
480		assert_eq!(settings.database.url, "sqlite::memory:");
481		assert_eq!(settings.cache.backend, "memory");
482	}
483
484	#[test]
485	fn test_settings_validation() {
486		let mut settings = AdvancedSettings::default();
487
488		// Should fail with default secret key
489		assert!(settings.validate().is_err());
490
491		// Should pass with proper secret key
492		settings.secret_key = "a".repeat(32);
493		assert!(settings.validate().is_ok());
494	}
495
496	#[test]
497	fn test_custom_settings() {
498		let mut settings = AdvancedSettings::default();
499
500		settings.set("api_version", "v1").unwrap();
501		settings.set("max_upload_size", 10485760_u64).unwrap();
502
503		let version: String = settings.get("api_version").unwrap();
504		assert_eq!(version, "v1");
505
506		let max_size: u64 = settings.get("max_upload_size").unwrap();
507		assert_eq!(max_size, 10485760);
508	}
509}