Skip to main content

prodigy/config/
prodigy_config.rs

1//! Unified configuration structure for Prodigy using premortem.
2//!
3//! This module provides `ProdigyConfig`, a single entry point for all configuration
4//! access. It uses premortem's builder pattern for layered source loading and
5//! stillwater's `Validation` for comprehensive error accumulation.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use prodigy::config::load_prodigy_config;
11//!
12//! let config = load_prodigy_config().expect("config errors");
13//! println!("Max concurrent specs: {}", config.max_concurrent_specs);
14//! ```
15//!
16//! # Testing with MockEnv
17//!
18//! ```
19//! use prodigy::config::load_prodigy_config_with;
20//! use premortem::MockEnv;
21//!
22//! let env = MockEnv::new()
23//!     .with_env("PRODIGY__LOG_LEVEL", "debug");
24//!
25//! let config = load_prodigy_config_with(&env).expect("load failed");
26//! assert_eq!(config.log_level, "debug");
27//! ```
28
29use premortem::prelude::*;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::path::PathBuf;
33
34/// Valid log levels for configuration validation.
35pub const VALID_LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
36
37/// Unified configuration for Prodigy.
38///
39/// Combines global settings, project settings, and runtime settings into a single
40/// struct. Configuration is loaded from multiple sources with layered precedence:
41///
42/// 1. Hardcoded defaults (lowest priority)
43/// 2. Global config file (`~/.prodigy/config.yml`)
44/// 3. Project config file (`.prodigy/config.yml`)
45/// 4. Environment variables (`PRODIGY_*` prefix) (highest priority)
46///
47/// # Validation
48///
49/// All fields are validated during loading. Invalid configurations result in
50/// accumulated errors - all issues are reported at once, not just the first one.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ProdigyConfig {
53    /// Logging level (trace, debug, info, warn, error).
54    #[serde(default = "default_log_level")]
55    pub log_level: String,
56
57    /// Claude API key for authentication.
58    /// Optional - can be set via environment variable PRODIGY_CLAUDE_API_KEY.
59    #[serde(default)]
60    pub claude_api_key: Option<String>,
61
62    /// Maximum number of specs to process concurrently in MapReduce workflows.
63    #[serde(default = "default_max_concurrent")]
64    pub max_concurrent_specs: usize,
65
66    /// Whether to automatically commit changes after successful operations.
67    #[serde(default = "default_auto_commit")]
68    pub auto_commit: bool,
69
70    /// Default editor for interactive editing.
71    #[serde(default)]
72    pub default_editor: Option<String>,
73
74    /// Prodigy home directory for storing data and configuration.
75    #[serde(default)]
76    pub prodigy_home: Option<PathBuf>,
77
78    /// Project-specific settings.
79    #[serde(default)]
80    pub project: Option<ProjectSettings>,
81
82    /// Storage configuration.
83    #[serde(default)]
84    pub storage: StorageSettings,
85
86    /// Plugin configuration.
87    #[serde(default)]
88    pub plugins: PluginConfig,
89}
90
91/// Project-specific configuration settings.
92///
93/// These settings apply to a specific project and override global defaults.
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct ProjectSettings {
96    /// Project name.
97    #[serde(default)]
98    pub name: Option<String>,
99
100    /// Project description.
101    #[serde(default)]
102    pub description: Option<String>,
103
104    /// Project version.
105    #[serde(default)]
106    pub version: Option<String>,
107
108    /// Directory containing spec files.
109    #[serde(default)]
110    pub spec_dir: Option<PathBuf>,
111
112    /// Project-level API key (overrides global).
113    #[serde(default)]
114    pub claude_api_key: Option<String>,
115
116    /// Project-level auto-commit setting (overrides global).
117    #[serde(default)]
118    pub auto_commit: Option<bool>,
119
120    /// Custom variables for this project.
121    #[serde(default)]
122    pub variables: HashMap<String, serde_json::Value>,
123}
124
125/// Storage backend configuration.
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct StorageSettings {
128    /// Storage backend type.
129    #[serde(default)]
130    pub backend: BackendType,
131
132    /// Base path for storage (if applicable to backend).
133    #[serde(default)]
134    pub base_path: Option<PathBuf>,
135
136    /// Compression level for checkpoints (0-9, 0 = none).
137    #[serde(default)]
138    pub compression_level: u8,
139}
140
141/// Plugin configuration for extending Prodigy functionality.
142///
143/// Plugins are loaded from a directory and can provide custom commands
144/// and workflows.
145#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct PluginConfig {
147    /// Whether plugins are enabled.
148    #[serde(default)]
149    pub enabled: bool,
150
151    /// Directory containing plugin files.
152    #[serde(default)]
153    pub directory: Option<PathBuf>,
154
155    /// List of plugins to auto-load on startup.
156    #[serde(default)]
157    pub auto_load: Vec<String>,
158}
159
160/// Supported storage backend types.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "lowercase")]
163pub enum BackendType {
164    /// File-system based storage (default).
165    #[default]
166    FileSystem,
167    /// In-memory storage (for testing).
168    Memory,
169}
170
171impl Default for ProdigyConfig {
172    fn default() -> Self {
173        Self {
174            log_level: default_log_level(),
175            claude_api_key: None,
176            max_concurrent_specs: default_max_concurrent(),
177            auto_commit: default_auto_commit(),
178            default_editor: None,
179            prodigy_home: None,
180            project: None,
181            storage: StorageSettings::default(),
182            plugins: PluginConfig::default(),
183        }
184    }
185}
186
187// Default value functions for serde
188fn default_log_level() -> String {
189    "info".to_string()
190}
191
192fn default_max_concurrent() -> usize {
193    4
194}
195
196fn default_auto_commit() -> bool {
197    true
198}
199
200impl ProdigyConfig {
201    /// Get the effective Claude API key (project overrides global).
202    ///
203    /// This method implements proper precedence handling:
204    /// 1. Project-level API key (if set)
205    /// 2. Global API key (if set)
206    ///
207    /// Note: Environment variables are already merged into these fields
208    /// during configuration loading.
209    #[deprecated(since = "0.6.0", note = "Use effective_api_key() instead")]
210    pub fn get_claude_api_key(&self) -> Option<&str> {
211        self.effective_api_key()
212    }
213
214    /// Get the effective Claude API key with proper precedence.
215    ///
216    /// Precedence (highest to lowest):
217    /// 1. Project-level API key
218    /// 2. Global API key
219    ///
220    /// Environment variables are merged during loading, so they're already
221    /// reflected in these fields based on when they were applied.
222    pub fn effective_api_key(&self) -> Option<&str> {
223        self.project
224            .as_ref()
225            .and_then(|p| p.claude_api_key.as_deref())
226            .or(self.claude_api_key.as_deref())
227    }
228
229    /// Get the effective auto-commit setting (project overrides global).
230    #[deprecated(since = "0.6.0", note = "Use effective_auto_commit() instead")]
231    pub fn get_auto_commit(&self) -> bool {
232        self.effective_auto_commit()
233    }
234
235    /// Get the effective auto-commit setting with proper precedence.
236    ///
237    /// Precedence (highest to lowest):
238    /// 1. Project-level setting (if set)
239    /// 2. Global setting
240    pub fn effective_auto_commit(&self) -> bool {
241        self.project
242            .as_ref()
243            .and_then(|p| p.auto_commit)
244            .unwrap_or(self.auto_commit)
245    }
246
247    /// Get the spec directory path (defaults to "specs").
248    pub fn get_spec_dir(&self) -> PathBuf {
249        self.project
250            .as_ref()
251            .and_then(|p| p.spec_dir.clone())
252            .unwrap_or_else(|| PathBuf::from("specs"))
253    }
254
255    /// Get the prodigy home directory.
256    ///
257    /// Falls back to `~/.prodigy` if not explicitly set.
258    pub fn get_prodigy_home(&self) -> PathBuf {
259        self.prodigy_home.clone().unwrap_or_else(|| {
260            dirs::home_dir()
261                .map(|h| h.join(".prodigy"))
262                .unwrap_or_else(|| PathBuf::from("~/.prodigy"))
263        })
264    }
265
266    /// Get the effective default editor.
267    ///
268    /// Returns the configured editor or None if not set.
269    pub fn effective_editor(&self) -> Option<&str> {
270        self.default_editor.as_deref()
271    }
272
273    /// Get the effective max concurrent specs.
274    pub fn effective_max_concurrent(&self) -> usize {
275        self.max_concurrent_specs
276    }
277
278    /// Get the effective log level.
279    pub fn effective_log_level(&self) -> &str {
280        &self.log_level
281    }
282}
283
284impl Validate for ProdigyConfig {
285    fn validate(&self) -> ConfigValidation<()> {
286        let mut errors = Vec::new();
287
288        // Validate log_level is not empty
289        if self.log_level.is_empty() {
290            errors.push(ConfigError::ValidationError {
291                path: "log_level".to_string(),
292                source_location: None,
293                value: Some(self.log_level.clone()),
294                message: "log_level cannot be empty".to_string(),
295            });
296        } else if !VALID_LOG_LEVELS.contains(&self.log_level.as_str()) {
297            // Validate log_level is one of the allowed values
298            errors.push(ConfigError::ValidationError {
299                path: "log_level".to_string(),
300                source_location: None,
301                value: Some(self.log_level.clone()),
302                message: format!("log_level must be one of: {}", VALID_LOG_LEVELS.join(", ")),
303            });
304        }
305
306        // Validate max_concurrent_specs is in range 1..=100
307        if self.max_concurrent_specs == 0 || self.max_concurrent_specs > 100 {
308            errors.push(ConfigError::ValidationError {
309                path: "max_concurrent_specs".to_string(),
310                source_location: None,
311                value: Some(self.max_concurrent_specs.to_string()),
312                message: "max_concurrent_specs must be between 1 and 100".to_string(),
313            });
314        }
315
316        // Validate storage.compression_level is in range 0..=9
317        if self.storage.compression_level > 9 {
318            errors.push(ConfigError::ValidationError {
319                path: "storage.compression_level".to_string(),
320                source_location: None,
321                value: Some(self.storage.compression_level.to_string()),
322                message: "storage.compression_level must be between 0 and 9".to_string(),
323            });
324        }
325
326        // Validate project settings if present
327        if let Some(ref project) = self.project {
328            // Validate project.name is non-empty when provided
329            if let Some(ref name) = project.name {
330                if name.is_empty() {
331                    errors.push(ConfigError::ValidationError {
332                        path: "project.name".to_string(),
333                        source_location: None,
334                        value: Some(name.clone()),
335                        message: "project.name cannot be empty when provided".to_string(),
336                    });
337                }
338            }
339
340            // Cross-field validation: spec_dir should be a relative path
341            if let Some(ref spec_dir) = project.spec_dir {
342                if spec_dir.is_absolute() {
343                    errors.push(ConfigError::ValidationError {
344                        path: "project.spec_dir".to_string(),
345                        source_location: None,
346                        value: Some(spec_dir.display().to_string()),
347                        message: "project.spec_dir should be a relative path".to_string(),
348                    });
349                }
350            }
351        }
352
353        match ConfigErrors::from_vec(errors) {
354            Some(errs) => Validation::Failure(errs),
355            None => Validation::Success(()),
356        }
357    }
358}
359
360/// Returns the global config file path.
361///
362/// This is `~/.prodigy/config.yml`.
363pub fn global_config_path() -> PathBuf {
364    dirs::home_dir()
365        .map(|h| h.join(".prodigy").join("config.yml"))
366        .unwrap_or_else(|| PathBuf::from("~/.prodigy/config.yml"))
367}
368
369/// Returns the project config file path.
370///
371/// This is `.prodigy/config.yml` in the current directory.
372pub fn project_config_path() -> PathBuf {
373    PathBuf::from(".prodigy/config.yml")
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    #[test]
381    fn test_prodigy_config_default() {
382        let config = ProdigyConfig::default();
383
384        assert_eq!(config.log_level, "info");
385        assert!(config.claude_api_key.is_none());
386        assert_eq!(config.max_concurrent_specs, 4);
387        assert!(config.auto_commit);
388        assert!(config.default_editor.is_none());
389        assert!(config.project.is_none());
390        assert_eq!(config.storage.backend, BackendType::FileSystem);
391    }
392
393    #[test]
394    fn test_get_spec_dir() {
395        let mut config = ProdigyConfig::default();
396
397        // Default value
398        assert_eq!(config.get_spec_dir(), PathBuf::from("specs"));
399
400        // Project setting
401        config.project = Some(ProjectSettings {
402            spec_dir: Some(PathBuf::from("custom/specs")),
403            ..Default::default()
404        });
405        assert_eq!(config.get_spec_dir(), PathBuf::from("custom/specs"));
406    }
407
408    #[test]
409    fn test_yaml_deserialization() {
410        let yaml = r#"
411log_level: debug
412max_concurrent_specs: 8
413auto_commit: false
414project:
415  name: test-project
416  spec_dir: my-specs
417storage:
418  backend: memory
419  compression_level: 6
420"#;
421
422        let config: ProdigyConfig = serde_yaml::from_str(yaml).unwrap();
423
424        assert_eq!(config.log_level, "debug");
425        assert_eq!(config.max_concurrent_specs, 8);
426        assert!(!config.auto_commit);
427
428        let project = config.project.unwrap();
429        assert_eq!(project.name, Some("test-project".to_string()));
430        assert_eq!(project.spec_dir, Some(PathBuf::from("my-specs")));
431
432        assert_eq!(config.storage.backend, BackendType::Memory);
433        assert_eq!(config.storage.compression_level, 6);
434    }
435
436    #[test]
437    fn test_validation() {
438        // Valid config
439        let config = ProdigyConfig::default();
440        let result = config.validate();
441        assert!(matches!(result, Validation::Success(_)));
442
443        // Invalid max_concurrent_specs (0 is out of range 1..=100)
444        let invalid_config = ProdigyConfig {
445            max_concurrent_specs: 0,
446            ..Default::default()
447        };
448        let result = invalid_config.validate();
449        assert!(matches!(result, Validation::Failure(_)));
450    }
451
452    #[test]
453    fn test_storage_settings_validation_via_config() {
454        // Valid compression level through ProdigyConfig
455        let config = ProdigyConfig {
456            storage: StorageSettings {
457                backend: BackendType::FileSystem,
458                base_path: None,
459                compression_level: 6,
460            },
461            ..Default::default()
462        };
463        let result = config.validate();
464        assert!(matches!(result, Validation::Success(_)));
465
466        // Invalid compression level (out of range)
467        let invalid_config = ProdigyConfig {
468            storage: StorageSettings {
469                backend: BackendType::FileSystem,
470                base_path: None,
471                compression_level: 10, // Invalid: max is 9
472            },
473            ..Default::default()
474        };
475        let result = invalid_config.validate();
476        assert!(matches!(result, Validation::Failure(_)));
477    }
478
479    #[test]
480    fn test_backend_type_serialization() {
481        assert_eq!(
482            serde_json::to_string(&BackendType::FileSystem).unwrap(),
483            "\"filesystem\""
484        );
485        assert_eq!(
486            serde_json::to_string(&BackendType::Memory).unwrap(),
487            "\"memory\""
488        );
489
490        let fs: BackendType = serde_json::from_str("\"filesystem\"").unwrap();
491        assert_eq!(fs, BackendType::FileSystem);
492
493        let mem: BackendType = serde_json::from_str("\"memory\"").unwrap();
494        assert_eq!(mem, BackendType::Memory);
495    }
496
497    #[test]
498    fn test_validation_log_level_valid_values() {
499        for level in VALID_LOG_LEVELS {
500            let config = ProdigyConfig {
501                log_level: level.to_string(),
502                ..Default::default()
503            };
504            let result = config.validate();
505            assert!(
506                matches!(result, Validation::Success(_)),
507                "log_level '{}' should be valid",
508                level
509            );
510        }
511    }
512
513    #[test]
514    fn test_validation_log_level_invalid() {
515        let config = ProdigyConfig {
516            log_level: "invalid_level".to_string(),
517            ..Default::default()
518        };
519        let result = config.validate();
520
521        match result {
522            Validation::Failure(errors) => {
523                assert!(
524                    errors.iter().any(|e| {
525                        matches!(e, ConfigError::ValidationError { path, .. } if path == "log_level")
526                    }),
527                    "Expected validation error for log_level"
528                );
529            }
530            Validation::Success(_) => panic!("Expected validation to fail for invalid log_level"),
531        }
532    }
533
534    #[test]
535    fn test_validation_project_name_empty() {
536        let config = ProdigyConfig {
537            project: Some(ProjectSettings {
538                name: Some("".to_string()),
539                ..Default::default()
540            }),
541            ..Default::default()
542        };
543        let result = config.validate();
544
545        match result {
546            Validation::Failure(errors) => {
547                assert!(
548                    errors.iter().any(|e| {
549                        matches!(e, ConfigError::ValidationError { path, .. } if path == "project.name")
550                    }),
551                    "Expected validation error for project.name"
552                );
553            }
554            Validation::Success(_) => {
555                panic!("Expected validation to fail for empty project.name")
556            }
557        }
558    }
559
560    #[test]
561    fn test_validation_spec_dir_absolute_path() {
562        let config = ProdigyConfig {
563            project: Some(ProjectSettings {
564                spec_dir: Some(PathBuf::from("/absolute/path/to/specs")),
565                ..Default::default()
566            }),
567            ..Default::default()
568        };
569        let result = config.validate();
570
571        match result {
572            Validation::Failure(errors) => {
573                assert!(
574                    errors.iter().any(|e| {
575                        matches!(e, ConfigError::ValidationError { path, .. } if path == "project.spec_dir")
576                    }),
577                    "Expected validation error for project.spec_dir"
578                );
579            }
580            Validation::Success(_) => {
581                panic!("Expected validation to fail for absolute spec_dir")
582            }
583        }
584    }
585
586    #[test]
587    fn test_validation_spec_dir_relative_path_valid() {
588        let config = ProdigyConfig {
589            project: Some(ProjectSettings {
590                spec_dir: Some(PathBuf::from("specs")),
591                ..Default::default()
592            }),
593            ..Default::default()
594        };
595        let result = config.validate();
596        assert!(
597            matches!(result, Validation::Success(_)),
598            "Relative spec_dir should be valid"
599        );
600    }
601
602    #[test]
603    fn test_validation_error_accumulation() {
604        // Create a config with multiple validation errors
605        let config = ProdigyConfig {
606            log_level: "invalid".to_string(),
607            max_concurrent_specs: 0,
608            storage: StorageSettings {
609                compression_level: 15,
610                ..Default::default()
611            },
612            project: Some(ProjectSettings {
613                name: Some("".to_string()),
614                spec_dir: Some(PathBuf::from("/absolute/path")),
615                ..Default::default()
616            }),
617            ..Default::default()
618        };
619        let result = config.validate();
620
621        match result {
622            Validation::Failure(errors) => {
623                // Should have accumulated all errors
624                assert!(
625                    errors.len() >= 4,
626                    "Expected at least 4 errors, got {}",
627                    errors.len()
628                );
629            }
630            Validation::Success(_) => panic!("Expected validation to fail with multiple errors"),
631        }
632    }
633
634    #[test]
635    fn test_effective_api_key_precedence() {
636        let mut config = ProdigyConfig::default();
637
638        // No API key set
639        assert!(config.effective_api_key().is_none());
640
641        // Global API key only
642        config.claude_api_key = Some("global-key".to_string());
643        assert_eq!(config.effective_api_key(), Some("global-key"));
644
645        // Project API key takes precedence
646        config.project = Some(ProjectSettings {
647            claude_api_key: Some("project-key".to_string()),
648            ..Default::default()
649        });
650        assert_eq!(config.effective_api_key(), Some("project-key"));
651    }
652
653    #[test]
654    fn test_effective_auto_commit_precedence() {
655        let mut config = ProdigyConfig::default();
656
657        // Default value
658        assert!(config.effective_auto_commit());
659
660        // Global setting
661        config.auto_commit = false;
662        assert!(!config.effective_auto_commit());
663
664        // Project setting takes precedence
665        config.project = Some(ProjectSettings {
666            auto_commit: Some(true),
667            ..Default::default()
668        });
669        assert!(config.effective_auto_commit());
670    }
671
672    #[test]
673    fn test_effective_methods() {
674        let config = ProdigyConfig {
675            log_level: "debug".to_string(),
676            max_concurrent_specs: 16,
677            default_editor: Some("vim".to_string()),
678            ..Default::default()
679        };
680
681        assert_eq!(config.effective_log_level(), "debug");
682        assert_eq!(config.effective_max_concurrent(), 16);
683        assert_eq!(config.effective_editor(), Some("vim"));
684    }
685
686    #[test]
687    fn test_plugin_config_default() {
688        let plugins = PluginConfig::default();
689
690        assert!(!plugins.enabled);
691        assert!(plugins.directory.is_none());
692        assert!(plugins.auto_load.is_empty());
693    }
694
695    #[test]
696    fn test_plugin_config_deserialization() {
697        let yaml = r#"
698plugins:
699  enabled: true
700  directory: /path/to/plugins
701  auto_load:
702    - plugin1
703    - plugin2
704"#;
705
706        let config: ProdigyConfig = serde_yaml::from_str(yaml).unwrap();
707
708        assert!(config.plugins.enabled);
709        assert_eq!(
710            config.plugins.directory,
711            Some(PathBuf::from("/path/to/plugins"))
712        );
713        assert_eq!(config.plugins.auto_load, vec!["plugin1", "plugin2"]);
714    }
715}