prodigy/cook/workflow/
composer_integration.rs1use crate::config::WorkflowConfig;
8use crate::cook::workflow::composition::{
9 ComposableWorkflow, ComposedWorkflow, TemplateRegistry, WorkflowComposer,
10};
11use anyhow::{Context, Result};
12use serde_json::Value;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17#[derive(Debug, thiserror::Error)]
19pub enum CompositionError {
20 #[error("Template '{0}' not found in registry or file system")]
21 TemplateNotFound(String),
22
23 #[error("Required parameter '{name}' not provided")]
24 MissingParameter { name: String },
25
26 #[error("Parameter '{name}' has invalid value: {reason}")]
27 InvalidParameter { name: String, reason: String },
28
29 #[error("Circular dependency detected: {0}")]
30 CircularDependency(String),
31
32 #[error("Failed to load workflow from {path}: {source}")]
33 WorkflowLoadError {
34 path: PathBuf,
35 #[source]
36 source: anyhow::Error,
37 },
38
39 #[error("Parameter substitution failed in command '{command}': {reason}")]
40 SubstitutionError { command: String, reason: String },
41}
42
43pub fn is_composable_workflow(content: &str) -> bool {
45 content.contains("template:")
46 || content.contains("imports:")
47 || content.contains("extends:")
48 || content.contains("workflows:")
49 || content.contains("parameters:")
50}
51
52pub async fn parse_composable_workflow(
54 path: &Path,
55 content: &str,
56 cli_params: HashMap<String, Value>,
57) -> Result<(
58 WorkflowConfig,
59 Option<crate::config::MapReduceWorkflowConfig>,
60)> {
61 let composable: ComposableWorkflow = serde_yaml::from_str(content)
63 .with_context(|| format!("Failed to parse composable workflow: {}", path.display()))?;
64
65 let workflow_params = extract_workflow_parameters(&composable)?;
67
68 let mut params = workflow_params;
70 for (key, value) in cli_params {
71 params.insert(key, value);
72 }
73
74 let registry = Arc::new(create_template_registry()?);
76
77 let composer = WorkflowComposer::new(registry);
79
80 let composed = composer
82 .compose(path, params)
83 .await
84 .context("Failed to compose workflow")?;
85
86 let workflow_config = convert_composed_to_config(composed)?;
88
89 Ok((workflow_config, None))
90}
91
92fn create_template_registry() -> Result<TemplateRegistry> {
94 let template_dirs = vec![
100 directories::ProjectDirs::from("com", "prodigy", "prodigy")
101 .map(|dirs| dirs.data_dir().join("templates"))
102 .unwrap_or_else(|| {
103 let home = std::env::var("HOME")
105 .or_else(|_| std::env::var("USERPROFILE"))
106 .unwrap_or_else(|_| ".".to_string());
107 PathBuf::from(home).join(".prodigy/templates")
108 }),
109 PathBuf::from(".prodigy/templates"),
110 PathBuf::from("templates"),
111 ];
112
113 let template_dir = template_dirs
115 .into_iter()
116 .find(|dir| dir.exists())
117 .unwrap_or_else(|| {
118 directories::ProjectDirs::from("com", "prodigy", "prodigy")
120 .map(|dirs| dirs.data_dir().join("templates"))
121 .unwrap_or_else(|| {
122 let home = std::env::var("HOME")
123 .or_else(|_| std::env::var("USERPROFILE"))
124 .unwrap_or_else(|_| ".".to_string());
125 PathBuf::from(home).join(".prodigy/templates")
126 })
127 });
128
129 let storage = Box::new(
131 crate::cook::workflow::composition::registry::FileTemplateStorage::new(template_dir),
132 );
133 let registry = TemplateRegistry::with_storage(storage);
134
135 Ok(registry)
136}
137
138fn extract_workflow_parameters(composable: &ComposableWorkflow) -> Result<HashMap<String, Value>> {
140 let mut params = HashMap::new();
141
142 if let Some(defaults) = &composable.defaults {
144 for (key, value) in defaults {
145 params.insert(key.clone(), value.clone());
146 }
147 }
148
149 if let Some(template) = &composable.template {
151 if let Some(template_params) = &template.with {
152 for (key, value) in template_params {
153 params.insert(key.clone(), value.clone());
154 }
155 }
156 }
157
158 composable
163 .validate_parameters(¶ms)
164 .context("Parameter validation failed")?;
165
166 Ok(params)
167}
168
169fn convert_composed_to_config(composed: ComposedWorkflow) -> Result<WorkflowConfig> {
171 let workflow = composed.workflow;
172
173 Ok(WorkflowConfig {
174 name: workflow.config.name,
175 commands: workflow.config.commands,
176 env: workflow.config.env,
177 secrets: workflow.config.secrets,
178 env_files: workflow.config.env_files,
179 profiles: workflow.config.profiles,
180 merge: workflow.config.merge,
181 })
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn test_is_composable_workflow() {
190 assert!(is_composable_workflow("template:\n name: foo"));
191 assert!(is_composable_workflow("imports:\n - path: bar.yml"));
192 assert!(is_composable_workflow("extends: base"));
193 assert!(is_composable_workflow("workflows:\n test: {}"));
194 assert!(is_composable_workflow("parameters:\n required: []"));
195 assert!(!is_composable_workflow("commands:\n - shell: test"));
196 }
197
198 #[test]
199 fn test_extract_workflow_parameters() {
200 let mut composable = ComposableWorkflow::from_config(WorkflowConfig {
201 name: None,
202 commands: vec![],
203 env: None,
204 secrets: None,
205 env_files: None,
206 profiles: None,
207 merge: None,
208 });
209
210 let mut defaults = HashMap::new();
211 defaults.insert("target".to_string(), Value::String("app.js".to_string()));
212 defaults.insert("style".to_string(), Value::String("functional".to_string()));
213 composable.defaults = Some(defaults);
214
215 let result = extract_workflow_parameters(&composable);
216 assert!(result.is_ok());
217
218 let params = result.unwrap();
219 assert_eq!(params.len(), 2);
220 assert_eq!(
221 params.get("target"),
222 Some(&Value::String("app.js".to_string()))
223 );
224 assert_eq!(
225 params.get("style"),
226 Some(&Value::String("functional".to_string()))
227 );
228 }
229}