1use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct EmergePattern {
10 pub id: String,
12 pub name: String,
14 pub description: String,
16 pub solution: String,
18 pub verification: String,
20 #[serde(default)]
22 pub tags: Vec<String>,
23}
24
25#[derive(Debug, Clone)]
27pub struct WorkflowSuggestion {
28 pub pattern: EmergePattern,
30 pub yaml: String,
32 pub target_path: PathBuf,
34}
35
36fn 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
43fn 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
50pub 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
59pub 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 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
94pub fn emerge_to_workflow(pattern: &EmergePattern) -> Result<String> {
96 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 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
145pub 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
157pub 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 let result = scan_patterns();
229 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 assert!(yaml.contains("steps:"));
248 }
249}