matrixcode_core/workflow/
registry.rs1use anyhow::Result;
8use std::path::PathBuf;
9use std::collections::HashMap;
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 self.discover_from_dir(&proj, WorkflowSource::Project)?;
93 }
94
95 if user_path.exists() {
97 self.discover_from_dir(&user_path, WorkflowSource::User)?;
98 }
99
100 log::info!("Discovered {} workflows", self.workflows.len());
101 Ok(())
102 }
103
104 fn discover_from_dir(&mut self, dir: &PathBuf, source: WorkflowSource) -> Result<()> {
106 for entry in std::fs::read_dir(dir)? {
107 let entry = entry?;
108 let path = entry.path();
109
110 if path.extension().is_some_and(|ext| ext == "yaml" || ext == "yml")
112 && let Ok(workflow) = parse_workflow_from_file(&path) {
113 let info = WorkflowInfo {
114 id: workflow.id.clone(),
115 name: workflow.name.clone(),
116 description: workflow.description.clone(),
117 path: path.clone(),
118 source: source.clone(),
119 tags: self.extract_tags(&workflow),
120 required_inputs: workflow.inputs.iter()
121 .filter(|i| i.required)
122 .map(|i| i.name.clone())
123 .collect(),
124 };
125
126 self.workflows.insert(workflow.id.clone(), info);
127 }
128 }
129
130 Ok(())
131 }
132
133 fn extract_tags(&self, workflow: &WorkflowDef) -> Vec<String> {
135 let mut tags = Vec::new();
136
137 tags.push(workflow.id.clone());
139 tags.extend(workflow.name.split_whitespace().map(|s| s.to_lowercase()));
140
141 for node in &workflow.nodes {
143 let type_tag = match node.node_type {
144 super::def::NodeType::Task => "task",
145 super::def::NodeType::Condition => "condition",
146 super::def::NodeType::Parallel => "parallel",
147 super::def::NodeType::Approval => "approval",
148 super::def::NodeType::Wait => "wait",
149 super::def::NodeType::SubWorkflow => "subworkflow",
150 super::def::NodeType::Start => "start",
151 super::def::NodeType::End => "end",
152 };
153 tags.push(type_tag.to_string());
154
155 if let Some(ref task) = node.task {
157 tags.push(task.clone());
158 }
159 }
160
161 tags
162 }
163
164 pub fn list(&self) -> Vec<&WorkflowInfo> {
166 self.workflows.values().collect()
167 }
168
169 pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
171 self.workflows.get(id)
172 }
173
174 pub fn match_workflows(&self, query: &str) -> Vec<&WorkflowInfo> {
178 let query_lower = query.to_lowercase();
179 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
180
181 let mut scored: Vec<(usize, &WorkflowInfo)> = self.workflows.values()
183 .map(|info| {
184 let score = self.calculate_match_score(info, &query_words, &query_lower);
185 (score, info)
186 })
187 .filter(|(score, _)| *score > 0)
188 .collect();
189
190 scored.sort_by(|a, b| b.0.cmp(&a.0));
192
193 scored.iter().map(|(_, info)| *info).collect()
194 }
195
196 fn calculate_match_score(&self, info: &WorkflowInfo, query_words: &[&str], query_lower: &str) -> usize {
198 let mut score = 0;
199
200 if info.id.to_lowercase() == query_lower {
202 score += 100;
203 }
204
205 if info.name.to_lowercase().contains(query_lower) {
207 score += 50;
208 }
209
210 for tag in &info.tags {
212 let tag_lower = tag.to_lowercase();
213
214 for word in query_words {
216 if tag_lower == *word {
217 score += 30;
218 }
219 if tag_lower.contains(word) {
221 score += 10;
222 }
223 }
224
225 if query_lower.contains(&tag_lower) {
227 score += 15;
228 }
229 }
230
231 if let Some(ref desc) = info.description {
233 let desc_lower = desc.to_lowercase();
234 for word in query_words {
235 if desc_lower.contains(word) {
236 score += 5;
237 }
238 }
239 }
240
241 score
242 }
243
244 pub fn load_workflow(&self, id: &str) -> Result<Option<WorkflowDef>> {
246 if let Some(info) = self.get(id) {
247 let workflow = parse_workflow_from_file(&info.path)?;
248 Ok(Some(workflow))
249 } else {
250 Ok(None)
251 }
252 }
253
254 pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
256 self.match_workflows(query).first().copied()
257 }
258
259 pub fn is_empty(&self) -> bool {
261 self.workflows.is_empty()
262 }
263
264 pub fn count(&self) -> usize {
266 self.workflows.len()
267 }
268
269 pub fn generate_summary(&self) -> String {
271 if self.is_empty() {
272 return "No workflows available.".to_string();
273 }
274
275 let mut summary = format!("Available workflows ({}):\n\n", self.count());
276
277 for info in self.list() {
278 let source = if info.source == WorkflowSource::Project { "project" } else { "global" };
279 summary.push_str(&format!(
280 "• {} - {} [{}]\n",
281 info.id,
282 info.name,
283 source
284 ));
285
286 if let Some(ref desc) = info.description {
287 let desc_short = desc.chars().take(50).collect::<String>();
288 summary.push_str(&format!(" {}\n", desc_short));
289 }
290
291 if !info.required_inputs.is_empty() {
292 summary.push_str(&format!(" Required inputs: {}\n", info.required_inputs.join(", ")));
293 }
294 }
295
296 summary.push_str("\nUsage: 'run workflow <id> with <inputs>' or describe your intent.");
297 summary
298 }
299}
300
301impl Default for WorkflowRegistry {
302 fn default() -> Self {
303 Self::new_global()
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_registry_creation() {
313 let registry = WorkflowRegistry::new_global();
314 let _count = registry.count();
316 }
317
318 #[test]
319 fn test_match_empty_registry() {
320 let registry = WorkflowRegistry::new_global();
321 let matches = registry.match_workflows("test query");
322 assert!(matches.is_empty() || registry.count() > 0);
324 }
325
326 #[test]
327 fn test_generate_summary() {
328 let registry = WorkflowRegistry::new_global();
329 let summary = registry.generate_summary();
330 assert!(summary.contains("workflows") || summary.contains("No workflows"));
331 }
332
333 #[test]
334 fn test_discover_image_article_workflow() {
335 let registry = WorkflowRegistry::new_global();
336
337 let info = registry.get("image-article");
339 if let Some(workflow_info) = info {
340 assert_eq!(workflow_info.id, "image-article");
341 assert_eq!(workflow_info.name, "Image Article Generator");
342 assert!(workflow_info.required_inputs.contains(&"topic".to_string()));
343 } else {
344 assert!(registry.get("hello-world").is_some(),
346 "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/");
347 }
348 }
349}