Skip to main content

matrixcode_core/workflow/
registry.rs

1//! Workflow Registry - Discovery and Matching
2//!
3//! Automatically discovers and manages available workflows from:
4//! - Project directory: .matrix/workflows/*.yaml
5//! - User directory: ~/.matrix/workflows/*.yaml
6
7use anyhow::Result;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use super::def::WorkflowDef;
12use super::parser::parse_workflow_from_file;
13
14/// Workflow metadata for matching
15#[derive(Debug, Clone)]
16pub struct WorkflowInfo {
17    /// Workflow ID
18    pub id: String,
19    /// Workflow name
20    pub name: String,
21    /// Workflow description
22    pub description: Option<String>,
23    /// File path
24    pub path: PathBuf,
25    /// Source (project or user)
26    pub source: WorkflowSource,
27    /// Tags for matching
28    pub tags: Vec<String>,
29    /// Required inputs
30    pub required_inputs: Vec<String>,
31}
32
33/// Where the workflow was discovered
34#[derive(Debug, Clone, PartialEq)]
35pub enum WorkflowSource {
36    /// Project-local workflow
37    Project,
38    /// User-global workflow
39    User,
40}
41
42/// Workflow registry - discovers and manages available workflows
43pub struct WorkflowRegistry {
44    /// Discovered workflows
45    workflows: HashMap<String, WorkflowInfo>,
46    /// Project workflows directory
47    project_path: Option<PathBuf>,
48    /// User workflows directory
49    user_path: PathBuf,
50}
51
52impl WorkflowRegistry {
53    /// Create a new registry with project context
54    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        // Auto-discover on creation
69        if let Err(e) = registry.discover() {
70            log::warn!("Workflow discovery failed: {}", e);
71        }
72
73        registry
74    }
75
76    /// Create registry for user directory only
77    pub fn new_global() -> Self {
78        Self::new(None)
79    }
80
81    /// Discover workflows from both directories
82    pub fn discover(&mut self) -> Result<()> {
83        self.workflows.clear();
84
85        // Clone paths to avoid borrow issues
86        let project_path = self.project_path.clone();
87        let user_path = self.user_path.clone();
88
89        // Discover from project directory
90        if let Some(proj) = project_path
91            && proj.exists()
92        {
93            self.discover_from_dir(&proj, WorkflowSource::Project)?;
94        }
95
96        // Discover from user directory
97        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    /// Discover workflows from a specific directory
106    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            // Only process .yaml and .yml files
112            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    /// Extract tags from workflow for matching
140    fn extract_tags(&self, workflow: &WorkflowDef) -> Vec<String> {
141        let mut tags = Vec::new();
142
143        // Add ID and name as tags
144        tags.push(workflow.id.clone());
145        tags.extend(workflow.name.split_whitespace().map(|s| s.to_lowercase()));
146
147        // Add node types as tags
148        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            // Add task name if present
162            if let Some(ref task) = node.task {
163                tags.push(task.clone());
164            }
165        }
166
167        tags
168    }
169
170    /// List all discovered workflows
171    pub fn list(&self) -> Vec<&WorkflowInfo> {
172        self.workflows.values().collect()
173    }
174
175    /// Get a specific workflow by ID
176    pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
177        self.workflows.get(id)
178    }
179
180    /// Match workflows by keywords/intent
181    ///
182    /// Returns workflows sorted by match score (highest first)
183    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        // Calculate match scores
188        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        // Sort by score descending
199        scored.sort_by(|a, b| b.0.cmp(&a.0));
200
201        scored.iter().map(|(_, info)| *info).collect()
202    }
203
204    /// Calculate match score for a workflow
205    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        // Direct ID match
214        if info.id.to_lowercase() == query_lower {
215            score += 100;
216        }
217
218        // Name contains query
219        if info.name.to_lowercase().contains(query_lower) {
220            score += 50;
221        }
222
223        // Tag matches
224        for tag in &info.tags {
225            let tag_lower = tag.to_lowercase();
226
227            // Exact tag match with query word
228            for word in query_words {
229                if tag_lower == *word {
230                    score += 30;
231                }
232                // Tag contains word
233                if tag_lower.contains(word) {
234                    score += 10;
235                }
236            }
237
238            // Query contains tag
239            if query_lower.contains(&tag_lower) {
240                score += 15;
241            }
242        }
243
244        // Description matches
245        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    /// Load a workflow definition by ID
258    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    /// Get the best matching workflow for a query
268    pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
269        self.match_workflows(query).first().copied()
270    }
271
272    /// Check if any workflows are available
273    pub fn is_empty(&self) -> bool {
274        self.workflows.is_empty()
275    }
276
277    /// Get count of discovered workflows
278    pub fn count(&self) -> usize {
279        self.workflows.len()
280    }
281
282    /// Generate a summary for AI context
283    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        // count() returns usize which is always >= 0
330        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        // Should return empty list
338        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        // Check if image-article workflow is discovered
353        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            // If not found, at least verify the hello-world workflow exists
360            assert!(
361                registry.get("hello-world").is_some(),
362                "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/"
363            );
364        }
365    }
366}