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!(
348 "postgresql://{}:{}@{}:{}/{}",
349 user, password, host, port, db
350 ));
351 }
352 }
353
354 if let Ok(conn) = env::var("DB_CONN") {
356 return Ok(conn);
357 }
358
359 Err(anyhow::anyhow!(
360 "Database credentials not found. Please provide either:\n\
361 1. DATABASE_URL environment variable, or\n\
362 2. DB_HOST, DB_USER, DB_NAME (and optionally DB_PASSWORD, DB_PORT), or\n\
363 3. DB_CONN environment variable\n\n\
364 Example:\n\
365 DATABASE_URL=postgresql://user:password@localhost:5432/database\n\
366 or\n\
367 DB_HOST=localhost\n\
368 DB_USER=myuser\n\
369 DB_PASSWORD=mypassword\n\
370 DB_NAME=mydatabase"
371 ))
372 }
373
374 pub fn safe_database_url(&self) -> String {
376 if let Some(at_pos) = self.database_url.find('@') {
377 if let Some(colon_pos) = self.database_url[..at_pos].rfind(':') {
378 let mut masked = self.database_url.clone();
380 masked.replace_range(colon_pos + 1..at_pos, "***");
381 return masked;
382 }
383 }
384 format!(
386 "postgresql://[credentials-hidden]{}",
387 self.database_url
388 .split_once('@')
389 .map(|(_, rest)| rest)
390 .unwrap_or("")
391 )
392 }
393
394 pub fn validate_mcp_environment(&self) -> Result<()> {
396 self.validate()?;
398
399 if self.embedding.provider == "openai" && self.embedding.api_key.len() < 20 {
401 return Err(anyhow::anyhow!(
402 "OpenAI API key appears to be invalid (too short)"
403 ));
404 }
405
406 if self.http_port < 1024 {
408 tracing::warn!(
409 "HTTP port {} requires root privileges. Consider using port >= 1024 for MCP deployment.",
410 self.http_port
411 );
412 }
413
414 if let Some(mcp_port) = self.mcp_port {
415 if mcp_port == self.http_port {
416 return Err(anyhow::anyhow!(
417 "MCP port and HTTP port cannot be the same ({})",
418 mcp_port
419 ));
420 }
421 }
422
423 Ok(())
424 }
425
426 pub fn create_diagnostic_report(&self) -> String {
428 let mut report = String::new();
429 report.push_str("=== Agentic Memory System - MCP Configuration Report ===\n\n");
430
431 report.push_str("Database Configuration:\n");
433 report.push_str(&format!(" Connection: {}\n", self.safe_database_url()));
434
435 report.push_str("\nEmbedding Configuration:\n");
437 report.push_str(&format!(" Provider: {}\n", self.embedding.provider));
438 report.push_str(&format!(" Model: {}\n", self.embedding.model));
439 report.push_str(&format!(" Base URL: {}\n", self.embedding.base_url));
440 report.push_str(&format!(" Timeout: {}s\n", self.embedding.timeout_seconds));
441 report.push_str(&format!(
442 " API Key: {}\n",
443 if self.embedding.api_key.is_empty() {
444 "Not set"
445 } else {
446 "***configured***"
447 }
448 ));
449
450 report.push_str("\nServer Configuration:\n");
452 report.push_str(&format!(" HTTP Port: {}\n", self.http_port));
453 report.push_str(&format!(
454 " MCP Port: {}\n",
455 self.mcp_port
456 .map(|p| p.to_string())
457 .unwrap_or_else(|| "Not set".to_string())
458 ));
459
460 report.push_str("\nMemory Tier Configuration:\n");
462 report.push_str(&format!(
463 " Working Tier Limit: {}\n",
464 self.tier_config.working_tier_limit
465 ));
466 report.push_str(&format!(
467 " Warm Tier Limit: {}\n",
468 self.tier_config.warm_tier_limit
469 ));
470 report.push_str(&format!(
471 " Working->Warm: {} days\n",
472 self.tier_config.working_to_warm_days
473 ));
474 report.push_str(&format!(
475 " Warm->Cold: {} days\n",
476 self.tier_config.warm_to_cold_days
477 ));
478
479 report.push_str("\nValidation Results:\n");
481 match self.validate_mcp_environment() {
482 Ok(_) => report.push_str(" ✅ All configuration checks passed\n"),
483 Err(e) => report.push_str(&format!(" ❌ Configuration error: {}\n", e)),
484 }
485
486 report.push_str("\n=== End Configuration Report ===\n");
487 report
488 }
489}
490
491impl Default for BackupConfiguration {
492 fn default() -> Self {
493 Self {
494 enabled: true,
495 backup_directory: PathBuf::from("/var/lib/codex/backups"),
496 wal_archive_directory: PathBuf::from("/var/lib/codex/wal_archive"),
497 retention_days: 30,
498 enable_encryption: true,
499 schedule: "0 2 * * *".to_string(), rto_minutes: 60, rpo_minutes: 5, enable_verification: true,
503 }
504 }
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SecurityConfiguration {
509 pub enabled: bool,
511 pub tls_enabled: bool,
513 pub tls_cert_path: PathBuf,
514 pub tls_key_path: PathBuf,
515 pub tls_port: u16,
516 pub auth_enabled: bool,
518 pub jwt_secret: String,
519 pub jwt_expiry_hours: u32,
520 pub api_key_enabled: bool,
521 pub rate_limiting_enabled: bool,
523 pub requests_per_minute: u32,
524 pub rate_limit_burst: u32,
525 pub audit_enabled: bool,
527 pub audit_retention_days: u32,
528 pub pii_detection_enabled: bool,
530 pub pii_mask_logs: bool,
531 pub gdpr_enabled: bool,
533 pub gdpr_retention_days: u32,
534 pub right_to_be_forgotten: bool,
535 pub vault_enabled: bool,
537 pub vault_address: Option<String>,
538 pub vault_token_path: Option<PathBuf>,
539 pub input_validation_enabled: bool,
541 pub max_request_size_mb: u32,
542}
543
544impl Default for SecurityConfiguration {
545 fn default() -> Self {
546 Self {
547 enabled: true,
548 tls_enabled: false,
549 tls_cert_path: PathBuf::from("/etc/ssl/certs/codex.crt"),
550 tls_key_path: PathBuf::from("/etc/ssl/private/codex.key"),
551 tls_port: 8443,
552 auth_enabled: false,
553 jwt_secret: "change-me-in-production".to_string(),
554 jwt_expiry_hours: 24,
555 api_key_enabled: false,
556 rate_limiting_enabled: false,
557 requests_per_minute: 100,
558 rate_limit_burst: 20,
559 audit_enabled: false,
560 audit_retention_days: 90,
561 pii_detection_enabled: false,
562 pii_mask_logs: true,
563 gdpr_enabled: false,
564 gdpr_retention_days: 730, right_to_be_forgotten: false,
566 vault_enabled: false,
567 vault_address: None,
568 vault_token_path: None,
569 input_validation_enabled: true,
570 max_request_size_mb: 10,
571 }
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn test_default_config() {
581 let config = Config::default();
582 assert_eq!(config.http_port, 8080);
583 assert_eq!(config.embedding.model, "nomic-embed-text");
584 assert_eq!(config.embedding.provider, "ollama");
585 assert_eq!(config.tier_config.working_tier_limit, 1000);
586 }
587
588 #[test]
589 fn test_config_validation() {
590 let mut config = Config::default();
591 assert!(config.validate().is_ok());
593
594 config.embedding.provider = "openai".to_string();
596 config.embedding.api_key = String::new();
597 assert!(config.validate().is_err());
598
599 config.embedding.api_key = "test-key".to_string();
601 assert!(config.validate().is_ok());
602 }
603
604 #[test]
605 fn test_embedding_config_validation() {
606 let mut config = Config::default();
607
608 config.embedding.provider = "invalid".to_string();
610 assert!(config.validate().is_err());
611
612 config.embedding.provider = "ollama".to_string();
614 config.embedding.model = String::new();
615 assert!(config.validate().is_err());
616
617 config.embedding.model = "test-model".to_string();
619 config.embedding.base_url = String::new();
620 assert!(config.validate().is_err());
621 }
622}