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::path::PathBuf;
9use std::collections::HashMap;
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                self.discover_from_dir(&proj, WorkflowSource::Project)?;
93            }
94
95        // Discover from user directory
96        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    /// Discover workflows from a specific directory
105    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            // Only process .yaml and .yml files
111            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    /// Extract tags from workflow for matching
134    fn extract_tags(&self, workflow: &WorkflowDef) -> Vec<String> {
135        let mut tags = Vec::new();
136
137        // Add ID and name as tags
138        tags.push(workflow.id.clone());
139        tags.extend(workflow.name.split_whitespace().map(|s| s.to_lowercase()));
140
141        // Add node types as tags
142        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            // Add task name if present
156            if let Some(ref task) = node.task {
157                tags.push(task.clone());
158            }
159        }
160
161        tags
162    }
163
164    /// List all discovered workflows
165    pub fn list(&self) -> Vec<&WorkflowInfo> {
166        self.workflows.values().collect()
167    }
168
169    /// Get a specific workflow by ID
170    pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
171        self.workflows.get(id)
172    }
173
174    /// Match workflows by keywords/intent
175    ///
176    /// Returns workflows sorted by match score (highest first)
177    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        // Calculate match scores
182        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        // Sort by score descending
191        scored.sort_by(|a, b| b.0.cmp(&a.0));
192
193        scored.iter().map(|(_, info)| *info).collect()
194    }
195
196    /// Calculate match score for a workflow
197    fn calculate_match_score(&self, info: &WorkflowInfo, query_words: &[&str], query_lower: &str) -> usize {
198        let mut score = 0;
199
200        // Direct ID match
201        if info.id.to_lowercase() == query_lower {
202            score += 100;
203        }
204
205        // Name contains query
206        if info.name.to_lowercase().contains(query_lower) {
207            score += 50;
208        }
209
210        // Tag matches
211        for tag in &info.tags {
212            let tag_lower = tag.to_lowercase();
213
214            // Exact tag match with query word
215            for word in query_words {
216                if tag_lower == *word {
217                    score += 30;
218                }
219                // Tag contains word
220                if tag_lower.contains(word) {
221                    score += 10;
222                }
223            }
224
225            // Query contains tag
226            if query_lower.contains(&tag_lower) {
227                score += 15;
228            }
229        }
230
231        // Description matches
232        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    /// Load a workflow definition by ID
245    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    /// Get the best matching workflow for a query
255    pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
256        self.match_workflows(query).first().copied()
257    }
258
259    /// Check if any workflows are available
260    pub fn is_empty(&self) -> bool {
261        self.workflows.is_empty()
262    }
263
264    /// Get count of discovered workflows
265    pub fn count(&self) -> usize {
266        self.workflows.len()
267    }
268
269    /// Generate a summary for AI context
270    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        // count() returns usize which is always >= 0
315        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        // Should return empty list
323        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        // Check if image-article workflow is discovered
338        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            // If not found, at least verify the hello-world workflow exists
345            assert!(registry.get("hello-world").is_some(), 
346                    "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/");
347        }
348    }
349}