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::Pipeline => "pipeline",
154 super::def::NodeType::Approval => "approval",
155 super::def::NodeType::Wait => "wait",
156 super::def::NodeType::SubWorkflow => "subworkflow",
157 super::def::NodeType::Start => "start",
158 super::def::NodeType::End => "end",
159 };
160 tags.push(type_tag.to_string());
161
162 if let Some(ref task) = node.task {
164 tags.push(task.clone());
165 }
166 }
167
168 tags
169 }
170
171 pub fn list(&self) -> Vec<&WorkflowInfo> {
173 self.workflows.values().collect()
174 }
175
176 pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
178 self.workflows.get(id)
179 }
180
181 pub fn match_workflows(&self, query: &str) -> Vec<&WorkflowInfo> {
185 let query_lower = query.to_lowercase();
186 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
187
188 let mut scored: Vec<(usize, &WorkflowInfo)> = self
190 .workflows
191 .values()
192 .map(|info| {
193 let score = self.calculate_match_score(info, &query_words, &query_lower);
194 (score, info)
195 })
196 .filter(|(score, _)| *score > 0)
197 .collect();
198
199 scored.sort_by(|a, b| b.0.cmp(&a.0));
201
202 scored.iter().map(|(_, info)| *info).collect()
203 }
204
205 fn calculate_match_score(
207 &self,
208 info: &WorkflowInfo,
209 query_words: &[&str],
210 query_lower: &str,
211 ) -> usize {
212 let mut score = 0;
213
214 if info.id.to_lowercase() == query_lower {
216 score += 100;
217 }
218
219 if info.name.to_lowercase().contains(query_lower) {
221 score += 50;
222 }
223
224 for tag in &info.tags {
226 let tag_lower = tag.to_lowercase();
227
228 for word in query_words {
230 if tag_lower == *word {
231 score += 30;
232 }
233 if tag_lower.contains(word) {
235 score += 10;
236 }
237 }
238
239 if query_lower.contains(&tag_lower) {
241 score += 15;
242 }
243 }
244
245 if let Some(ref desc) = info.description {
247 let desc_lower = desc.to_lowercase();
248 for word in query_words {
249 if desc_lower.contains(word) {
250 score += 5;
251 }
252 }
253 }
254
255 score
256 }
257
258 pub fn load_workflow(&self, id: &str) -> Result<Option<WorkflowDef>> {
260 if let Some(info) = self.get(id) {
261 let workflow = parse_workflow_from_file(&info.path)?;
262 Ok(Some(workflow))
263 } else {
264 Ok(None)
265 }
266 }
267
268 pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
270 self.match_workflows(query).first().copied()
271 }
272
273 pub fn is_empty(&self) -> bool {
275 self.workflows.is_empty()
276 }
277
278 pub fn count(&self) -> usize {
280 self.workflows.len()
281 }
282
283 pub fn generate_summary(&self) -> String {
285 if self.is_empty() {
286 return "No workflows available.".to_string();
287 }
288
289 let mut summary = format!("Available workflows ({}):\n\n", self.count());
290
291 for info in self.list() {
292 let source = if info.source == WorkflowSource::Project {
293 "project"
294 } else {
295 "global"
296 };
297 summary.push_str(&format!("• {} - {} [{}]\n", info.id, info.name, source));
298
299 if let Some(ref desc) = info.description {
300 let desc_short = desc.chars().take(50).collect::<String>();
301 summary.push_str(&format!(" {}\n", desc_short));
302 }
303
304 if !info.required_inputs.is_empty() {
305 summary.push_str(&format!(
306 " Required inputs: {}\n",
307 info.required_inputs.join(", ")
308 ));
309 }
310 }
311
312 summary.push_str("\nUsage: 'run workflow <id> with <inputs>' or describe your intent.");
313 summary
314 }
315}
316
317impl Default for WorkflowRegistry {
318 fn default() -> Self {
319 Self::new_global()
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_registry_creation() {
329 let registry = WorkflowRegistry::new_global();
330 let _count = registry.count();
332 }
333
334 #[test]
335 fn test_match_empty_registry() {
336 let registry = WorkflowRegistry::new_global();
337 let matches = registry.match_workflows("test query");
338 assert!(matches.is_empty() || registry.count() > 0);
340 }
341
342 #[test]
343 fn test_generate_summary() {
344 let registry = WorkflowRegistry::new_global();
345 let summary = registry.generate_summary();
346 assert!(summary.contains("workflows") || summary.contains("No workflows"));
347 }
348
349 #[test]
350 fn test_discover_image_article_workflow() {
351 let registry = WorkflowRegistry::new_global();
352
353 let info = registry.get("image-article");
355 if let Some(workflow_info) = info {
356 assert_eq!(workflow_info.id, "image-article");
357 assert_eq!(workflow_info.name, "Image Article Generator");
358 assert!(workflow_info.required_inputs.contains(&"topic".to_string()));
359 } else {
360 assert!(
362 registry.get("hello-world").is_some(),
363 "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/"
364 );
365 }
366 }
367}