1use premortem::prelude::*;
30use serde::{Deserialize, Serialize};
31use std::collections::HashMap;
32use std::path::PathBuf;
33
34pub const VALID_LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ProdigyConfig {
53 #[serde(default = "default_log_level")]
55 pub log_level: String,
56
57 #[serde(default)]
60 pub claude_api_key: Option<String>,
61
62 #[serde(default = "default_max_concurrent")]
64 pub max_concurrent_specs: usize,
65
66 #[serde(default = "default_auto_commit")]
68 pub auto_commit: bool,
69
70 #[serde(default)]
72 pub default_editor: Option<String>,
73
74 #[serde(default)]
76 pub prodigy_home: Option<PathBuf>,
77
78 #[serde(default)]
80 pub project: Option<ProjectSettings>,
81
82 #[serde(default)]
84 pub storage: StorageSettings,
85
86 #[serde(default)]
88 pub plugins: PluginConfig,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct ProjectSettings {
96 #[serde(default)]
98 pub name: Option<String>,
99
100 #[serde(default)]
102 pub description: Option<String>,
103
104 #[serde(default)]
106 pub version: Option<String>,
107
108 #[serde(default)]
110 pub spec_dir: Option<PathBuf>,
111
112 #[serde(default)]
114 pub claude_api_key: Option<String>,
115
116 #[serde(default)]
118 pub auto_commit: Option<bool>,
119
120 #[serde(default)]
122 pub variables: HashMap<String, serde_json::Value>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct StorageSettings {
128 #[serde(default)]
130 pub backend: BackendType,
131
132 #[serde(default)]
134 pub base_path: Option<PathBuf>,
135
136 #[serde(default)]
138 pub compression_level: u8,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
146pub struct PluginConfig {
147 #[serde(default)]
149 pub enabled: bool,
150
151 #[serde(default)]
153 pub directory: Option<PathBuf>,
154
155 #[serde(default)]
157 pub auto_load: Vec<String>,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
162#[serde(rename_all = "lowercase")]
163pub enum BackendType {
164 #[default]
166 FileSystem,
167 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
187fn 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 #[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 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 #[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 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 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 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 pub fn effective_editor(&self) -> Option<&str> {
270 self.default_editor.as_deref()
271 }
272
273 pub fn effective_max_concurrent(&self) -> usize {
275 self.max_concurrent_specs
276 }
277
278 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 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 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 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 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 if let Some(ref project) = self.project {
328 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 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
360pub 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
369pub 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 assert_eq!(config.get_spec_dir(), PathBuf::from("specs"));
399
400 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 let config = ProdigyConfig::default();
440 let result = config.validate();
441 assert!(matches!(result, Validation::Success(_)));
442
443 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 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 let invalid_config = ProdigyConfig {
468 storage: StorageSettings {
469 backend: BackendType::FileSystem,
470 base_path: None,
471 compression_level: 10, },
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 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 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 assert!(config.effective_api_key().is_none());
640
641 config.claude_api_key = Some("global-key".to_string());
643 assert_eq!(config.effective_api_key(), Some("global-key"));
644
645 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 assert!(config.effective_auto_commit());
659
660 config.auto_commit = false;
662 assert!(!config.effective_auto_commit());
663
664 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}