use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
pub mod composer;
pub mod registry;
pub mod sub_workflow;
pub use composer::WorkflowComposer;
pub use registry::{TemplateRegistry, TemplateStorage};
pub use sub_workflow::{SubWorkflow, SubWorkflowExecutor, SubWorkflowResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposableWorkflow {
#[serde(flatten)]
pub config: crate::config::WorkflowConfig,
#[serde(skip_serializing_if = "Option::is_none")]
pub imports: Option<Vec<WorkflowImport>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extends: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<WorkflowTemplate>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameters: Option<ParameterDefinitions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub defaults: Option<HashMap<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workflows: Option<HashMap<String, SubWorkflow>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowImport {
pub path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(default)]
pub selective: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowTemplate {
pub name: String,
pub source: TemplateSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub with: Option<HashMap<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "override")]
pub override_field: Option<HashMap<String, Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TemplateSource {
File(PathBuf),
Registry(String),
Url(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterDefinitions {
#[serde(default)]
pub required: Vec<Parameter>,
#[serde(default)]
pub optional: Vec<Parameter>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Parameter {
pub name: String,
#[serde(rename = "type")]
pub type_hint: ParameterType,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParameterType {
String,
Number,
Boolean,
Array,
Object,
Any,
}
#[derive(Debug, Clone)]
pub struct ComposedWorkflow {
pub workflow: ComposableWorkflow,
pub metadata: CompositionMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositionMetadata {
pub sources: Vec<PathBuf>,
pub templates: Vec<String>,
pub parameters: HashMap<String, Value>,
pub composed_at: chrono::DateTime<chrono::Utc>,
pub dependencies: Vec<DependencyInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyInfo {
pub source: PathBuf,
pub dep_type: DependencyType,
pub resolved: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyType {
Import,
Extends,
Template,
SubWorkflow,
}
impl ComposableWorkflow {
pub fn from_config(config: crate::config::WorkflowConfig) -> Self {
Self {
config,
imports: None,
extends: None,
template: None,
parameters: None,
defaults: None,
workflows: None,
}
}
pub fn uses_composition(&self) -> bool {
self.imports.is_some()
|| self.extends.is_some()
|| self.template.is_some()
|| self.workflows.is_some()
}
pub fn required_parameters(&self) -> Vec<&Parameter> {
self.parameters
.as_ref()
.map(|p| p.required.iter().collect())
.unwrap_or_default()
}
pub fn validate_parameters(&self, provided: &HashMap<String, Value>) -> Result<()> {
if let Some(params) = &self.parameters {
for param in ¶ms.required {
if !provided.contains_key(¶m.name) && param.default.is_none() {
anyhow::bail!("Required parameter '{}' not provided", param.name);
}
}
for (name, value) in provided {
if let Some(param) = params
.required
.iter()
.chain(params.optional.iter())
.find(|p| p.name == *name)
{
self.validate_parameter_value(param, value)
.with_context(|| format!("Invalid value for parameter '{}'", name))?;
}
}
}
Ok(())
}
fn validate_parameter_value(&self, param: &Parameter, value: &Value) -> Result<()> {
match (¶m.type_hint, value) {
(ParameterType::String, Value::String(_)) => {}
(ParameterType::Number, Value::Number(_)) => {}
(ParameterType::Boolean, Value::Bool(_)) => {}
(ParameterType::Array, Value::Array(_)) => {}
(ParameterType::Object, Value::Object(_)) => {}
(ParameterType::Any, _) => {}
_ => anyhow::bail!(
"Type mismatch: expected {:?}, got {}",
param.type_hint,
value
),
}
if let Some(validation) = ¶m.validation {
tracing::debug!(
"Custom validation for parameter '{}': {}",
param.name,
validation
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_composable_workflow_creation() {
let config = crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
};
let workflow = ComposableWorkflow::from_config(config);
assert!(!workflow.uses_composition());
}
#[test]
fn test_parameter_validation() {
let mut workflow = ComposableWorkflow::from_config(crate::config::WorkflowConfig {
name: None,
commands: vec![],
env: None,
secrets: None,
env_files: None,
profiles: None,
merge: None,
});
workflow.parameters = Some(ParameterDefinitions {
required: vec![Parameter {
name: "target".to_string(),
type_hint: ParameterType::String,
description: "Target file".to_string(),
default: None,
validation: None,
}],
optional: vec![],
});
let mut params = HashMap::new();
params.insert("target".to_string(), Value::String("file.js".to_string()));
assert!(workflow.validate_parameters(¶ms).is_ok());
let empty_params = HashMap::new();
assert!(workflow.validate_parameters(&empty_params).is_err());
}
}