use super::Config;
use crate::core::config::{
merge_project_config, merge_workflow_config, parse_project_config, parse_workflow_config,
validate_config_format,
};
use anyhow::{anyhow, Context, Result};
use std::path::Path;
use std::sync::{Arc, RwLock};
use tokio::fs;
pub struct ConfigLoader {
config: Arc<RwLock<Config>>,
}
impl ConfigLoader {
pub async fn new() -> Result<Self> {
let config = Config::new();
Ok(Self {
config: Arc::new(RwLock::new(config)),
})
}
pub async fn load_with_explicit_path(
&self,
project_path: &Path,
explicit_path: Option<&Path>,
) -> Result<()> {
match explicit_path {
Some(path) => {
self.load_from_path(path).await?;
}
None => {
let default_path = project_path.join(".prodigy").join("workflow.yml");
if default_path.exists() {
self.load_from_path(&default_path).await?;
}
}
}
Ok(())
}
async fn load_from_path(&self, path: &Path) -> Result<()> {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("Failed to read configuration file: {}", path.display()))?;
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
validate_config_format(extension)
.with_context(|| format!("Invalid configuration file: {}", path.display()))?;
let workflow_config = parse_workflow_config(&content)
.with_context(|| format!("Failed to parse configuration: {}", path.display()))?;
let mut config = self
.config
.write()
.map_err(|_| anyhow!("Failed to acquire write lock for config"))?;
*config = merge_workflow_config(config.clone(), workflow_config);
Ok(())
}
pub async fn load_project(&self, project_path: &Path) -> Result<()> {
let config_path = project_path.join(".prodigy").join("config.yml");
if config_path.exists() {
let content = fs::read_to_string(&config_path).await?;
let project_config = parse_project_config(&content)?;
let mut config = self
.config
.write()
.map_err(|_| anyhow!("Failed to acquire write lock for config"))?;
*config = merge_project_config(config.clone(), project_config);
}
Ok(())
}
pub fn get_config(&self) -> Config {
self.config
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::fs;
#[tokio::test]
async fn test_new_creates_default_config() -> Result<()> {
let loader = ConfigLoader::new().await?;
let config = loader.get_config();
assert!(config.project.is_none());
assert!(config.workflow.is_none());
assert_eq!(config.global.log_level, Some("info".to_string()));
assert_eq!(config.global.max_concurrent_specs, Some(1));
assert_eq!(config.global.auto_commit, Some(true));
Ok(())
}
#[tokio::test]
async fn test_load_with_explicit_path_yaml() -> Result<()> {
let temp_dir = TempDir::new()?;
let workflow_path = temp_dir.path().join("workflow.yml");
let workflow_content = r#"
commands:
- prodigy-code-review
- prodigy-implement-spec
- prodigy-lint
"#;
fs::write(&workflow_path, workflow_content).await?;
let loader = ConfigLoader::new().await?;
loader
.load_with_explicit_path(temp_dir.path(), Some(&workflow_path))
.await?;
let config = loader.get_config();
assert!(config.workflow.is_some());
let workflow = config.workflow.unwrap();
assert_eq!(workflow.commands.len(), 3);
Ok(())
}
#[tokio::test]
async fn test_load_with_explicit_path_nested_workflow() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("config.yml");
let config_content = r#"
workflow:
commands:
- name: prodigy-code-review
options:
focus: performance
- prodigy-lint
"#;
fs::write(&config_path, config_content).await.unwrap();
let loader = ConfigLoader::new().await?;
loader
.load_with_explicit_path(temp_dir.path(), Some(&config_path))
.await?;
let config = loader.get_config();
assert!(config.workflow.is_some());
let workflow = config.workflow.unwrap();
assert_eq!(workflow.commands.len(), 2);
Ok(())
}
#[tokio::test]
async fn test_load_with_default_path() -> Result<()> {
let temp_dir = TempDir::new()?;
let prodigy_dir = temp_dir.path().join(".prodigy");
fs::create_dir(&prodigy_dir).await?;
let workflow_path = prodigy_dir.join("workflow.yml");
let workflow_content = r#"
commands:
- prodigy-test
"#;
fs::write(&workflow_path, workflow_content).await?;
let loader = ConfigLoader::new().await?;
loader
.load_with_explicit_path(temp_dir.path(), None)
.await?;
let config = loader.get_config();
assert!(config.workflow.is_some());
let workflow = config.workflow.unwrap();
assert_eq!(workflow.commands.len(), 1);
Ok(())
}
#[tokio::test]
async fn test_load_with_no_config_uses_defaults() -> Result<()> {
let temp_dir = TempDir::new()?;
let loader = ConfigLoader::new().await?;
loader
.load_with_explicit_path(temp_dir.path(), None)
.await?;
let config = loader.get_config();
assert!(config.workflow.is_none());
Ok(())
}
#[tokio::test]
async fn test_load_project_config() -> Result<()> {
let temp_dir = TempDir::new()?;
let prodigy_dir = temp_dir.path().join(".prodigy");
fs::create_dir(&prodigy_dir).await?;
let config_path = prodigy_dir.join("config.yml");
let project_content = r#"
name: test-project
description: A test project
version: 1.0.0
spec_dir: custom-specs
claude_api_key: test-key
auto_commit: false
"#;
fs::write(&config_path, project_content).await?;
let loader = ConfigLoader::new().await?;
loader.load_project(temp_dir.path()).await?;
let config = loader.get_config();
assert!(config.project.is_some());
let project = config.project.unwrap();
assert_eq!(project.name, "test-project");
assert_eq!(project.description, Some("A test project".to_string()));
assert_eq!(project.version, Some("1.0.0".to_string()));
assert_eq!(project.spec_dir, Some(PathBuf::from("custom-specs")));
assert_eq!(project.claude_api_key, Some("test-key".to_string()));
assert_eq!(project.auto_commit, Some(false));
Ok(())
}
#[tokio::test]
async fn test_load_from_path_unsupported_format() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("config.json");
fs::write(&config_path, "{}").await?;
let loader = ConfigLoader::new().await?;
let result = loader.load_from_path(&config_path).await;
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Invalid configuration file")
|| error_msg.contains("Unsupported configuration file format"),
"Expected error message about unsupported format, got: {}",
error_msg
);
Ok(())
}
#[tokio::test]
async fn test_load_from_path_invalid_yaml() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("config.yml");
fs::write(&config_path, "invalid: yaml: content:").await?;
let loader = ConfigLoader::new().await?;
let result = loader.load_from_path(&config_path).await;
assert!(result.is_err());
Ok(())
}
#[tokio::test]
async fn test_load_from_path_nonexistent_file() -> Result<()> {
let temp_dir = TempDir::new()?;
let config_path = temp_dir.path().join("nonexistent.yml");
let loader = ConfigLoader::new().await?;
let result = loader.load_from_path(&config_path).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Failed to read configuration file"));
Ok(())
}
#[tokio::test]
async fn test_concurrent_access() -> Result<()> {
let loader = ConfigLoader::new().await?;
let loader_arc = Arc::new(loader);
let mut handles = vec![];
for _ in 0..10 {
let loader_clone = loader_arc.clone();
let handle = tokio::spawn(async move {
let config = loader_clone.get_config();
assert!(config.global.auto_commit.is_some());
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
Ok(())
}
}