use premortem::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub const VALID_LOG_LEVELS: &[&str] = &["trace", "debug", "info", "warn", "error"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProdigyConfig {
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default)]
pub claude_api_key: Option<String>,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_specs: usize,
#[serde(default = "default_auto_commit")]
pub auto_commit: bool,
#[serde(default)]
pub default_editor: Option<String>,
#[serde(default)]
pub prodigy_home: Option<PathBuf>,
#[serde(default)]
pub project: Option<ProjectSettings>,
#[serde(default)]
pub storage: StorageSettings,
#[serde(default)]
pub plugins: PluginConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectSettings {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub spec_dir: Option<PathBuf>,
#[serde(default)]
pub claude_api_key: Option<String>,
#[serde(default)]
pub auto_commit: Option<bool>,
#[serde(default)]
pub variables: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct StorageSettings {
#[serde(default)]
pub backend: BackendType,
#[serde(default)]
pub base_path: Option<PathBuf>,
#[serde(default)]
pub compression_level: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PluginConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub directory: Option<PathBuf>,
#[serde(default)]
pub auto_load: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum BackendType {
#[default]
FileSystem,
Memory,
}
impl Default for ProdigyConfig {
fn default() -> Self {
Self {
log_level: default_log_level(),
claude_api_key: None,
max_concurrent_specs: default_max_concurrent(),
auto_commit: default_auto_commit(),
default_editor: None,
prodigy_home: None,
project: None,
storage: StorageSettings::default(),
plugins: PluginConfig::default(),
}
}
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_max_concurrent() -> usize {
4
}
fn default_auto_commit() -> bool {
true
}
impl ProdigyConfig {
#[deprecated(since = "0.6.0", note = "Use effective_api_key() instead")]
pub fn get_claude_api_key(&self) -> Option<&str> {
self.effective_api_key()
}
pub fn effective_api_key(&self) -> Option<&str> {
self.project
.as_ref()
.and_then(|p| p.claude_api_key.as_deref())
.or(self.claude_api_key.as_deref())
}
#[deprecated(since = "0.6.0", note = "Use effective_auto_commit() instead")]
pub fn get_auto_commit(&self) -> bool {
self.effective_auto_commit()
}
pub fn effective_auto_commit(&self) -> bool {
self.project
.as_ref()
.and_then(|p| p.auto_commit)
.unwrap_or(self.auto_commit)
}
pub fn get_spec_dir(&self) -> PathBuf {
self.project
.as_ref()
.and_then(|p| p.spec_dir.clone())
.unwrap_or_else(|| PathBuf::from("specs"))
}
pub fn get_prodigy_home(&self) -> PathBuf {
self.prodigy_home.clone().unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join(".prodigy"))
.unwrap_or_else(|| PathBuf::from("~/.prodigy"))
})
}
pub fn effective_editor(&self) -> Option<&str> {
self.default_editor.as_deref()
}
pub fn effective_max_concurrent(&self) -> usize {
self.max_concurrent_specs
}
pub fn effective_log_level(&self) -> &str {
&self.log_level
}
}
impl Validate for ProdigyConfig {
fn validate(&self) -> ConfigValidation<()> {
let mut errors = Vec::new();
if self.log_level.is_empty() {
errors.push(ConfigError::ValidationError {
path: "log_level".to_string(),
source_location: None,
value: Some(self.log_level.clone()),
message: "log_level cannot be empty".to_string(),
});
} else if !VALID_LOG_LEVELS.contains(&self.log_level.as_str()) {
errors.push(ConfigError::ValidationError {
path: "log_level".to_string(),
source_location: None,
value: Some(self.log_level.clone()),
message: format!("log_level must be one of: {}", VALID_LOG_LEVELS.join(", ")),
});
}
if self.max_concurrent_specs == 0 || self.max_concurrent_specs > 100 {
errors.push(ConfigError::ValidationError {
path: "max_concurrent_specs".to_string(),
source_location: None,
value: Some(self.max_concurrent_specs.to_string()),
message: "max_concurrent_specs must be between 1 and 100".to_string(),
});
}
if self.storage.compression_level > 9 {
errors.push(ConfigError::ValidationError {
path: "storage.compression_level".to_string(),
source_location: None,
value: Some(self.storage.compression_level.to_string()),
message: "storage.compression_level must be between 0 and 9".to_string(),
});
}
if let Some(ref project) = self.project {
if let Some(ref name) = project.name {
if name.is_empty() {
errors.push(ConfigError::ValidationError {
path: "project.name".to_string(),
source_location: None,
value: Some(name.clone()),
message: "project.name cannot be empty when provided".to_string(),
});
}
}
if let Some(ref spec_dir) = project.spec_dir {
if spec_dir.is_absolute() {
errors.push(ConfigError::ValidationError {
path: "project.spec_dir".to_string(),
source_location: None,
value: Some(spec_dir.display().to_string()),
message: "project.spec_dir should be a relative path".to_string(),
});
}
}
}
match ConfigErrors::from_vec(errors) {
Some(errs) => Validation::Failure(errs),
None => Validation::Success(()),
}
}
}
pub fn global_config_path() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".prodigy").join("config.yml"))
.unwrap_or_else(|| PathBuf::from("~/.prodigy/config.yml"))
}
pub fn project_config_path() -> PathBuf {
PathBuf::from(".prodigy/config.yml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_prodigy_config_default() {
let config = ProdigyConfig::default();
assert_eq!(config.log_level, "info");
assert!(config.claude_api_key.is_none());
assert_eq!(config.max_concurrent_specs, 4);
assert!(config.auto_commit);
assert!(config.default_editor.is_none());
assert!(config.project.is_none());
assert_eq!(config.storage.backend, BackendType::FileSystem);
}
#[test]
fn test_get_spec_dir() {
let mut config = ProdigyConfig::default();
assert_eq!(config.get_spec_dir(), PathBuf::from("specs"));
config.project = Some(ProjectSettings {
spec_dir: Some(PathBuf::from("custom/specs")),
..Default::default()
});
assert_eq!(config.get_spec_dir(), PathBuf::from("custom/specs"));
}
#[test]
fn test_yaml_deserialization() {
let yaml = r#"
log_level: debug
max_concurrent_specs: 8
auto_commit: false
project:
name: test-project
spec_dir: my-specs
storage:
backend: memory
compression_level: 6
"#;
let config: ProdigyConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.log_level, "debug");
assert_eq!(config.max_concurrent_specs, 8);
assert!(!config.auto_commit);
let project = config.project.unwrap();
assert_eq!(project.name, Some("test-project".to_string()));
assert_eq!(project.spec_dir, Some(PathBuf::from("my-specs")));
assert_eq!(config.storage.backend, BackendType::Memory);
assert_eq!(config.storage.compression_level, 6);
}
#[test]
fn test_validation() {
let config = ProdigyConfig::default();
let result = config.validate();
assert!(matches!(result, Validation::Success(_)));
let invalid_config = ProdigyConfig {
max_concurrent_specs: 0,
..Default::default()
};
let result = invalid_config.validate();
assert!(matches!(result, Validation::Failure(_)));
}
#[test]
fn test_storage_settings_validation_via_config() {
let config = ProdigyConfig {
storage: StorageSettings {
backend: BackendType::FileSystem,
base_path: None,
compression_level: 6,
},
..Default::default()
};
let result = config.validate();
assert!(matches!(result, Validation::Success(_)));
let invalid_config = ProdigyConfig {
storage: StorageSettings {
backend: BackendType::FileSystem,
base_path: None,
compression_level: 10, },
..Default::default()
};
let result = invalid_config.validate();
assert!(matches!(result, Validation::Failure(_)));
}
#[test]
fn test_backend_type_serialization() {
assert_eq!(
serde_json::to_string(&BackendType::FileSystem).unwrap(),
"\"filesystem\""
);
assert_eq!(
serde_json::to_string(&BackendType::Memory).unwrap(),
"\"memory\""
);
let fs: BackendType = serde_json::from_str("\"filesystem\"").unwrap();
assert_eq!(fs, BackendType::FileSystem);
let mem: BackendType = serde_json::from_str("\"memory\"").unwrap();
assert_eq!(mem, BackendType::Memory);
}
#[test]
fn test_validation_log_level_valid_values() {
for level in VALID_LOG_LEVELS {
let config = ProdigyConfig {
log_level: level.to_string(),
..Default::default()
};
let result = config.validate();
assert!(
matches!(result, Validation::Success(_)),
"log_level '{}' should be valid",
level
);
}
}
#[test]
fn test_validation_log_level_invalid() {
let config = ProdigyConfig {
log_level: "invalid_level".to_string(),
..Default::default()
};
let result = config.validate();
match result {
Validation::Failure(errors) => {
assert!(
errors.iter().any(|e| {
matches!(e, ConfigError::ValidationError { path, .. } if path == "log_level")
}),
"Expected validation error for log_level"
);
}
Validation::Success(_) => panic!("Expected validation to fail for invalid log_level"),
}
}
#[test]
fn test_validation_project_name_empty() {
let config = ProdigyConfig {
project: Some(ProjectSettings {
name: Some("".to_string()),
..Default::default()
}),
..Default::default()
};
let result = config.validate();
match result {
Validation::Failure(errors) => {
assert!(
errors.iter().any(|e| {
matches!(e, ConfigError::ValidationError { path, .. } if path == "project.name")
}),
"Expected validation error for project.name"
);
}
Validation::Success(_) => {
panic!("Expected validation to fail for empty project.name")
}
}
}
#[test]
fn test_validation_spec_dir_absolute_path() {
let config = ProdigyConfig {
project: Some(ProjectSettings {
spec_dir: Some(PathBuf::from("/absolute/path/to/specs")),
..Default::default()
}),
..Default::default()
};
let result = config.validate();
match result {
Validation::Failure(errors) => {
assert!(
errors.iter().any(|e| {
matches!(e, ConfigError::ValidationError { path, .. } if path == "project.spec_dir")
}),
"Expected validation error for project.spec_dir"
);
}
Validation::Success(_) => {
panic!("Expected validation to fail for absolute spec_dir")
}
}
}
#[test]
fn test_validation_spec_dir_relative_path_valid() {
let config = ProdigyConfig {
project: Some(ProjectSettings {
spec_dir: Some(PathBuf::from("specs")),
..Default::default()
}),
..Default::default()
};
let result = config.validate();
assert!(
matches!(result, Validation::Success(_)),
"Relative spec_dir should be valid"
);
}
#[test]
fn test_validation_error_accumulation() {
let config = ProdigyConfig {
log_level: "invalid".to_string(),
max_concurrent_specs: 0,
storage: StorageSettings {
compression_level: 15,
..Default::default()
},
project: Some(ProjectSettings {
name: Some("".to_string()),
spec_dir: Some(PathBuf::from("/absolute/path")),
..Default::default()
}),
..Default::default()
};
let result = config.validate();
match result {
Validation::Failure(errors) => {
assert!(
errors.len() >= 4,
"Expected at least 4 errors, got {}",
errors.len()
);
}
Validation::Success(_) => panic!("Expected validation to fail with multiple errors"),
}
}
#[test]
fn test_effective_api_key_precedence() {
let mut config = ProdigyConfig::default();
assert!(config.effective_api_key().is_none());
config.claude_api_key = Some("global-key".to_string());
assert_eq!(config.effective_api_key(), Some("global-key"));
config.project = Some(ProjectSettings {
claude_api_key: Some("project-key".to_string()),
..Default::default()
});
assert_eq!(config.effective_api_key(), Some("project-key"));
}
#[test]
fn test_effective_auto_commit_precedence() {
let mut config = ProdigyConfig::default();
assert!(config.effective_auto_commit());
config.auto_commit = false;
assert!(!config.effective_auto_commit());
config.project = Some(ProjectSettings {
auto_commit: Some(true),
..Default::default()
});
assert!(config.effective_auto_commit());
}
#[test]
fn test_effective_methods() {
let config = ProdigyConfig {
log_level: "debug".to_string(),
max_concurrent_specs: 16,
default_editor: Some("vim".to_string()),
..Default::default()
};
assert_eq!(config.effective_log_level(), "debug");
assert_eq!(config.effective_max_concurrent(), 16);
assert_eq!(config.effective_editor(), Some("vim"));
}
#[test]
fn test_plugin_config_default() {
let plugins = PluginConfig::default();
assert!(!plugins.enabled);
assert!(plugins.directory.is_none());
assert!(plugins.auto_load.is_empty());
}
#[test]
fn test_plugin_config_deserialization() {
let yaml = r#"
plugins:
enabled: true
directory: /path/to/plugins
auto_load:
- plugin1
- plugin2
"#;
let config: ProdigyConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.plugins.enabled);
assert_eq!(
config.plugins.directory,
Some(PathBuf::from("/path/to/plugins"))
);
assert_eq!(config.plugins.auto_load, vec!["plugin1", "plugin2"]);
}
}