use crate::config::WorkflowConfig;
use crate::cook::workflow::composition::{
ComposableWorkflow, ComposedWorkflow, TemplateRegistry, WorkflowComposer,
};
use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, thiserror::Error)]
pub enum CompositionError {
#[error("Template '{0}' not found in registry or file system")]
TemplateNotFound(String),
#[error("Required parameter '{name}' not provided")]
MissingParameter { name: String },
#[error("Parameter '{name}' has invalid value: {reason}")]
InvalidParameter { name: String, reason: String },
#[error("Circular dependency detected: {0}")]
CircularDependency(String),
#[error("Failed to load workflow from {path}: {source}")]
WorkflowLoadError {
path: PathBuf,
#[source]
source: anyhow::Error,
},
#[error("Parameter substitution failed in command '{command}': {reason}")]
SubstitutionError { command: String, reason: String },
}
pub fn is_composable_workflow(content: &str) -> bool {
content.contains("template:")
|| content.contains("imports:")
|| content.contains("extends:")
|| content.contains("workflows:")
|| content.contains("parameters:")
}
pub async fn parse_composable_workflow(
path: &Path,
content: &str,
cli_params: HashMap<String, Value>,
) -> Result<(
WorkflowConfig,
Option<crate::config::MapReduceWorkflowConfig>,
)> {
let composable: ComposableWorkflow = serde_yaml::from_str(content)
.with_context(|| format!("Failed to parse composable workflow: {}", path.display()))?;
let workflow_params = extract_workflow_parameters(&composable)?;
let mut params = workflow_params;
for (key, value) in cli_params {
params.insert(key, value);
}
let registry = Arc::new(create_template_registry()?);
let composer = WorkflowComposer::new(registry);
let composed = composer
.compose(path, params)
.await
.context("Failed to compose workflow")?;
let workflow_config = convert_composed_to_config(composed)?;
Ok((workflow_config, None))
}
fn create_template_registry() -> Result<TemplateRegistry> {
let template_dirs = vec![
directories::ProjectDirs::from("com", "prodigy", "prodigy")
.map(|dirs| dirs.data_dir().join("templates"))
.unwrap_or_else(|| {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".prodigy/templates")
}),
PathBuf::from(".prodigy/templates"),
PathBuf::from("templates"),
];
let template_dir = template_dirs
.into_iter()
.find(|dir| dir.exists())
.unwrap_or_else(|| {
directories::ProjectDirs::from("com", "prodigy", "prodigy")
.map(|dirs| dirs.data_dir().join("templates"))
.unwrap_or_else(|| {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".prodigy/templates")
})
});
let storage = Box::new(
crate::cook::workflow::composition::registry::FileTemplateStorage::new(template_dir),
);
let registry = TemplateRegistry::with_storage(storage);
Ok(registry)
}
fn extract_workflow_parameters(composable: &ComposableWorkflow) -> Result<HashMap<String, Value>> {
let mut params = HashMap::new();
if let Some(defaults) = &composable.defaults {
for (key, value) in defaults {
params.insert(key.clone(), value.clone());
}
}
if let Some(template) = &composable.template {
if let Some(template_params) = &template.with {
for (key, value) in template_params {
params.insert(key.clone(), value.clone());
}
}
}
composable
.validate_parameters(¶ms)
.context("Parameter validation failed")?;
Ok(params)
}
fn convert_composed_to_config(composed: ComposedWorkflow) -> Result<WorkflowConfig> {
let workflow = composed.workflow;
Ok(WorkflowConfig {
name: workflow.config.name,
commands: workflow.config.commands,
env: workflow.config.env,
secrets: workflow.config.secrets,
env_files: workflow.config.env_files,
profiles: workflow.config.profiles,
merge: workflow.config.merge,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_composable_workflow() {
assert!(is_composable_workflow("template:\n name: foo"));
assert!(is_composable_workflow("imports:\n - path: bar.yml"));
assert!(is_composable_workflow("extends: base"));
assert!(is_composable_workflow("workflows:\n test: {}"));
assert!(is_composable_workflow("parameters:\n required: []"));
assert!(!is_composable_workflow("commands:\n - shell: test"));
}
#[test]
fn test_extract_workflow_parameters() {
let mut composable = ComposableWorkflow::from_config(WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
let mut defaults = HashMap::new();
defaults.insert("target".to_string(), Value::String("app.js".to_string()));
defaults.insert("style".to_string(), Value::String("functional".to_string()));
composable.defaults = Some(defaults);
let result = extract_workflow_parameters(&composable);
assert!(result.is_ok());
let params = result.unwrap();
assert_eq!(params.len(), 2);
assert_eq!(
params.get("target"),
Some(&Value::String("app.js".to_string()))
);
assert_eq!(
params.get("style"),
Some(&Value::String("functional".to_string()))
);
}
}