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        let mut config = Config::default();
170
171        // Database URL - support multiple environment variable names for flexibility
172        config.database_url = Self::get_database_url_from_env()
173            .map_err(|e| anyhow::anyhow!("Database configuration error: {}", e))?;
174
175        // Embedding configuration
176        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        // API key is optional (not needed for Ollama)
195        if let Ok(api_key) = env::var("OPENAI_API_KEY") {
196            config.embedding.api_key = api_key;
197        }
198
199        // For backward compatibility, also check EMBEDDING_API_KEY
200        if let Ok(api_key) = env::var("EMBEDDING_API_KEY") {
201            config.embedding.api_key = api_key;
202        }
203
204        // Optional environment variables
205        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        // Tier configuration
219        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        // Operational configuration
250        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    /// Validate the configuration
276    pub fn validate(&self) -> Result<()> {
277        if self.database_url.is_empty() {
278            return Err(anyhow::anyhow!("Database URL is required"));
279        }
280
281        // Validate embedding configuration
282        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                // Mock provider for testing - no additional validation needed
295            }
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    /// Get database URL from environment variables with multiple fallback options
328    /// for MCP compatibility
329    fn get_database_url_from_env() -> Result<String> {
330        // Try DATABASE_URL first (standard convention)
331        if let Ok(url) = env::var("DATABASE_URL") {
332            return Ok(url);
333        }
334
335        // Try individual components for MCP-style configuration
336        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        // Fall back to DB_CONN if provided
355        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    /// Generate a safe connection string for logging (masks password)
375    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                // postgresql://user:password@host:port/db -> postgresql://user:***@host:port/db
379                let mut masked = self.database_url.clone();
380                masked.replace_range(colon_pos + 1..at_pos, "***");
381                return masked;
382            }
383        }
384        // If we can't parse it, just show the prefix
385        format!(
386            "postgresql://[credentials-hidden]{}",
387            self.database_url
388                .split_once('@')
389                .map(|(_, rest)| rest)
390                .unwrap_or("")
391        )
392    }
393
394    /// Validate MCP environment configuration
395    pub fn validate_mcp_environment(&self) -> Result<()> {
396        // Standard validation first
397        self.validate()?;
398
399        // MCP-specific validations
400        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        // Check for reasonable port configuration
407        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    /// Create a diagnostic report for troubleshooting MCP setup
427    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        // Database configuration
432        report.push_str("Database Configuration:\n");
433        report.push_str(&format!("  Connection: {}\n", self.safe_database_url()));
434
435        // Embedding configuration
436        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        // Server configuration
451        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        // Memory tier configuration
461        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        // Validation results
480        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(), // Daily at 2 AM
500            rto_minutes: 60,                   // 1 hour
501            rpo_minutes: 5,                    // 5 minutes
502            enable_verification: true,
503        }
504    }
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SecurityConfiguration {
509    /// Enable security features
510    pub enabled: bool,
511    /// TLS configuration
512    pub tls_enabled: bool,
513    pub tls_cert_path: PathBuf,
514    pub tls_key_path: PathBuf,
515    pub tls_port: u16,
516    /// Authentication configuration
517    pub auth_enabled: bool,
518    pub jwt_secret: String,
519    pub jwt_expiry_hours: u32,
520    pub api_key_enabled: bool,
521    /// Rate limiting configuration
522    pub rate_limiting_enabled: bool,
523    pub requests_per_minute: u32,
524    pub rate_limit_burst: u32,
525    /// Audit logging configuration
526    pub audit_enabled: bool,
527    pub audit_retention_days: u32,
528    /// PII detection configuration
529    pub pii_detection_enabled: bool,
530    pub pii_mask_logs: bool,
531    /// GDPR compliance configuration
532    pub gdpr_enabled: bool,
533    pub gdpr_retention_days: u32,
534    pub right_to_be_forgotten: bool,
535    /// Secrets management configuration
536    pub vault_enabled: bool,
537    pub vault_address: Option<String>,
538    pub vault_token_path: Option<PathBuf>,
539    /// Input validation configuration
540    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, // 2 years
565            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        // Ollama provider with base_url should be valid
592        assert!(config.validate().is_ok());
593
594        // OpenAI provider without API key should fail
595        config.embedding.provider = "openai".to_string();
596        config.embedding.api_key = String::new();
597        assert!(config.validate().is_err());
598
599        // OpenAI provider with API key should pass
600        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        // Invalid provider should fail
609        config.embedding.provider = "invalid".to_string();
610        assert!(config.validate().is_err());
611
612        // Empty model should fail
613        config.embedding.provider = "ollama".to_string();
614        config.embedding.model = String::new();
615        assert!(config.validate().is_err());
616
617        // Ollama without base_url should fail
618        config.embedding.model = "test-model".to_string();
619        config.embedding.base_url = String::new();
620        assert!(config.validate().is_err());
621    }
622}