Skip to main content

prodigy/cook/workflow/
composer_integration.rs

1//! Integration layer between workflow composer and executor
2//!
3//! This module bridges the gap between the workflow composition system
4//! and the workflow execution runtime, enabling template-based workflows
5//! to be composed and executed seamlessly.
6
7use 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/// Errors that can occur during workflow composition
18#[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
43/// Detects if a workflow file uses composition features
44pub 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
52/// Parse and compose a composable workflow file
53pub 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    // Parse as ComposableWorkflow
62    let composable: ComposableWorkflow = serde_yaml::from_str(content)
63        .with_context(|| format!("Failed to parse composable workflow: {}", path.display()))?;
64
65    // Extract parameters from workflow defaults
66    let workflow_params = extract_workflow_parameters(&composable)?;
67
68    // Merge CLI parameters with workflow defaults (CLI takes precedence)
69    let mut params = workflow_params;
70    for (key, value) in cli_params {
71        params.insert(key, value);
72    }
73
74    // Initialize template registry
75    let registry = Arc::new(create_template_registry()?);
76
77    // Create composer
78    let composer = WorkflowComposer::new(registry);
79
80    // Compose the workflow
81    let composed = composer
82        .compose(path, params)
83        .await
84        .context("Failed to compose workflow")?;
85
86    // Convert to WorkflowConfig
87    let workflow_config = convert_composed_to_config(composed)?;
88
89    Ok((workflow_config, None))
90}
91
92/// Create a template registry with standard search paths
93fn create_template_registry() -> Result<TemplateRegistry> {
94    // Look for templates in standard locations (following Prodigy global storage pattern)
95    // Priority order:
96    // 1. ~/.prodigy/templates/ (global, shared across repos)
97    // 2. .prodigy/templates/ (project-local)
98    // 3. templates/ (legacy, project-local)
99    let template_dirs = vec![
100        directories::ProjectDirs::from("com", "prodigy", "prodigy")
101            .map(|dirs| dirs.data_dir().join("templates"))
102            .unwrap_or_else(|| {
103                // Fallback for when ProjectDirs fails
104                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    // Use first existing directory, or create global default
114    let template_dir = template_dirs
115        .into_iter()
116        .find(|dir| dir.exists())
117        .unwrap_or_else(|| {
118            // Default to global storage location
119            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    // Use file-based template storage
130    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
138/// Extract workflow parameters from composable workflow
139fn extract_workflow_parameters(composable: &ComposableWorkflow) -> Result<HashMap<String, Value>> {
140    let mut params = HashMap::new();
141
142    // Start with defaults from the workflow file
143    if let Some(defaults) = &composable.defaults {
144        for (key, value) in defaults {
145            params.insert(key.clone(), value.clone());
146        }
147    }
148
149    // Also extract parameters from template.with if present
150    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    // NOTE: CLI parameter overrides will be implemented in Phase 2
159    // Phase 1 uses only defaults from workflow file
160
161    // Validate required parameters
162    composable
163        .validate_parameters(&params)
164        .context("Parameter validation failed")?;
165
166    Ok(params)
167}
168
169/// Convert composed workflow to executable WorkflowConfig
170fn 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}