1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::env;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Config {
8 pub database_url: String,
10
11 pub embedding: EmbeddingConfig,
13
14 pub http_port: u16,
16
17 pub mcp_port: Option<u16>,
19
20 pub tier_config: TierConfig,
22
23 pub operational: OperationalConfig,
25
26 pub backup: BackupConfiguration,
28
29 pub security: SecurityConfiguration,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct EmbeddingConfig {
35 pub provider: String,
37
38 pub model: String,
40
41 pub api_key: String,
43
44 pub base_url: String,
46
47 pub timeout_seconds: u64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TierConfig {
53 pub working_tier_limit: usize,
55
56 pub warm_tier_limit: usize,
58
59 pub working_to_warm_days: u32,
61
62 pub warm_to_cold_days: u32,
64
65 pub importance_threshold: f32,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct OperationalConfig {
71 pub max_db_connections: u32,
73
74 pub request_timeout_seconds: u64,
76
77 pub enable_metrics: bool,
79
80 pub log_level: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BackupConfiguration {
86 pub enabled: bool,
88
89 pub backup_directory: PathBuf,
91
92 pub wal_archive_directory: PathBuf,
94
95 pub retention_days: u32,
97
98 pub enable_encryption: bool,
100
101 pub schedule: String,
103
104 pub rto_minutes: u32,
106
107 pub rpo_minutes: u32,
109
110 pub enable_verification: bool,
112}
113
114impl Default for Config {
115 fn default() -> Self {
116 Self {
117 database_url: "postgresql://postgres:postgres@localhost:5432/codex_memory".to_string(),
118 embedding: EmbeddingConfig::default(),
119 http_port: 8080,
120 mcp_port: None,
121 tier_config: TierConfig::default(),
122 operational: OperationalConfig::default(),
123 backup: BackupConfiguration::default(),
124 security: SecurityConfiguration::default(),
125 }
126 }
127}
128
129impl Default for EmbeddingConfig {
130 fn default() -> Self {
131 Self {
132 provider: "ollama".to_string(),
133 model: "nomic-embed-text".to_string(),
134 api_key: String::new(),
135 base_url: "http://192.168.1.110:11434".to_string(),
136 timeout_seconds: 60,
137 }
138 }
139}
140
141impl Default for TierConfig {
142 fn default() -> Self {
143 Self {
144 working_tier_limit: 1000,
145 warm_tier_limit: 10000,
146 working_to_warm_days: 7,
147 warm_to_cold_days: 30,
148 importance_threshold: 0.7,
149 }
150 }
151}
152
153impl Default for OperationalConfig {
154 fn default() -> Self {
155 Self {
156 max_db_connections: 10,
157 request_timeout_seconds: 30,
158 enable_metrics: true,
159 log_level: "info".to_string(),
160 }
161 }
162}
163
164impl Config {
165 pub fn from_env() -> Result<Self> {
167 dotenv::dotenv().ok(); let mut config = Config::default();
170
171 config.database_url = Self::get_database_url_from_env()
173 .map_err(|e| anyhow::anyhow!("Database configuration error: {}", e))?;
174
175 if let Ok(provider) = env::var("EMBEDDING_PROVIDER") {
177 config.embedding.provider = provider;
178 }
179
180 if let Ok(model) = env::var("EMBEDDING_MODEL") {
181 config.embedding.model = model;
182 }
183
184 if let Ok(base_url) = env::var("EMBEDDING_BASE_URL") {
185 config.embedding.base_url = base_url;
186 }
187
188 if let Ok(timeout) = env::var("EMBEDDING_TIMEOUT_SECONDS") {
189 config.embedding.timeout_seconds = timeout
190 .parse()
191 .map_err(|e| anyhow::anyhow!("Invalid EMBEDDING_TIMEOUT_SECONDS: {}", e))?;
192 }
193
194 if let Ok(api_key) = env::var("OPENAI_API_KEY") {
196 config.embedding.api_key = api_key;
197 }
198
199 if let Ok(api_key) = env::var("EMBEDDING_API_KEY") {
201 config.embedding.api_key = api_key;
202 }
203
204 if let Ok(port) = env::var("HTTP_PORT") {
206 config.http_port = port
207 .parse()
208 .map_err(|e| anyhow::anyhow!("Invalid HTTP_PORT: {}", e))?;
209 }
210
211 if let Ok(port) = env::var("MCP_PORT") {
212 config.mcp_port = Some(
213 port.parse()
214 .map_err(|e| anyhow::anyhow!("Invalid MCP_PORT: {}", e))?,
215 );
216 }
217
218 if let Ok(limit) = env::var("WORKING_TIER_LIMIT") {
220 config.tier_config.working_tier_limit = limit
221 .parse()
222 .map_err(|e| anyhow::anyhow!("Invalid WORKING_TIER_LIMIT: {}", e))?;
223 }
224
225 if let Ok(limit) = env::var("WARM_TIER_LIMIT") {
226 config.tier_config.warm_tier_limit = limit
227 .parse()
228 .map_err(|e| anyhow::anyhow!("Invalid WARM_TIER_LIMIT: {}", e))?;
229 }
230
231 if let Ok(days) = env::var("WORKING_TO_WARM_DAYS") {
232 config.tier_config.working_to_warm_days = days
233 .parse()
234 .map_err(|e| anyhow::anyhow!("Invalid WORKING_TO_WARM_DAYS: {}", e))?;
235 }
236
237 if let Ok(days) = env::var("WARM_TO_COLD_DAYS") {
238 config.tier_config.warm_to_cold_days = days
239 .parse()
240 .map_err(|e| anyhow::anyhow!("Invalid WARM_TO_COLD_DAYS: {}", e))?;
241 }
242
243 if let Ok(threshold) = env::var("IMPORTANCE_THRESHOLD") {
244 config.tier_config.importance_threshold = threshold
245 .parse()
246 .map_err(|e| anyhow::anyhow!("Invalid IMPORTANCE_THRESHOLD: {}", e))?;
247 }
248
249 if let Ok(conns) = env::var("MAX_DB_CONNECTIONS") {
251 config.operational.max_db_connections = conns
252 .parse()
253 .map_err(|e| anyhow::anyhow!("Invalid MAX_DB_CONNECTIONS: {}", e))?;
254 }
255
256 if let Ok(timeout) = env::var("REQUEST_TIMEOUT_SECONDS") {
257 config.operational.request_timeout_seconds = timeout
258 .parse()
259 .map_err(|e| anyhow::anyhow!("Invalid REQUEST_TIMEOUT_SECONDS: {}", e))?;
260 }
261
262 if let Ok(enable) = env::var("ENABLE_METRICS") {
263 config.operational.enable_metrics = enable
264 .parse()
265 .map_err(|e| anyhow::anyhow!("Invalid ENABLE_METRICS: {}", e))?;
266 }
267
268 if let Ok(level) = env::var("LOG_LEVEL") {
269 config.operational.log_level = level;
270 }
271
272 Ok(config)
273 }
274
275 pub fn validate(&self) -> Result<()> {
277 if self.database_url.is_empty() {
278 return Err(anyhow::anyhow!("Database URL is required"));
279 }
280
281 match self.embedding.provider.as_str() {
283 "openai" => {
284 if self.embedding.api_key.is_empty() {
285 return Err(anyhow::anyhow!("API key is required for OpenAI provider"));
286 }
287 }
288 "ollama" => {
289 if self.embedding.base_url.is_empty() {
290 return Err(anyhow::anyhow!("Base URL is required for Ollama provider"));
291 }
292 }
293 "mock" => {
294 }
296 _ => {
297 return Err(anyhow::anyhow!(
298 "Invalid embedding provider: {}. Must be 'openai', 'ollama', or 'mock'",
299 self.embedding.provider
300 ));
301 }
302 }
303
304 if self.embedding.model.is_empty() {
305 return Err(anyhow::anyhow!("Embedding model is required"));
306 }
307
308 if self.tier_config.working_tier_limit == 0 {
309 return Err(anyhow::anyhow!("Working tier limit must be greater than 0"));
310 }
311
312 if self.tier_config.warm_tier_limit == 0 {
313 return Err(anyhow::anyhow!("Warm tier limit must be greater than 0"));
314 }
315
316 if self.tier_config.importance_threshold < 0.0
317 || self.tier_config.importance_threshold > 1.0
318 {
319 return Err(anyhow::anyhow!(
320 "Importance threshold must be between 0.0 and 1.0"
321 ));
322 }
323
324 Ok(())
325 }
326
327 fn get_database_url_from_env() -> Result<String> {
330 if let Ok(url) = env::var("DATABASE_URL") {
332 return Ok(url);
333 }
334
335 if let (Ok(host), Ok(user), Ok(db)) = (
337 env::var("DB_HOST"),
338 env::var("DB_USER"),
339 env::var("DB_NAME"),
340 ) {
341 let password = env::var("DB_PASSWORD").unwrap_or_default();
342 let port = env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string());
343
344 if password.is_empty() {
345 return Ok(format!("postgresql://{}@{}:{}/{}", user, host, port, db));
346 } else {
347 return Ok(format!("postgresql://{}:{}@{}:{}/{}", user, password, host, port, db));
348 }
349 }
350
351 if let Ok(conn) = env::var("DB_CONN") {
353 return Ok(conn);
354 }
355
356 Err(anyhow::anyhow!(
357 "Database credentials not found. Please provide either:\n\
358 1. DATABASE_URL environment variable, or\n\
359 2. DB_HOST, DB_USER, DB_NAME (and optionally DB_PASSWORD, DB_PORT), or\n\
360 3. DB_CONN environment variable\n\n\
361 Example:\n\
362 DATABASE_URL=postgresql://user:password@localhost:5432/database\n\
363 or\n\
364 DB_HOST=localhost\n\
365 DB_USER=myuser\n\
366 DB_PASSWORD=mypassword\n\
367 DB_NAME=mydatabase"
368 ))
369 }
370
371 pub fn safe_database_url(&self) -> String {
373 if let Some(at_pos) = self.database_url.find('@') {
374 if let Some(colon_pos) = self.database_url[..at_pos].rfind(':') {
375 let mut masked = self.database_url.clone();
377 masked.replace_range(colon_pos + 1..at_pos, "***");
378 return masked;
379 }
380 }
381 format!("postgresql://[credentials-hidden]{}",
383 self.database_url.split_once('@').map(|(_, rest)| rest).unwrap_or(""))
384 }
385
386 pub fn validate_mcp_environment(&self) -> Result<()> {
388 self.validate()?;
390
391 if self.embedding.provider == "openai" && self.embedding.api_key.len() < 20 {
393 return Err(anyhow::anyhow!("OpenAI API key appears to be invalid (too short)"));
394 }
395
396 if self.http_port < 1024 {
398 tracing::warn!(
399 "HTTP port {} requires root privileges. Consider using port >= 1024 for MCP deployment.",
400 self.http_port
401 );
402 }
403
404 if let Some(mcp_port) = self.mcp_port {
405 if mcp_port == self.http_port {
406 return Err(anyhow::anyhow!(
407 "MCP port and HTTP port cannot be the same ({})",
408 mcp_port
409 ));
410 }
411 }
412
413 Ok(())
414 }
415
416 pub fn create_diagnostic_report(&self) -> String {
418 let mut report = String::new();
419 report.push_str("=== Agentic Memory System - MCP Configuration Report ===\n\n");
420
421 report.push_str("Database Configuration:\n");
423 report.push_str(&format!(" Connection: {}\n", self.safe_database_url()));
424
425 report.push_str("\nEmbedding Configuration:\n");
427 report.push_str(&format!(" Provider: {}\n", self.embedding.provider));
428 report.push_str(&format!(" Model: {}\n", self.embedding.model));
429 report.push_str(&format!(" Base URL: {}\n", self.embedding.base_url));
430 report.push_str(&format!(" Timeout: {}s\n", self.embedding.timeout_seconds));
431 report.push_str(&format!(" API Key: {}\n",
432 if self.embedding.api_key.is_empty() { "Not set" } else { "***configured***" }
433 ));
434
435 report.push_str("\nServer Configuration:\n");
437 report.push_str(&format!(" HTTP Port: {}\n", self.http_port));
438 report.push_str(&format!(" MCP Port: {}\n",
439 self.mcp_port.map(|p| p.to_string()).unwrap_or_else(|| "Not set".to_string())
440 ));
441
442 report.push_str("\nMemory Tier Configuration:\n");
444 report.push_str(&format!(" Working Tier Limit: {}\n", self.tier_config.working_tier_limit));
445 report.push_str(&format!(" Warm Tier Limit: {}\n", self.tier_config.warm_tier_limit));
446 report.push_str(&format!(" Working->Warm: {} days\n", self.tier_config.working_to_warm_days));
447 report.push_str(&format!(" Warm->Cold: {} days\n", self.tier_config.warm_to_cold_days));
448
449 report.push_str("\nValidation Results:\n");
451 match self.validate_mcp_environment() {
452 Ok(_) => report.push_str(" ✅ All configuration checks passed\n"),
453 Err(e) => report.push_str(&format!(" ❌ Configuration error: {}\n", e)),
454 }
455
456 report.push_str("\n=== End Configuration Report ===\n");
457 report
458 }
459}
460
461impl Default for BackupConfiguration {
462 fn default() -> Self {
463 Self {
464 enabled: true,
465 backup_directory: PathBuf::from("/var/lib/codex/backups"),
466 wal_archive_directory: PathBuf::from("/var/lib/codex/wal_archive"),
467 retention_days: 30,
468 enable_encryption: true,
469 schedule: "0 2 * * *".to_string(), rto_minutes: 60, rpo_minutes: 5, enable_verification: true,
473 }
474 }
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct SecurityConfiguration {
479 pub enabled: bool,
481 pub tls_enabled: bool,
483 pub tls_cert_path: PathBuf,
484 pub tls_key_path: PathBuf,
485 pub tls_port: u16,
486 pub auth_enabled: bool,
488 pub jwt_secret: String,
489 pub jwt_expiry_hours: u32,
490 pub api_key_enabled: bool,
491 pub rate_limiting_enabled: bool,
493 pub requests_per_minute: u32,
494 pub rate_limit_burst: u32,
495 pub audit_enabled: bool,
497 pub audit_retention_days: u32,
498 pub pii_detection_enabled: bool,
500 pub pii_mask_logs: bool,
501 pub gdpr_enabled: bool,
503 pub gdpr_retention_days: u32,
504 pub right_to_be_forgotten: bool,
505 pub vault_enabled: bool,
507 pub vault_address: Option<String>,
508 pub vault_token_path: Option<PathBuf>,
509 pub input_validation_enabled: bool,
511 pub max_request_size_mb: u32,
512}
513
514impl Default for SecurityConfiguration {
515 fn default() -> Self {
516 Self {
517 enabled: true,
518 tls_enabled: false,
519 tls_cert_path: PathBuf::from("/etc/ssl/certs/codex.crt"),
520 tls_key_path: PathBuf::from("/etc/ssl/private/codex.key"),
521 tls_port: 8443,
522 auth_enabled: false,
523 jwt_secret: "change-me-in-production".to_string(),
524 jwt_expiry_hours: 24,
525 api_key_enabled: false,
526 rate_limiting_enabled: false,
527 requests_per_minute: 100,
528 rate_limit_burst: 20,
529 audit_enabled: false,
530 audit_retention_days: 90,
531 pii_detection_enabled: false,
532 pii_mask_logs: true,
533 gdpr_enabled: false,
534 gdpr_retention_days: 730, right_to_be_forgotten: false,
536 vault_enabled: false,
537 vault_address: None,
538 vault_token_path: None,
539 input_validation_enabled: true,
540 max_request_size_mb: 10,
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use std::env;
549
550 #[test]
551 fn test_default_config() {
552 let config = Config::default();
553 assert_eq!(config.http_port, 8080);
554 assert_eq!(config.embedding.model, "nomic-embed-text");
555 assert_eq!(config.embedding.provider, "ollama");
556 assert_eq!(config.tier_config.working_tier_limit, 1000);
557 }
558
559 #[test]
560 fn test_config_validation() {
561 let mut config = Config::default();
562 assert!(config.validate().is_ok());
564
565 config.embedding.provider = "openai".to_string();
567 config.embedding.api_key = String::new();
568 assert!(config.validate().is_err());
569
570 config.embedding.api_key = "test-key".to_string();
572 assert!(config.validate().is_ok());
573 }
574
575 #[test]
576 fn test_embedding_config_validation() {
577 let mut config = Config::default();
578
579 config.embedding.provider = "invalid".to_string();
581 assert!(config.validate().is_err());
582
583 config.embedding.provider = "ollama".to_string();
585 config.embedding.model = String::new();
586 assert!(config.validate().is_err());
587
588 config.embedding.model = "test-model".to_string();
590 config.embedding.base_url = String::new();
591 assert!(config.validate().is_err());
592 }
593}