codex_memory/
config.rs

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    /// PostgreSQL database connection URL
9    pub database_url: String,
10
11    /// Embedding service configuration
12    pub embedding: EmbeddingConfig,
13
14    /// HTTP server port
15    pub http_port: u16,
16
17    /// MCP server port (if different from HTTP)
18    pub mcp_port: Option<u16>,
19
20    /// Memory tier configuration
21    pub tier_config: TierConfig,
22
23    /// Operational settings
24    pub operational: OperationalConfig,
25
26    /// Backup and disaster recovery settings
27    pub backup: BackupConfiguration,
28
29    /// Security and compliance settings
30    pub security: SecurityConfiguration,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct EmbeddingConfig {
35    /// Embedding provider (openai or ollama)
36    pub provider: String,
37
38    /// Model name to use for embeddings
39    pub model: String,
40
41    /// API key (for OpenAI, empty for Ollama)
42    pub api_key: String,
43
44    /// Base URL for the embedding service
45    pub base_url: String,
46
47    /// Request timeout in seconds
48    pub timeout_seconds: u64,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TierConfig {
53    /// Maximum memories in working tier
54    pub working_tier_limit: usize,
55
56    /// Maximum memories in warm tier  
57    pub warm_tier_limit: usize,
58
59    /// Days before moving from working to warm
60    pub working_to_warm_days: u32,
61
62    /// Days before moving from warm to cold
63    pub warm_to_cold_days: u32,
64
65    /// Importance threshold for tier promotion
66    pub importance_threshold: f32,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct OperationalConfig {
71    /// Maximum database connections
72    pub max_db_connections: u32,
73
74    /// Request timeout in seconds
75    pub request_timeout_seconds: u64,
76
77    /// Enable metrics endpoint
78    pub enable_metrics: bool,
79
80    /// Log level (error, warn, info, debug, trace)
81    pub log_level: String,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct BackupConfiguration {
86    /// Enable automated backups
87    pub enabled: bool,
88
89    /// Directory for backup storage
90    pub backup_directory: PathBuf,
91
92    /// WAL archive directory
93    pub wal_archive_directory: PathBuf,
94
95    /// Backup retention in days
96    pub retention_days: u32,
97
98    /// Enable backup encryption
99    pub enable_encryption: bool,
100
101    /// Backup schedule (cron format)
102    pub schedule: String,
103
104    /// Recovery time objective in minutes
105    pub rto_minutes: u32,
106
107    /// Recovery point objective in minutes
108    pub rpo_minutes: u32,
109
110    /// Enable backup verification
111    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    /// Load configuration from environment variables with MCP-friendly error handling
166    pub fn from_env() -> Result<Self> {
167        dotenv::dotenv().ok(); // Load .env file if present
168
169        // Handle Claude Desktop MCP_ prefixed environment variables
170        if let Ok(mcp_db_url) = std::env::var("MCP_DATABASE_URL") {
171            std::env::set_var("DATABASE_URL", mcp_db_url);
172        }
173        if let Ok(mcp_provider) = std::env::var("MCP_EMBEDDING_PROVIDER") {
174            std::env::set_var("EMBEDDING_PROVIDER", mcp_provider);
175        }
176        if let Ok(mcp_model) = std::env::var("MCP_EMBEDDING_MODEL") {
177            std::env::set_var("EMBEDDING_MODEL", mcp_model);
178        }
179        if let Ok(mcp_api_key) = std::env::var("MCP_OPENAI_API_KEY") {
180            std::env::set_var("OPENAI_API_KEY", mcp_api_key);
181        }
182        if let Ok(mcp_ollama_url) = std::env::var("MCP_OLLAMA_BASE_URL") {
183            std::env::set_var("OLLAMA_BASE_URL", mcp_ollama_url);
184        }
185        if let Ok(mcp_log_level) = std::env::var("MCP_LOG_LEVEL") {
186            std::env::set_var("RUST_LOG", mcp_log_level);
187        }
188
189        let mut config = Config::default();
190
191        // Database URL - support multiple environment variable names for flexibility
192        config.database_url = Self::get_database_url_from_env()
193            .map_err(|e| anyhow::anyhow!("Database configuration error: {}", e))?;
194
195        // Embedding configuration
196        if let Ok(provider) = env::var("EMBEDDING_PROVIDER") {
197            config.embedding.provider = provider;
198        }
199
200        if let Ok(model) = env::var("EMBEDDING_MODEL") {
201            config.embedding.model = model;
202        }
203
204        if let Ok(base_url) = env::var("EMBEDDING_BASE_URL") {
205            config.embedding.base_url = base_url;
206        }
207
208        if let Ok(timeout) = env::var("EMBEDDING_TIMEOUT_SECONDS") {
209            config.embedding.timeout_seconds = timeout
210                .parse()
211                .map_err(|e| anyhow::anyhow!("Invalid EMBEDDING_TIMEOUT_SECONDS: {}", e))?;
212        }
213
214        // API key is optional (not needed for Ollama)
215        if let Ok(api_key) = env::var("OPENAI_API_KEY") {
216            config.embedding.api_key = api_key;
217        }
218
219        // For backward compatibility, also check EMBEDDING_API_KEY
220        if let Ok(api_key) = env::var("EMBEDDING_API_KEY") {
221            config.embedding.api_key = api_key;
222        }
223
224        // Optional environment variables
225        if let Ok(port) = env::var("HTTP_PORT") {
226            config.http_port = port
227                .parse()
228                .map_err(|e| anyhow::anyhow!("Invalid HTTP_PORT: {}", e))?;
229        }
230
231        if let Ok(port) = env::var("MCP_PORT") {
232            config.mcp_port = Some(
233                port.parse()
234                    .map_err(|e| anyhow::anyhow!("Invalid MCP_PORT: {}", e))?,
235            );
236        }
237
238        // Tier configuration
239        if let Ok(limit) = env::var("WORKING_TIER_LIMIT") {
240            config.tier_config.working_tier_limit = limit
241                .parse()
242                .map_err(|e| anyhow::anyhow!("Invalid WORKING_TIER_LIMIT: {}", e))?;
243        }
244
245        if let Ok(limit) = env::var("WARM_TIER_LIMIT") {
246            config.tier_config.warm_tier_limit = limit
247                .parse()
248                .map_err(|e| anyhow::anyhow!("Invalid WARM_TIER_LIMIT: {}", e))?;
249        }
250
251        if let Ok(days) = env::var("WORKING_TO_WARM_DAYS") {
252            config.tier_config.working_to_warm_days = days
253                .parse()
254                .map_err(|e| anyhow::anyhow!("Invalid WORKING_TO_WARM_DAYS: {}", e))?;
255        }
256
257        if let Ok(days) = env::var("WARM_TO_COLD_DAYS") {
258            config.tier_config.warm_to_cold_days = days
259                .parse()
260                .map_err(|e| anyhow::anyhow!("Invalid WARM_TO_COLD_DAYS: {}", e))?;
261        }
262
263        if let Ok(threshold) = env::var("IMPORTANCE_THRESHOLD") {
264            config.tier_config.importance_threshold = threshold
265                .parse()
266                .map_err(|e| anyhow::anyhow!("Invalid IMPORTANCE_THRESHOLD: {}", e))?;
267        }
268
269        // Operational configuration
270        if let Ok(conns) = env::var("MAX_DB_CONNECTIONS") {
271            config.operational.max_db_connections = conns
272                .parse()
273                .map_err(|e| anyhow::anyhow!("Invalid MAX_DB_CONNECTIONS: {}", e))?;
274        }
275
276        if let Ok(timeout) = env::var("REQUEST_TIMEOUT_SECONDS") {
277            config.operational.request_timeout_seconds = timeout
278                .parse()
279                .map_err(|e| anyhow::anyhow!("Invalid REQUEST_TIMEOUT_SECONDS: {}", e))?;
280        }
281
282        if let Ok(enable) = env::var("ENABLE_METRICS") {
283            config.operational.enable_metrics = enable
284                .parse()
285                .map_err(|e| anyhow::anyhow!("Invalid ENABLE_METRICS: {}", e))?;
286        }
287
288        if let Ok(level) = env::var("LOG_LEVEL") {
289            config.operational.log_level = level;
290        }
291
292        Ok(config)
293    }
294
295    /// Validate the configuration
296    pub fn validate(&self) -> Result<()> {
297        if self.database_url.is_empty() {
298            return Err(anyhow::anyhow!("Database URL is required"));
299        }
300
301        // Validate embedding configuration
302        match self.embedding.provider.as_str() {
303            "openai" => {
304                if self.embedding.api_key.is_empty() {
305                    return Err(anyhow::anyhow!("API key is required for OpenAI provider"));
306                }
307            }
308            "ollama" => {
309                if self.embedding.base_url.is_empty() {
310                    return Err(anyhow::anyhow!("Base URL is required for Ollama provider"));
311                }
312            }
313            "mock" => {
314                // Mock provider for testing - no additional validation needed
315            }
316            _ => {
317                return Err(anyhow::anyhow!(
318                    "Invalid embedding provider: {}. Must be 'openai', 'ollama', or 'mock'",
319                    self.embedding.provider
320                ));
321            }
322        }
323
324        if self.embedding.model.is_empty() {
325            return Err(anyhow::anyhow!("Embedding model is required"));
326        }
327
328        if self.tier_config.working_tier_limit == 0 {
329            return Err(anyhow::anyhow!("Working tier limit must be greater than 0"));
330        }
331
332        if self.tier_config.warm_tier_limit == 0 {
333            return Err(anyhow::anyhow!("Warm tier limit must be greater than 0"));
334        }
335
336        if self.tier_config.importance_threshold < 0.0
337            || self.tier_config.importance_threshold > 1.0
338        {
339            return Err(anyhow::anyhow!(
340                "Importance threshold must be between 0.0 and 1.0"
341            ));
342        }
343
344        Ok(())
345    }
346
347    /// Get database URL from environment variables with multiple fallback options
348    /// for MCP compatibility
349    fn get_database_url_from_env() -> Result<String> {
350        // Try DATABASE_URL first (standard convention)
351        if let Ok(url) = env::var("DATABASE_URL") {
352            return Ok(url);
353        }
354
355        // Try individual components for MCP-style configuration
356        if let (Ok(host), Ok(user), Ok(db)) = (
357            env::var("DB_HOST"),
358            env::var("DB_USER"),
359            env::var("DB_NAME"),
360        ) {
361            let password = env::var("DB_PASSWORD").unwrap_or_default();
362            let port = env::var("DB_PORT").unwrap_or_else(|_| "5432".to_string());
363
364            if password.is_empty() {
365                return Ok(format!("postgresql://{}@{}:{}/{}", user, host, port, db));
366            } else {
367                return Ok(format!(
368                    "postgresql://{}:{}@{}:{}/{}",
369                    user, password, host, port, db
370                ));
371            }
372        }
373
374        // Fall back to DB_CONN if provided
375        if let Ok(conn) = env::var("DB_CONN") {
376            return Ok(conn);
377        }
378
379        Err(anyhow::anyhow!(
380            "Database credentials not found. Please provide either:\n\
381             1. DATABASE_URL environment variable, or\n\
382             2. DB_HOST, DB_USER, DB_NAME (and optionally DB_PASSWORD, DB_PORT), or\n\
383             3. DB_CONN environment variable\n\n\
384             Example:\n\
385             DATABASE_URL=postgresql://user:password@localhost:5432/database\n\
386             or\n\
387             DB_HOST=localhost\n\
388             DB_USER=myuser\n\
389             DB_PASSWORD=mypassword\n\
390             DB_NAME=mydatabase"
391        ))
392    }
393
394    /// Generate a safe connection string for logging (masks password)
395    pub fn safe_database_url(&self) -> String {
396        if let Some(at_pos) = self.database_url.find('@') {
397            if let Some(colon_pos) = self.database_url[..at_pos].rfind(':') {
398                // postgresql://user:password@host:port/db -> postgresql://user:***@host:port/db
399                let mut masked = self.database_url.clone();
400                masked.replace_range(colon_pos + 1..at_pos, "***");
401                return masked;
402            }
403        }
404        // If we can't parse it, just show the prefix
405        format!(
406            "postgresql://[credentials-hidden]{}",
407            self.database_url
408                .split_once('@')
409                .map(|(_, rest)| rest)
410                .unwrap_or("")
411        )
412    }
413
414    /// Validate MCP environment configuration
415    pub fn validate_mcp_environment(&self) -> Result<()> {
416        // Standard validation first
417        self.validate()?;
418
419        // MCP-specific validations
420        if self.embedding.provider == "openai" && self.embedding.api_key.len() < 20 {
421            return Err(anyhow::anyhow!(
422                "OpenAI API key appears to be invalid (too short)"
423            ));
424        }
425
426        // Check for reasonable port configuration
427        if self.http_port < 1024 {
428            tracing::warn!(
429                "HTTP port {} requires root privileges. Consider using port >= 1024 for MCP deployment.",
430                self.http_port
431            );
432        }
433
434        if let Some(mcp_port) = self.mcp_port {
435            if mcp_port == self.http_port {
436                return Err(anyhow::anyhow!(
437                    "MCP port and HTTP port cannot be the same ({})",
438                    mcp_port
439                ));
440            }
441        }
442
443        Ok(())
444    }
445
446    /// Create a diagnostic report for troubleshooting MCP setup
447    pub fn create_diagnostic_report(&self) -> String {
448        let mut report = String::new();
449        report.push_str("=== Agentic Memory System - MCP Configuration Report ===\n\n");
450
451        // Database configuration
452        report.push_str("Database Configuration:\n");
453        report.push_str(&format!("  Connection: {}\n", self.safe_database_url()));
454
455        // Embedding configuration
456        report.push_str("\nEmbedding Configuration:\n");
457        report.push_str(&format!("  Provider: {}\n", self.embedding.provider));
458        report.push_str(&format!("  Model: {}\n", self.embedding.model));
459        report.push_str(&format!("  Base URL: {}\n", self.embedding.base_url));
460        report.push_str(&format!("  Timeout: {}s\n", self.embedding.timeout_seconds));
461        report.push_str(&format!(
462            "  API Key: {}\n",
463            if self.embedding.api_key.is_empty() {
464                "Not set"
465            } else {
466                "***configured***"
467            }
468        ));
469
470        // Server configuration
471        report.push_str("\nServer Configuration:\n");
472        report.push_str(&format!("  HTTP Port: {}\n", self.http_port));
473        report.push_str(&format!(
474            "  MCP Port: {}\n",
475            self.mcp_port
476                .map(|p| p.to_string())
477                .unwrap_or_else(|| "Not set".to_string())
478        ));
479
480        // Memory tier configuration
481        report.push_str("\nMemory Tier Configuration:\n");
482        report.push_str(&format!(
483            "  Working Tier Limit: {}\n",
484            self.tier_config.working_tier_limit
485        ));
486        report.push_str(&format!(
487            "  Warm Tier Limit: {}\n",
488            self.tier_config.warm_tier_limit
489        ));
490        report.push_str(&format!(
491            "  Working->Warm: {} days\n",
492            self.tier_config.working_to_warm_days
493        ));
494        report.push_str(&format!(
495            "  Warm->Cold: {} days\n",
496            self.tier_config.warm_to_cold_days
497        ));
498
499        // Validation results
500        report.push_str("\nValidation Results:\n");
501        match self.validate_mcp_environment() {
502            Ok(_) => report.push_str("  ✅ All configuration checks passed\n"),
503            Err(e) => report.push_str(&format!("  ❌ Configuration error: {}\n", e)),
504        }
505
506        report.push_str("\n=== End Configuration Report ===\n");
507        report
508    }
509}
510
511impl Default for BackupConfiguration {
512    fn default() -> Self {
513        Self {
514            enabled: true,
515            backup_directory: PathBuf::from("/var/lib/codex/backups"),
516            wal_archive_directory: PathBuf::from("/var/lib/codex/wal_archive"),
517            retention_days: 30,
518            enable_encryption: true,
519            schedule: "0 2 * * *".to_string(), // Daily at 2 AM
520            rto_minutes: 60,                   // 1 hour
521            rpo_minutes: 5,                    // 5 minutes
522            enable_verification: true,
523        }
524    }
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct SecurityConfiguration {
529    /// Enable security features
530    pub enabled: bool,
531    /// TLS configuration
532    pub tls_enabled: bool,
533    pub tls_cert_path: PathBuf,
534    pub tls_key_path: PathBuf,
535    pub tls_port: u16,
536    /// Authentication configuration
537    pub auth_enabled: bool,
538    pub jwt_secret: String,
539    pub jwt_expiry_hours: u32,
540    pub api_key_enabled: bool,
541    /// Rate limiting configuration
542    pub rate_limiting_enabled: bool,
543    pub requests_per_minute: u32,
544    pub rate_limit_burst: u32,
545    /// Audit logging configuration
546    pub audit_enabled: bool,
547    pub audit_retention_days: u32,
548    /// PII detection configuration
549    pub pii_detection_enabled: bool,
550    pub pii_mask_logs: bool,
551    /// GDPR compliance configuration
552    pub gdpr_enabled: bool,
553    pub gdpr_retention_days: u32,
554    pub right_to_be_forgotten: bool,
555    /// Secrets management configuration
556    pub vault_enabled: bool,
557    pub vault_address: Option<String>,
558    pub vault_token_path: Option<PathBuf>,
559    /// Input validation configuration
560    pub input_validation_enabled: bool,
561    pub max_request_size_mb: u32,
562}
563
564impl Default for SecurityConfiguration {
565    fn default() -> Self {
566        Self {
567            enabled: true,
568            tls_enabled: false,
569            tls_cert_path: PathBuf::from("/etc/ssl/certs/codex.crt"),
570            tls_key_path: PathBuf::from("/etc/ssl/private/codex.key"),
571            tls_port: 8443,
572            auth_enabled: false,
573            jwt_secret: "change-me-in-production".to_string(),
574            jwt_expiry_hours: 24,
575            api_key_enabled: false,
576            rate_limiting_enabled: false,
577            requests_per_minute: 100,
578            rate_limit_burst: 20,
579            audit_enabled: false,
580            audit_retention_days: 90,
581            pii_detection_enabled: false,
582            pii_mask_logs: true,
583            gdpr_enabled: false,
584            gdpr_retention_days: 730, // 2 years
585            right_to_be_forgotten: false,
586            vault_enabled: false,
587            vault_address: None,
588            vault_token_path: None,
589            input_validation_enabled: true,
590            max_request_size_mb: 10,
591        }
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_default_config() {
601        let config = Config::default();
602        assert_eq!(config.http_port, 8080);
603        assert_eq!(config.embedding.model, "nomic-embed-text");
604        assert_eq!(config.embedding.provider, "ollama");
605        assert_eq!(config.tier_config.working_tier_limit, 1000);
606    }
607
608    #[test]
609    fn test_config_validation() {
610        let mut config = Config::default();
611        // Ollama provider with base_url should be valid
612        assert!(config.validate().is_ok());
613
614        // OpenAI provider without API key should fail
615        config.embedding.provider = "openai".to_string();
616        config.embedding.api_key = String::new();
617        assert!(config.validate().is_err());
618
619        // OpenAI provider with API key should pass
620        config.embedding.api_key = "test-key".to_string();
621        assert!(config.validate().is_ok());
622    }
623
624    #[test]
625    fn test_embedding_config_validation() {
626        let mut config = Config::default();
627
628        // Invalid provider should fail
629        config.embedding.provider = "invalid".to_string();
630        assert!(config.validate().is_err());
631
632        // Empty model should fail
633        config.embedding.provider = "ollama".to_string();
634        config.embedding.model = String::new();
635        assert!(config.validate().is_err());
636
637        // Ollama without base_url should fail
638        config.embedding.model = "test-model".to_string();
639        config.embedding.base_url = String::new();
640        assert!(config.validate().is_err());
641    }
642}