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::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            // Add task name if present
163            if let Some(ref task) = node.task {
164                tags.push(task.clone());
165            }
166        }
167
168        tags
169    }
170
171    /// List all discovered workflows
172    pub fn list(&self) -> Vec<&WorkflowInfo> {
173        self.workflows.values().collect()
174    }
175
176    /// Get a specific workflow by ID
177    pub fn get(&self, id: &str) -> Option<&WorkflowInfo> {
178        self.workflows.get(id)
179    }
180
181    /// Match workflows by keywords/intent
182    ///
183    /// Returns workflows sorted by match score (highest first)
184    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        // Calculate match scores
189        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        // Sort by score descending
200        scored.sort_by(|a, b| b.0.cmp(&a.0));
201
202        scored.iter().map(|(_, info)| *info).collect()
203    }
204
205    /// Calculate match score for a workflow
206    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        // Direct ID match
215        if info.id.to_lowercase() == query_lower {
216            score += 100;
217        }
218
219        // Name contains query
220        if info.name.to_lowercase().contains(query_lower) {
221            score += 50;
222        }
223
224        // Tag matches
225        for tag in &info.tags {
226            let tag_lower = tag.to_lowercase();
227
228            // Exact tag match with query word
229            for word in query_words {
230                if tag_lower == *word {
231                    score += 30;
232                }
233                // Tag contains word
234                if tag_lower.contains(word) {
235                    score += 10;
236                }
237            }
238
239            // Query contains tag
240            if query_lower.contains(&tag_lower) {
241                score += 15;
242            }
243        }
244
245        // Description matches
246        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    /// Load a workflow definition by ID
259    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    /// Get the best matching workflow for a query
269    pub fn best_match(&self, query: &str) -> Option<&WorkflowInfo> {
270        self.match_workflows(query).first().copied()
271    }
272
273    /// Check if any workflows are available
274    pub fn is_empty(&self) -> bool {
275        self.workflows.is_empty()
276    }
277
278    /// Get count of discovered workflows
279    pub fn count(&self) -> usize {
280        self.workflows.len()
281    }
282
283    /// Generate a summary for AI context
284    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        // count() returns usize which is always >= 0
331        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        // Should return empty list
339        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        // Check if image-article workflow is discovered
354        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            // If not found, at least verify the hello-world workflow exists
361            assert!(
362                registry.get("hello-world").is_some(),
363                "Neither image-article nor hello-world workflows found in ~/.matrix/workflows/"
364            );
365        }
366    }
367}