matrixcode_core/workflow/
registry.rs1use anyhow::Result;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use super::def::WorkflowDef;
12use super::parser::parse_workflow_from_file;
13
14#[derive(Debug, Clone)]
16pub struct WorkflowInfo {
17 pub id: String,
19 pub name: String,
21 pub description: Option<String>,
23 pub path: PathBuf,
25 pub source: WorkflowSource,
27 pub tags: Vec<String>,
29 pub required_inputs: Vec<String>,
31}
32
33#[derive(Debug, Clone, PartialEq)]
35pub enum WorkflowSource {
36 Project,
38 User,
40}
41
42pub struct WorkflowRegistry {
44 workflows: HashMap<String, WorkflowInfo>,
46 project_path: Option<PathBuf>,
48 user_path: PathBuf,
50}
51
52impl WorkflowRegistry {
53 pub fn new(project_dir: Option<&PathBuf>) -> Self {
55 let user_path = dirs::home_dir()
56 .unwrap_or_else(|| PathBuf::from("."))
57 .join(".matrix")
58 .join("workflows");
59
60 let project_path = project_dir.map(|p| p.join(".matrix").join("workflows"));
61
62 let mut registry = Self {
63 workflows: HashMap::new(),
64 project_path,
65 user_path,
66 };
67
68 if let Err(e) = registry.discover() {
70 log::warn!("Workflow discovery failed: {}", e);
71 }
72
73 registry
74 }
75
76 pub fn new_global() -> Self {
78 Self::new(None)
79 }
80
81 pub fn discover(&mut self) -> Result<()> {
83 self.workflows.clear();
84
85 let project_path = self.project_path.clone();
87 let user_path = self.user_path.clone();
88
89 if let Some(proj) = project_path
91 && proj.exists()
92 {
93 self.discover_from_dir(&proj, WorkflowSource::Project)?;
94 }
95
96 if user_path.exists() {
98 self.discover_from_dir(&user_path, WorkflowSource::User)?;
99 }
100
101 log::info!("Discovered {} workflows", self.workflows.len());
102 Ok(())
103 }
104
105 fn discover_from_dir(&mut self, dir: &PathBuf, source: WorkflowSource) -> Result<()> {
107 for entry in std::fs::read_dir(dir)? {
108 let entry = entry?;
109 let path = entry.path();
110
111 if path
113 .extension()
114 .is_some_and(|ext| ext == "yaml" || ext == "yml")
115 && let Ok(workflow) = parse_workflow_from_file(&path)
116 {
117 let info = WorkflowInfo {
118 id: workflow.id.clone(),
119 name: workflow.name.clone(),
120 description: workflow.description.clone(),
121 path: path.clone(),
122 source: source.clone(),
123 tags: self.extract_tags(&workflow),
124 required_inputs: workflow
125 .inputs
126 .iter()
127 .filter(|i| i.required)
128 .map(|i| i.name.clone())
129 .collect(),
130 };
131
132 self.workflows.insert(workflow.id.clone(), info);
133 }
134 }
135
136 Ok(())
137 }
138
139 fn extract_tags(&self, workflow: &WorkflowDef) -> Vec<String> {
141 let mut tags = Vec::new();
142
143 tags.push(workflow.id.clone());
145 tags.extend(workflow.name.split_whitespace().map(|s| s.to_lowercase()));
146
147 for node in &workflow.nodes {
149 let type_tag = match node.node_type {
150 super::def::NodeType::Task => "task",
151 super::def::NodeType::Condition => "condition",
152 super::def::NodeType::Parallel => "parallel",
153 super::def::NodeType::Approval => "approval",
154 super::def::NodeType::Wait => "wait",
155 super::def::NodeType::SubWorkflow => "subworkflow",
156 super::def::NodeType::Start => "start",
157 super::def::NodeType::End => "end",
158 };
159 tags.push(type_tag.to_string());
160
161 if let Some(ref task) = node.task {
163 tags.push(task.clone());
164 }
165 }
166
167 tags
168 }
169
170 pub fn list(&self) -> Vec<&WorkflowInfo> {
172 self.workflows.values().collect()
173 }
174
175 pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
177 self.workflows.get(id)
178 }
179
180 pub fn match_workflows(&self, query: &str) -> Vec<&WorkflowInfo> {
184 let query_lower = query.to_lowercase();
185 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
186
187 let mut scored: Vec<(usize, &WorkflowInfo)> = self
189 .workflows
190 .values()
191 .map(|info| {
192 let score = self.calculate_match_score(info, &query_words, &query_lower);
193 (score, info)
194 })
195 .filter(|(score, _)| *score > 0)
196 .collect();
197
198 scored.sort_by(|a, b| b.0.cmp(&a.0));
200
201 scored.iter().map(|(_, info)| *info).collect()
202 }
203
204 fn calculate_match_score(
206 &self,
207 info: &WorkflowInfo,
208 query_words: &[&str],
209 query_lower: &str,
210 ) -> usize {
211 let mut score = 0;
212
213 if info.id.to_lowercase() == query_lower {
215 score += 100;
216 }
217
218 if info.name.to_lowercase().contains(query_lower) {
220 score += 50;
221 }
222
223 for tag in &info.tags {
225 let tag_lower = tag.to_lowercase();
226
227 for word in query_words {
229 if tag_lower == *word {
230 score += 30;
231 }
232 if tag_lower.contains(word) {
234 score += 10;
235 }
236 }
237
238 if query_lower.contains(&tag_lower) {
240 score += 15;
241 }
242 }
243
244 if let Some(ref desc) = info.description {
246 let desc_lower = desc.to_lowercase();
247 for word in query_words {
248 if desc_lower.contains(word) {
249 score += 5;
250 }
251 }
252 }
253
254 score
255 }
256
257 pub fn load_workflow(&self, id: &str) -> Result<Option<WorkflowDef>> {
259 if let Some(info) = self.get(id) {
260 let workflow = parse_workflow_from_file(&info.path)?;
261 Ok(Some(workflow))
262 } else {
263 Ok(None)
264 }
265 }
266
267 pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
269 self.match_workflows(query).first().copied()
270 }
271
272 pub fn is_empty(&self) -> bool {
274 self.workflows.is_empty()
275 }
276
277 pub fn count(&self) -> usize {
279 self.workflows.len()
280 }
281
282 pub fn generate_summary(&self) -> String {
284 if self.is_empty() {
285 return "No workflows available.".to_string();
286 }
287
288 let mut summary = format!("Available workflows ({}):\n\n", self.count());
289
290 for info in self.list() {
291 let source = if info.source == WorkflowSource::Project {
292 "project"
293 } else {
294 "global"
295 };
296 summary.push_str(&format!("• {} - {} [{}]\n", info.id, info.name, source));
297
298 if let Some(ref desc) = info.description {
299 let desc_short = desc.chars().take(50).collect::<String>();
300 summary.push_str(&format!(" {}\n", desc_short));
301 }
302
303 if !info.required_inputs.is_empty() {
304 summary.push_str(&format!(
305 " Required inputs: {}\n",
306 info.required_inputs.join(", ")
307 ));
308 }
309 }
310
311 summary.push_str("\nUsage: 'run workflow <id> with <inputs>' or describe your intent.");
312 summary
313 }
314}
315
316impl Default for WorkflowRegistry {
317 fn default() -> Self {
318 Self::new_global()
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_registry_creation() {
328 let registry = WorkflowRegistry::new_global();
329 let _count = registry.count();
331 }
332
333 #[test]
334 fn test_match_empty_registry() {
335 let registry = WorkflowRegistry::new_global();
336 let matches = registry.match_workflows("test query");
337 assert!(matches.is_empty() || registry.count() > 0);
339 }
340
341 #[test]
342 fn test_generate_summary() {
343 let registry = WorkflowRegistry::new_global();
344 let summary = registry.generate_summary();
345 assert!(summary.contains("workflows") || summary.contains("No workflows"));
346 }
347
348 #[test]
349 fn test_discover_image_article_workflow() {
350 let registry = WorkflowRegistry::new_global();
351
352 let info = registry.get("image-article");
354 if let Some(workflow_info) = info {
355 assert_eq!(workflow_info.id, "image-article");
356 assert_eq!(workflow_info.name, "Image Article Generator");
357 assert!(workflow_info.required_inputs.contains(&"topic".to_string()));
358 } else {
359 assert!(
361 registry.get("hello-world").is_some(),
362 "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/"
363 );
364 }
365 }
366}