Skip to main content

mur_core/workflow/
nl.rs

1//! Natural Language → Workflow generator.
2//!
3//! Uses a Thinking model to parse natural language descriptions
4//! into executable workflow YAML.
5
6use crate::model::router::ModelRouter;
7use crate::types::Workflow;
8use crate::workflow::parser;
9use anyhow::{Context, Result};
10use std::sync::Arc;
11
12/// Generates workflow YAML from natural language descriptions.
13pub struct NLWorkflowGenerator {
14    router: Arc<ModelRouter>,
15}
16
17/// Result of NL workflow generation.
18#[derive(Debug, Clone)]
19pub struct GeneratedWorkflow {
20    /// The generated YAML string.
21    pub yaml: String,
22    /// The parsed workflow (if YAML is valid).
23    pub workflow: Option<Workflow>,
24    /// Model explanation of what was generated.
25    pub explanation: String,
26}
27
28impl NLWorkflowGenerator {
29    pub fn new(router: Arc<ModelRouter>) -> Self {
30        Self { router }
31    }
32
33    /// Generate a workflow from a natural language description.
34    pub async fn generate(&self, description: &str) -> Result<GeneratedWorkflow> {
35        let prompt = format!(
36            r#"Generate a MUR Commander workflow YAML from this description:
37
38"{}"
39
40The YAML format is:
41```yaml
42id: <kebab-case-id>
43name: <human-readable name>
44description: <what the workflow does>
45variables:
46  <key>: "<default value>"
47steps:
48  - name: <step-name>
49    step_type: <analyze|plan|debug|code|refactor|fix|search|classify|summarize|security_check|execute>
50    action: "<shell command or AI prompt>"
51    on_failure: <abort|skip|retry>
52    on_failure_max: <number>  # only if retry
53    breakpoint: <true|false>
54    breakpoint_message: "<optional message>"
55```
56
57Rules:
58- Use `{{{{variable}}}}` syntax for variable interpolation
59- Set breakpoints before destructive actions (deploy, delete, restart)
60- Use appropriate step_types (analyze for AI analysis, execute for shell commands)
61- Keep steps focused and atomic
62
63Respond with:
641. The YAML block (in ```yaml fences)
652. A brief explanation of each step
66
67YAML only, no extra prose before the yaml block."#,
68            description
69        );
70
71        let response = self
72            .router
73            .complete_for_step(&crate::types::StepType::Plan, &prompt)
74            .await
75            .context("Generating workflow from NL")?;
76
77        let content = &response.content;
78
79        // Extract YAML from fenced code block
80        let yaml = extract_yaml_block(content).unwrap_or_else(|| content.clone());
81
82        // Extract explanation (everything after the YAML block)
83        let explanation = content
84            .split("```")
85            .nth(2)
86            .unwrap_or("")
87            .trim()
88            .to_string();
89
90        // Try to parse the generated YAML
91        let workflow = parser::parse_workflow_str(&yaml).ok();
92
93        Ok(GeneratedWorkflow {
94            yaml,
95            workflow,
96            explanation,
97        })
98    }
99
100    /// Save a generated workflow to the workflows directory.
101    pub async fn save(&self, generated: &GeneratedWorkflow) -> Result<std::path::PathBuf> {
102        let workflow = generated
103            .workflow
104            .as_ref()
105            .context("Cannot save invalid workflow YAML")?;
106
107        let dir = parser::workflows_dir();
108        tokio::fs::create_dir_all(&dir).await?;
109
110        let path = dir.join(format!("{}.yaml", workflow.id));
111        tokio::fs::write(&path, &generated.yaml).await?;
112
113        tracing::info!("Saved workflow to {:?}", path);
114        Ok(path)
115    }
116}
117
118/// Extract YAML content from a fenced code block.
119fn extract_yaml_block(content: &str) -> Option<String> {
120    let start = content.find("```yaml").or_else(|| content.find("```yml"))?;
121    let after_fence = &content[start..];
122    let content_start = after_fence.find('\n')? + 1;
123    let end = after_fence[content_start..].find("```")?;
124    Some(after_fence[content_start..content_start + end].trim().to_string())
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_extract_yaml_block() {
133        let input = r#"Here's the workflow:
134
135```yaml
136id: test
137name: Test
138steps: []
139```
140
141This workflow does XYZ."#;
142
143        let yaml = extract_yaml_block(input).unwrap();
144        assert!(yaml.contains("id: test"));
145        assert!(!yaml.contains("```"));
146    }
147
148    #[test]
149    fn test_extract_yaml_no_block() {
150        let input = "Just plain text";
151        assert!(extract_yaml_block(input).is_none());
152    }
153}