ricecoder_orchestration/managers/
config_manager.rs

1//! Configuration management for workspace orchestration
2//!
3//! Handles loading and applying workspace-level configuration with support for
4//! configuration hierarchy (workspace → project → user → defaults) and validation.
5
6use crate::error::{OrchestrationError, Result};
7use crate::models::{RuleType, WorkspaceConfig, WorkspaceRule};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13/// Configuration manager for workspace orchestration
14///
15/// Loads and applies workspace-level configuration with support for:
16/// - Configuration hierarchy (workspace → project → user → defaults)
17/// - Configuration validation against schema
18/// - Runtime configuration updates
19#[derive(Debug, Clone)]
20pub struct ConfigManager {
21    /// Workspace root path
22    workspace_root: PathBuf,
23
24    /// Loaded configuration
25    config: WorkspaceConfig,
26
27    /// Configuration schema for validation
28    schema: ConfigSchema,
29}
30
31/// Schema for validating workspace configuration
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ConfigSchema {
34    /// Required configuration keys
35    pub required_keys: Vec<String>,
36
37    /// Optional configuration keys with defaults
38    pub optional_keys: HashMap<String, Value>,
39
40    /// Validation rules for configuration values
41    pub validation_rules: HashMap<String, ValidationRule>,
42}
43
44/// Validation rule for a configuration value
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ValidationRule {
47    /// Rule type (e.g., "string", "number", "array")
48    pub rule_type: String,
49
50    /// Minimum value (for numbers)
51    pub min: Option<f64>,
52
53    /// Maximum value (for numbers)
54    pub max: Option<f64>,
55
56    /// Allowed values (for enums)
57    pub allowed_values: Option<Vec<String>>,
58
59    /// Pattern for string validation (regex)
60    pub pattern: Option<String>,
61}
62
63/// Configuration source priority
64#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
65pub enum ConfigSource {
66    /// Built-in defaults (lowest priority)
67    Defaults = 0,
68
69    /// User-level configuration (~/.ricecoder/config.yaml)
70    User = 1,
71
72    /// Project-level configuration (.ricecoder/project.yaml)
73    Project = 2,
74
75    /// Workspace-level configuration (.ricecoder/workspace.yaml)
76    Workspace = 3,
77
78    /// Runtime overrides (highest priority)
79    Runtime = 4,
80}
81
82/// Configuration load result with source tracking
83#[derive(Debug, Clone)]
84pub struct ConfigLoadResult {
85    /// Loaded configuration
86    pub config: WorkspaceConfig,
87
88    /// Source of each configuration value
89    pub sources: HashMap<String, ConfigSource>,
90
91    /// Warnings during configuration loading
92    pub warnings: Vec<String>,
93}
94
95impl ConfigManager {
96    /// Creates a new configuration manager
97    pub fn new(workspace_root: PathBuf) -> Self {
98        Self {
99            workspace_root,
100            config: WorkspaceConfig::default(),
101            schema: ConfigSchema::default(),
102        }
103    }
104
105    /// Loads configuration from the configuration hierarchy
106    ///
107    /// Configuration is loaded in priority order:
108    /// 1. Built-in defaults
109    /// 2. User-level configuration (~/.ricecoder/config.yaml)
110    /// 3. Project-level configuration (.ricecoder/project.yaml)
111    /// 4. Workspace-level configuration (.ricecoder/workspace.yaml)
112    ///
113    /// Later sources override earlier sources.
114    pub async fn load_configuration(&mut self) -> Result<ConfigLoadResult> {
115        let mut config = WorkspaceConfig::default();
116        let mut sources = HashMap::new();
117        let warnings = Vec::new();
118
119        // Load defaults
120        let defaults = self.load_defaults();
121        config = self.merge_configs(config, defaults.clone());
122        for key in defaults.settings.as_object().unwrap_or(&Default::default()).keys() {
123            sources.insert(key.clone(), ConfigSource::Defaults);
124        }
125
126        // Load user-level configuration
127        if let Ok(user_config) = self.load_user_config().await {
128            config = self.merge_configs(config, user_config.clone());
129            for key in user_config.settings.as_object().unwrap_or(&Default::default()).keys() {
130                sources.insert(key.clone(), ConfigSource::User);
131            }
132        }
133
134        // Load project-level configuration
135        if let Ok(project_config) = self.load_project_config().await {
136            config = self.merge_configs(config, project_config.clone());
137            for key in project_config.settings.as_object().unwrap_or(&Default::default()).keys() {
138                sources.insert(key.clone(), ConfigSource::Project);
139            }
140        }
141
142        // Load workspace-level configuration
143        if let Ok(workspace_config) = self.load_workspace_config().await {
144            config = self.merge_configs(config, workspace_config.clone());
145            for key in workspace_config.settings.as_object().unwrap_or(&Default::default()).keys() {
146                sources.insert(key.clone(), ConfigSource::Workspace);
147            }
148        }
149
150        // Validate configuration
151        self.validate_config(&config)?;
152
153        self.config = config.clone();
154
155        Ok(ConfigLoadResult {
156            config,
157            sources,
158            warnings,
159        })
160    }
161
162    /// Loads default configuration
163    fn load_defaults(&self) -> WorkspaceConfig {
164        WorkspaceConfig {
165            rules: vec![
166                WorkspaceRule {
167                    name: "no-circular-deps".to_string(),
168                    rule_type: RuleType::DependencyConstraint,
169                    enabled: true,
170                },
171                WorkspaceRule {
172                    name: "naming-convention".to_string(),
173                    rule_type: RuleType::NamingConvention,
174                    enabled: true,
175                },
176            ],
177            settings: json!({
178                "max_parallel_operations": 4,
179                "transaction_timeout_ms": 30000,
180                "enable_audit_logging": true,
181            }),
182        }
183    }
184
185    /// Loads user-level configuration from ~/.ricecoder/config.yaml
186    async fn load_user_config(&self) -> Result<WorkspaceConfig> {
187        let user_home = std::env::var("HOME")
188            .or_else(|_| std::env::var("USERPROFILE"))
189            .map_err(|_| crate::error::OrchestrationError::ConfigurationError(
190                "Could not determine user home directory".to_string(),
191            ))?;
192
193        let config_path = PathBuf::from(user_home)
194            .join(".ricecoder")
195            .join("config.yaml");
196
197        if !config_path.exists() {
198            return Err(OrchestrationError::ConfigurationError(
199                format!("User config not found: {}", config_path.display()),
200            ));
201        }
202
203        self.load_config_from_file(&config_path).await
204    }
205
206    /// Loads project-level configuration from .ricecoder/project.yaml
207    async fn load_project_config(&self) -> Result<WorkspaceConfig> {
208        let config_path = self.workspace_root
209            .join(".ricecoder")
210            .join("project.yaml");
211
212        if !config_path.exists() {
213            return Err(crate::error::OrchestrationError::ConfigurationError(
214                format!("Project config not found: {}", config_path.display()),
215            ));
216        }
217
218        self.load_config_from_file(&config_path).await
219    }
220
221    /// Loads workspace-level configuration from .ricecoder/workspace.yaml
222    async fn load_workspace_config(&self) -> Result<WorkspaceConfig> {
223        let config_path = self.workspace_root
224            .join(".ricecoder")
225            .join("workspace.yaml");
226
227        if !config_path.exists() {
228            return Err(crate::error::OrchestrationError::ConfigurationError(
229                format!("Workspace config not found: {}", config_path.display()),
230            ));
231        }
232
233        self.load_config_from_file(&config_path).await
234    }
235
236    /// Loads configuration from a YAML file
237    async fn load_config_from_file(&self, path: &Path) -> Result<WorkspaceConfig> {
238        let content = tokio::fs::read_to_string(path)
239            .await
240            .map_err(crate::error::OrchestrationError::IoError)?;
241
242        let config: WorkspaceConfig = serde_yaml::from_str(&content)?;
243
244        Ok(config)
245    }
246
247    /// Merges two configurations, with the second overriding the first
248    pub fn merge_configs(&self, mut base: WorkspaceConfig, override_config: WorkspaceConfig) -> WorkspaceConfig {
249        // Merge rules
250        for rule in override_config.rules {
251            if let Some(pos) = base.rules.iter().position(|r| r.name == rule.name) {
252                base.rules[pos] = rule;
253            } else {
254                base.rules.push(rule);
255            }
256        }
257
258        // Merge settings
259        if let (Some(base_obj), Some(override_obj)) = (
260            base.settings.as_object_mut(),
261            override_config.settings.as_object(),
262        ) {
263            for (key, value) in override_obj {
264                base_obj.insert(key.clone(), value.clone());
265            }
266        }
267
268        base
269    }
270
271    /// Validates configuration against the schema
272    fn validate_config(&self, config: &WorkspaceConfig) -> Result<()> {
273        // Validate rules
274        for rule in &config.rules {
275            if rule.name.is_empty() {
276                return Err(crate::error::OrchestrationError::ConfigurationError(
277                    "Rule name cannot be empty".to_string(),
278                ));
279            }
280        }
281
282        // Validate settings
283        if let Some(settings_obj) = config.settings.as_object() {
284            for (key, value) in settings_obj {
285                if let Some(validation_rule) = self.schema.validation_rules.get(key) {
286                    self.validate_value(key, value, validation_rule)?;
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Validates a configuration value against a validation rule
295    pub fn validate_value(&self, key: &str, value: &Value, rule: &ValidationRule) -> Result<()> {
296        match rule.rule_type.as_str() {
297            "number" => {
298                if let Some(num) = value.as_f64() {
299                    if let Some(min) = rule.min {
300                        if num < min {
301                            return Err(crate::error::OrchestrationError::ConfigurationError(
302                                format!("{} must be >= {}", key, min),
303                            ));
304                        }
305                    }
306                    if let Some(max) = rule.max {
307                        if num > max {
308                            return Err(crate::error::OrchestrationError::ConfigurationError(
309                                format!("{} must be <= {}", key, max),
310                            ));
311                        }
312                    }
313                } else {
314                    return Err(crate::error::OrchestrationError::ConfigurationError(
315                        format!("{} must be a number", key),
316                    ));
317                }
318            }
319            "string" => {
320                if !value.is_string() {
321                    return Err(crate::error::OrchestrationError::ConfigurationError(
322                        format!("{} must be a string", key),
323                    ));
324                }
325            }
326            "boolean" => {
327                if !value.is_boolean() {
328                    return Err(crate::error::OrchestrationError::ConfigurationError(
329                        format!("{} must be a boolean", key),
330                    ));
331                }
332            }
333            "array" => {
334                if !value.is_array() {
335                    return Err(crate::error::OrchestrationError::ConfigurationError(
336                        format!("{} must be an array", key),
337                    ));
338                }
339            }
340            _ => {}
341        }
342
343        Ok(())
344    }
345
346    /// Gets the current configuration
347    pub fn get_config(&self) -> &WorkspaceConfig {
348        &self.config
349    }
350
351    /// Gets a configuration setting by key
352    pub fn get_setting(&self, key: &str) -> Option<&Value> {
353        self.config.settings.get(key)
354    }
355
356    /// Sets a configuration setting at runtime
357    pub fn set_setting(&mut self, key: String, value: Value) -> Result<()> {
358        if let Some(obj) = self.config.settings.as_object_mut() {
359            obj.insert(key, value);
360            Ok(())
361        } else {
362            Err(crate::error::OrchestrationError::ConfigurationError(
363                "Settings is not an object".to_string(),
364            ))
365        }
366    }
367
368    /// Gets all rules
369    pub fn get_rules(&self) -> &[WorkspaceRule] {
370        &self.config.rules
371    }
372
373    /// Gets a rule by name
374    pub fn get_rule(&self, name: &str) -> Option<&WorkspaceRule> {
375        self.config.rules.iter().find(|r| r.name == name)
376    }
377
378    /// Enables a rule by name
379    pub fn enable_rule(&mut self, name: &str) -> Result<()> {
380        if let Some(rule) = self.config.rules.iter_mut().find(|r| r.name == name) {
381            rule.enabled = true;
382            Ok(())
383        } else {
384            Err(crate::error::OrchestrationError::RulesValidationFailed(
385                format!("Rule not found: {}", name),
386            ))
387        }
388    }
389
390    /// Disables a rule by name
391    pub fn disable_rule(&mut self, name: &str) -> Result<()> {
392        if let Some(rule) = self.config.rules.iter_mut().find(|r| r.name == name) {
393            rule.enabled = false;
394            Ok(())
395        } else {
396            Err(crate::error::OrchestrationError::RulesValidationFailed(
397                format!("Rule not found: {}", name),
398            ))
399        }
400    }
401}
402
403impl Default for ConfigSchema {
404    fn default() -> Self {
405        let mut validation_rules = HashMap::new();
406
407        validation_rules.insert(
408            "max_parallel_operations".to_string(),
409            ValidationRule {
410                rule_type: "number".to_string(),
411                min: Some(1.0),
412                max: Some(32.0),
413                allowed_values: None,
414                pattern: None,
415            },
416        );
417
418        validation_rules.insert(
419            "transaction_timeout_ms".to_string(),
420            ValidationRule {
421                rule_type: "number".to_string(),
422                min: Some(1000.0),
423                max: Some(300000.0),
424                allowed_values: None,
425                pattern: None,
426            },
427        );
428
429        validation_rules.insert(
430            "enable_audit_logging".to_string(),
431            ValidationRule {
432                rule_type: "boolean".to_string(),
433                min: None,
434                max: None,
435                allowed_values: None,
436                pattern: None,
437            },
438        );
439
440        Self {
441            required_keys: vec![],
442            optional_keys: HashMap::new(),
443            validation_rules,
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_config_manager_creation() {
454        let manager = ConfigManager::new(PathBuf::from("/workspace"));
455        assert_eq!(manager.workspace_root, PathBuf::from("/workspace"));
456    }
457
458    #[test]
459    fn test_load_defaults() {
460        let manager = ConfigManager::new(PathBuf::from("/workspace"));
461        let defaults = manager.load_defaults();
462
463        assert!(!defaults.rules.is_empty());
464        assert!(defaults.settings.get("max_parallel_operations").is_some());
465    }
466
467    #[test]
468    fn test_merge_configs() {
469        let manager = ConfigManager::new(PathBuf::from("/workspace"));
470
471        let base = WorkspaceConfig {
472            rules: vec![WorkspaceRule {
473                name: "rule1".to_string(),
474                rule_type: RuleType::DependencyConstraint,
475                enabled: true,
476            }],
477            settings: json!({"key1": "value1"}),
478        };
479
480        let override_config = WorkspaceConfig {
481            rules: vec![WorkspaceRule {
482                name: "rule2".to_string(),
483                rule_type: RuleType::NamingConvention,
484                enabled: false,
485            }],
486            settings: json!({"key2": "value2"}),
487        };
488
489        let merged = manager.merge_configs(base, override_config);
490
491        assert_eq!(merged.rules.len(), 2);
492        assert!(merged.settings.get("key1").is_some());
493        assert!(merged.settings.get("key2").is_some());
494    }
495
496    #[test]
497    fn test_validate_config_success() {
498        let manager = ConfigManager::new(PathBuf::from("/workspace"));
499        let config = WorkspaceConfig {
500            rules: vec![WorkspaceRule {
501                name: "test-rule".to_string(),
502                rule_type: RuleType::DependencyConstraint,
503                enabled: true,
504            }],
505            settings: json!({"max_parallel_operations": 4}),
506        };
507
508        assert!(manager.validate_config(&config).is_ok());
509    }
510
511    #[test]
512    fn test_validate_config_empty_rule_name() {
513        let manager = ConfigManager::new(PathBuf::from("/workspace"));
514        let config = WorkspaceConfig {
515            rules: vec![WorkspaceRule {
516                name: "".to_string(),
517                rule_type: RuleType::DependencyConstraint,
518                enabled: true,
519            }],
520            settings: json!({}),
521        };
522
523        assert!(manager.validate_config(&config).is_err());
524    }
525
526    #[test]
527    fn test_get_setting() {
528        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
529        manager.config = WorkspaceConfig {
530            rules: vec![],
531            settings: json!({"key1": "value1"}),
532        };
533
534        assert_eq!(manager.get_setting("key1"), Some(&Value::String("value1".to_string())));
535        assert_eq!(manager.get_setting("nonexistent"), None);
536    }
537
538    #[test]
539    fn test_set_setting() {
540        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
541        manager.config = WorkspaceConfig {
542            rules: vec![],
543            settings: json!({}),
544        };
545
546        assert!(manager.set_setting("key1".to_string(), Value::String("value1".to_string())).is_ok());
547        assert_eq!(manager.get_setting("key1"), Some(&Value::String("value1".to_string())));
548    }
549
550    #[test]
551    fn test_get_rules() {
552        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
553        let rule = WorkspaceRule {
554            name: "test-rule".to_string(),
555            rule_type: RuleType::DependencyConstraint,
556            enabled: true,
557        };
558        manager.config.rules.push(rule);
559
560        assert_eq!(manager.get_rules().len(), 1);
561    }
562
563    #[test]
564    fn test_get_rule_by_name() {
565        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
566        let rule = WorkspaceRule {
567            name: "test-rule".to_string(),
568            rule_type: RuleType::DependencyConstraint,
569            enabled: true,
570        };
571        manager.config.rules.push(rule);
572
573        assert!(manager.get_rule("test-rule").is_some());
574        assert!(manager.get_rule("nonexistent").is_none());
575    }
576
577    #[test]
578    fn test_enable_rule() {
579        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
580        let rule = WorkspaceRule {
581            name: "test-rule".to_string(),
582            rule_type: RuleType::DependencyConstraint,
583            enabled: false,
584        };
585        manager.config.rules.push(rule);
586
587        assert!(manager.enable_rule("test-rule").is_ok());
588        assert!(manager.get_rule("test-rule").unwrap().enabled);
589    }
590
591    #[test]
592    fn test_disable_rule() {
593        let mut manager = ConfigManager::new(PathBuf::from("/workspace"));
594        let rule = WorkspaceRule {
595            name: "test-rule".to_string(),
596            rule_type: RuleType::DependencyConstraint,
597            enabled: true,
598        };
599        manager.config.rules.push(rule);
600
601        assert!(manager.disable_rule("test-rule").is_ok());
602        assert!(!manager.get_rule("test-rule").unwrap().enabled);
603    }
604
605    #[test]
606    fn test_config_schema_default() {
607        let schema = ConfigSchema::default();
608        assert!(schema.validation_rules.contains_key("max_parallel_operations"));
609        assert!(schema.validation_rules.contains_key("transaction_timeout_ms"));
610        assert!(schema.validation_rules.contains_key("enable_audit_logging"));
611    }
612
613    #[test]
614    fn test_validate_number_value() {
615        let manager = ConfigManager::new(PathBuf::from("/workspace"));
616        let rule = ValidationRule {
617            rule_type: "number".to_string(),
618            min: Some(1.0),
619            max: Some(10.0),
620            allowed_values: None,
621            pattern: None,
622        };
623
624        assert!(manager.validate_value("test", &Value::Number(5.into()), &rule).is_ok());
625        assert!(manager.validate_value("test", &Value::Number(0.into()), &rule).is_err());
626        assert!(manager.validate_value("test", &Value::Number(11.into()), &rule).is_err());
627    }
628
629    #[test]
630    fn test_validate_string_value() {
631        let manager = ConfigManager::new(PathBuf::from("/workspace"));
632        let rule = ValidationRule {
633            rule_type: "string".to_string(),
634            min: None,
635            max: None,
636            allowed_values: None,
637            pattern: None,
638        };
639
640        assert!(manager.validate_value("test", &Value::String("value".to_string()), &rule).is_ok());
641        assert!(manager.validate_value("test", &Value::Number(5.into()), &rule).is_err());
642    }
643
644    #[test]
645    fn test_validate_boolean_value() {
646        let manager = ConfigManager::new(PathBuf::from("/workspace"));
647        let rule = ValidationRule {
648            rule_type: "boolean".to_string(),
649            min: None,
650            max: None,
651            allowed_values: None,
652            pattern: None,
653        };
654
655        assert!(manager.validate_value("test", &Value::Bool(true), &rule).is_ok());
656        assert!(manager.validate_value("test", &Value::String("true".to_string()), &rule).is_err());
657    }
658}