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 {
190            database_url: Self::get_database_url_from_env()
191                .map_err(|e| anyhow::anyhow!("Database configuration error: {e}"))?,
192            ..Config::default()
193        };
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!("postgresql://{user}:{password}@{host}:{port}/{db}"));
368            }
369        }
370
371        // Fall back to DB_CONN if provided
372        if let Ok(conn) = env::var("DB_CONN") {
373            return Ok(conn);
374        }
375
376        Err(anyhow::anyhow!(
377            "Database credentials not found. Please provide either:\n\
378             1. DATABASE_URL environment variable, or\n\
379             2. DB_HOST, DB_USER, DB_NAME (and optionally DB_PASSWORD, DB_PORT), or\n\
380             3. DB_CONN environment variable\n\n\
381             Example:\n\
382             DATABASE_URL=postgresql://user:password@localhost:5432/database\n\
383             or\n\
384             DB_HOST=localhost\n\
385             DB_USER=myuser\n\
386             DB_PASSWORD=mypassword\n\
387             DB_NAME=mydatabase"
388        ))
389    }
390
391    /// Generate a safe connection string for logging (masks password)
392    pub fn safe_database_url(&self) -> String {
393        if let Some(at_pos) = self.database_url.find('@') {
394            if let Some(colon_pos) = self.database_url[..at_pos].rfind(':') {
395                // postgresql://user:password@host:port/db -> postgresql://user:***@host:port/db
396                let mut masked = self.database_url.clone();
397                masked.replace_range(colon_pos + 1..at_pos, "***");
398                return masked;
399            }
400        }
401        // If we can't parse it, just show the prefix
402        format!(
403            "postgresql://[credentials-hidden]{}",
404            self.database_url
405                .split_once('@')
406                .map(|(_, rest)| rest)
407                .unwrap_or("")
408        )
409    }
410
411    /// Validate MCP environment configuration
412    pub fn validate_mcp_environment(&self) -> Result<()> {
413        // Standard validation first
414        self.validate()?;
415
416        // MCP-specific validations
417        if self.embedding.provider == "openai" && self.embedding.api_key.len() < 20 {
418            return Err(anyhow::anyhow!(
419                "OpenAI API key appears to be invalid (too short)"
420            ));
421        }
422
423        // Check for reasonable port configuration
424        if self.http_port < 1024 {
425            tracing::warn!(
426                "HTTP port {} requires root privileges. Consider using port >= 1024 for MCP deployment.",
427                self.http_port
428            );
429        }
430
431        if let Some(mcp_port) = self.mcp_port {
432            if mcp_port == self.http_port {
433                return Err(anyhow::anyhow!(
434                    "MCP port and HTTP port cannot be the same ({})",
435                    mcp_port
436                ));
437            }
438        }
439
440        Ok(())
441    }
442
443    /// Create a diagnostic report for troubleshooting MCP setup
444    pub fn create_diagnostic_report(&self) -> String {
445        let mut report = String::new();
446        report.push_str("=== Agentic Memory System - MCP Configuration Report ===\n\n");
447
448        // Database configuration
449        report.push_str("Database Configuration:\n");
450        report.push_str(&format!("  Connection: {}\n", self.safe_database_url()));
451
452        // Embedding configuration
453        report.push_str("\nEmbedding Configuration:\n");
454        report.push_str(&format!("  Provider: {}\n", self.embedding.provider));
455        report.push_str(&format!("  Model: {}\n", self.embedding.model));
456        report.push_str(&format!("  Base URL: {}\n", self.embedding.base_url));
457        report.push_str(&format!("  Timeout: {}s\n", self.embedding.timeout_seconds));
458        report.push_str(&format!(
459            "  API Key: {}\n",
460            if self.embedding.api_key.is_empty() {
461                "Not set"
462            } else {
463                "***configured***"
464            }
465        ));
466
467        // Server configuration
468        report.push_str("\nServer Configuration:\n");
469        report.push_str(&format!("  HTTP Port: {}\n", self.http_port));
470        report.push_str(&format!(
471            "  MCP Port: {}\n",
472            self.mcp_port
473                .map(|p| p.to_string())
474                .unwrap_or_else(|| "Not set".to_string())
475        ));
476
477        // Memory tier configuration
478        report.push_str("\nMemory Tier Configuration:\n");
479        report.push_str(&format!(
480            "  Working Tier Limit: {}\n",
481            self.tier_config.working_tier_limit
482        ));
483        report.push_str(&format!(
484            "  Warm Tier Limit: {}\n",
485            self.tier_config.warm_tier_limit
486        ));
487        report.push_str(&format!(
488            "  Working->Warm: {} days\n",
489            self.tier_config.working_to_warm_days
490        ));
491        report.push_str(&format!(
492            "  Warm->Cold: {} days\n",
493            self.tier_config.warm_to_cold_days
494        ));
495
496        // Validation results
497        report.push_str("\nValidation Results:\n");
498        match self.validate_mcp_environment() {
499            Ok(_) => report.push_str("  ✅ All configuration checks passed\n"),
500            Err(e) => report.push_str(&format!("  ❌ Configuration error: {e}\n")),
501        }
502
503        report.push_str("\n=== End Configuration Report ===\n");
504        report
505    }
506}
507
508impl Default for BackupConfiguration {
509    fn default() -> Self {
510        Self {
511            enabled: true,
512            backup_directory: PathBuf::from("/var/lib/codex/backups"),
513            wal_archive_directory: PathBuf::from("/var/lib/codex/wal_archive"),
514            retention_days: 30,
515            enable_encryption: true,
516            schedule: "0 2 * * *".to_string(), // Daily at 2 AM
517            rto_minutes: 60,                   // 1 hour
518            rpo_minutes: 5,                    // 5 minutes
519            enable_verification: true,
520        }
521    }
522}
523
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct SecurityConfiguration {
526    /// Enable security features
527    pub enabled: bool,
528    /// TLS configuration
529    pub tls_enabled: bool,
530    pub tls_cert_path: PathBuf,
531    pub tls_key_path: PathBuf,
532    pub tls_port: u16,
533    /// Authentication configuration
534    pub auth_enabled: bool,
535    pub jwt_secret: String,
536    pub jwt_expiry_hours: u32,
537    pub api_key_enabled: bool,
538    /// Rate limiting configuration
539    pub rate_limiting_enabled: bool,
540    pub requests_per_minute: u32,
541    pub rate_limit_burst: u32,
542    /// Audit logging configuration
543    pub audit_enabled: bool,
544    pub audit_retention_days: u32,
545    /// PII detection configuration
546    pub pii_detection_enabled: bool,
547    pub pii_mask_logs: bool,
548    /// GDPR compliance configuration
549    pub gdpr_enabled: bool,
550    pub gdpr_retention_days: u32,
551    pub right_to_be_forgotten: bool,
552    /// Secrets management configuration
553    pub vault_enabled: bool,
554    pub vault_address: Option<String>,
555    pub vault_token_path: Option<PathBuf>,
556    /// Input validation configuration
557    pub input_validation_enabled: bool,
558    pub max_request_size_mb: u32,
559}
560
561impl Default for SecurityConfiguration {
562    fn default() -> Self {
563        Self {
564            enabled: true,
565            tls_enabled: false,
566            tls_cert_path: PathBuf::from("/etc/ssl/certs/codex.crt"),
567            tls_key_path: PathBuf::from("/etc/ssl/private/codex.key"),
568            tls_port: 8443,
569            auth_enabled: false,
570            jwt_secret: "change-me-in-production".to_string(),
571            jwt_expiry_hours: 24,
572            api_key_enabled: false,
573            rate_limiting_enabled: false,
574            requests_per_minute: 100,
575            rate_limit_burst: 20,
576            audit_enabled: false,
577            audit_retention_days: 90,
578            pii_detection_enabled: false,
579            pii_mask_logs: true,
580            gdpr_enabled: false,
581            gdpr_retention_days: 730, // 2 years
582            right_to_be_forgotten: false,
583            vault_enabled: false,
584            vault_address: None,
585            vault_token_path: None,
586            input_validation_enabled: true,
587            max_request_size_mb: 10,
588        }
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn test_default_config() {
598        let config = Config::default();
599        assert_eq!(config.http_port, 8080);
600        assert_eq!(config.embedding.model, "nomic-embed-text");
601        assert_eq!(config.embedding.provider, "ollama");
602        assert_eq!(config.tier_config.working_tier_limit, 1000);
603    }
604
605    #[test]
606    fn test_config_validation() {
607        let mut config = Config::default();
608        // Ollama provider with base_url should be valid
609        assert!(config.validate().is_ok());
610
611        // OpenAI provider without API key should fail
612        config.embedding.provider = "openai".to_string();
613        config.embedding.api_key = String::new();
614        assert!(config.validate().is_err());
615
616        // OpenAI provider with API key should pass
617        config.embedding.api_key = "test-key".to_string();
618        assert!(config.validate().is_ok());
619    }
620
621    #[test]
622    fn test_embedding_config_validation() {
623        let mut config = Config::default();
624
625        // Invalid provider should fail
626        config.embedding.provider = "invalid".to_string();
627        assert!(config.validate().is_err());
628
629        // Empty model should fail
630        config.embedding.provider = "ollama".to_string();
631        config.embedding.model = String::new();
632        assert!(config.validate().is_err());
633
634        // Ollama without base_url should fail
635        config.embedding.model = "test-model".to_string();
636        config.embedding.base_url = String::new();
637        assert!(config.validate().is_err());
638    }
639}