Skip to main content

mur_core/
emerge.rs

1//! MUR Emerge bridge — convert emerge patterns into Commander workflows.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7/// A MUR emerge pattern loaded from `~/.mur/patterns/`.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EmergePattern {
10    /// Pattern identifier (derived from filename).
11    pub id: String,
12    /// Human-readable name.
13    pub name: String,
14    /// Description of what the pattern does.
15    pub description: String,
16    /// The solution / steps to execute.
17    pub solution: String,
18    /// Verification command or check.
19    pub verification: String,
20    /// Tags for categorization.
21    #[serde(default)]
22    pub tags: Vec<String>,
23}
24
25/// Suggestion for creating a workflow from a detected pattern.
26#[derive(Debug, Clone)]
27pub struct WorkflowSuggestion {
28    /// The source pattern.
29    pub pattern: EmergePattern,
30    /// Generated YAML content.
31    pub yaml: String,
32    /// Suggested workflow file path.
33    pub target_path: PathBuf,
34}
35
36/// Get the patterns directory path.
37fn patterns_dir() -> PathBuf {
38    directories::BaseDirs::new()
39        .map(|d| d.home_dir().join(".mur").join("patterns"))
40        .unwrap_or_else(|| PathBuf::from(".mur/patterns"))
41}
42
43/// Get the workflows directory path.
44fn workflows_dir() -> PathBuf {
45    directories::BaseDirs::new()
46        .map(|d| d.home_dir().join(".mur").join("workflows"))
47        .unwrap_or_else(|| PathBuf::from(".mur/workflows"))
48}
49
50/// Load a single emerge pattern from a YAML file.
51pub fn load_pattern(path: &Path) -> Result<EmergePattern> {
52    let content = std::fs::read_to_string(path)
53        .with_context(|| format!("Reading pattern file {:?}", path))?;
54    let pattern: EmergePattern =
55        serde_yaml::from_str(&content).with_context(|| format!("Parsing pattern {:?}", path))?;
56    Ok(pattern)
57}
58
59/// Scan `~/.mur/patterns/` for all emerge pattern files.
60pub fn scan_patterns() -> Result<Vec<EmergePattern>> {
61    let dir = patterns_dir();
62    if !dir.exists() {
63        return Ok(vec![]);
64    }
65
66    let mut patterns = Vec::new();
67    let pattern_glob = dir.join("*.yaml").to_string_lossy().to_string();
68
69    for entry in glob::glob(&pattern_glob)
70        .context("Invalid glob pattern")?
71        .filter_map(|e| e.ok())
72    {
73        match load_pattern(&entry) {
74            Ok(p) => patterns.push(p),
75            Err(e) => tracing::warn!("Skipping pattern {:?}: {}", entry, e),
76        }
77    }
78
79    // Also check .yml
80    let yml_glob = dir.join("*.yml").to_string_lossy().to_string();
81    for entry in glob::glob(&yml_glob)
82        .context("Invalid glob pattern")?
83        .filter_map(|e| e.ok())
84    {
85        match load_pattern(&entry) {
86            Ok(p) => patterns.push(p),
87            Err(e) => tracing::warn!("Skipping pattern {:?}: {}", entry, e),
88        }
89    }
90
91    Ok(patterns)
92}
93
94/// Convert a MUR emerge pattern into a Commander workflow YAML string.
95pub fn emerge_to_workflow(pattern: &EmergePattern) -> Result<String> {
96    // Split the solution into steps by newline (each line becomes a step)
97    let solution_lines: Vec<&str> = pattern
98        .solution
99        .lines()
100        .map(|l| l.trim())
101        .filter(|l| !l.is_empty())
102        .collect();
103
104    let mut steps = Vec::new();
105
106    for (i, line) in solution_lines.iter().enumerate() {
107        steps.push(format!(
108            r#"  - name: "step-{}"
109    step_type: execute
110    action: "{}"
111    on_failure: abort"#,
112            i + 1,
113            line.replace('"', "\\\"")
114        ));
115    }
116
117    // Add verification step if present
118    if !pattern.verification.is_empty() {
119        steps.push(format!(
120            r#"  - name: "verify"
121    step_type: analyze
122    action: "{}"
123    on_failure: abort"#,
124            pattern.verification.replace('"', "\\\"")
125        ));
126    }
127
128    let yaml = format!(
129        r#"id: "emerge-{id}"
130name: "{name}"
131description: "{desc}"
132variables: {{}}
133steps:
134{steps}
135"#,
136        id = pattern.id,
137        name = pattern.name.replace('"', "\\\""),
138        desc = pattern.description.replace('"', "\\\""),
139        steps = steps.join("\n"),
140    );
141
142    Ok(yaml)
143}
144
145/// Generate a workflow suggestion from an emerge pattern.
146pub fn suggest_workflow(pattern: &EmergePattern) -> Result<WorkflowSuggestion> {
147    let yaml = emerge_to_workflow(pattern)?;
148    let target_path = workflows_dir().join(format!("emerge-{}.yaml", pattern.id));
149
150    Ok(WorkflowSuggestion {
151        pattern: pattern.clone(),
152        yaml,
153        target_path,
154    })
155}
156
157/// Save a generated workflow YAML to the workflows directory.
158pub fn save_workflow(suggestion: &WorkflowSuggestion) -> Result<PathBuf> {
159    let dir = workflows_dir();
160    if !dir.exists() {
161        std::fs::create_dir_all(&dir)
162            .with_context(|| format!("Creating workflows directory {:?}", dir))?;
163    }
164
165    std::fs::write(&suggestion.target_path, &suggestion.yaml)
166        .with_context(|| format!("Writing workflow {:?}", suggestion.target_path))?;
167
168    Ok(suggestion.target_path.clone())
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn sample_pattern() -> EmergePattern {
176        EmergePattern {
177            id: "fix-build".into(),
178            name: "Fix Build Errors".into(),
179            description: "Automatically fix common build errors".into(),
180            solution: "cargo check 2>&1\ncargo fix --allow-dirty".into(),
181            verification: "cargo build".into(),
182            tags: vec!["rust".into(), "build".into()],
183        }
184    }
185
186    #[test]
187    fn test_emerge_to_workflow() {
188        let pattern = sample_pattern();
189        let yaml = emerge_to_workflow(&pattern).unwrap();
190
191        assert!(yaml.contains("emerge-fix-build"));
192        assert!(yaml.contains("Fix Build Errors"));
193        assert!(yaml.contains("cargo check"));
194        assert!(yaml.contains("cargo fix --allow-dirty"));
195        assert!(yaml.contains("verify"));
196        assert!(yaml.contains("cargo build"));
197    }
198
199    #[test]
200    fn test_suggest_workflow() {
201        let pattern = sample_pattern();
202        let suggestion = suggest_workflow(&pattern).unwrap();
203
204        assert!(suggestion.target_path.to_string_lossy().contains("emerge-fix-build"));
205        assert!(!suggestion.yaml.is_empty());
206    }
207
208    #[test]
209    fn test_load_pattern_from_yaml() {
210        let yaml = r#"
211id: test-pattern
212name: Test Pattern
213description: A test pattern
214solution: "echo hello"
215verification: "echo done"
216tags:
217  - test
218"#;
219        let pattern: EmergePattern = serde_yaml::from_str(yaml).unwrap();
220        assert_eq!(pattern.id, "test-pattern");
221        assert_eq!(pattern.name, "Test Pattern");
222        assert_eq!(pattern.tags, vec!["test"]);
223    }
224
225    #[test]
226    fn test_scan_patterns_empty_dir() {
227        // When the patterns dir doesn't exist, should return empty
228        let result = scan_patterns();
229        // This test depends on the environment; if ~/.mur/patterns exists it may find patterns.
230        // We just verify it doesn't error out.
231        assert!(result.is_ok());
232    }
233
234    #[test]
235    fn test_emerge_to_workflow_empty_solution() {
236        let pattern = EmergePattern {
237            id: "empty".into(),
238            name: "Empty".into(),
239            description: "No solution".into(),
240            solution: String::new(),
241            verification: String::new(),
242            tags: vec![],
243        };
244        let yaml = emerge_to_workflow(&pattern).unwrap();
245        assert!(yaml.contains("emerge-empty"));
246        // No steps except possibly an empty verification
247        assert!(yaml.contains("steps:"));
248    }
249}