Skip to main content

composio_sdk/wizard/
skills.rs

1//! Skills extraction utilities for wizard instruction generation
2//!
3//! This module provides functionality to extract and parse Composio Skills
4//! content from the official Skills repository.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Errors that can occur during skills extraction
11#[derive(Debug, thiserror::Error)]
12pub enum SkillsError {
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15
16    #[error("Failed to parse frontmatter: {0}")]
17    FrontmatterParse(String),
18
19    #[error("Invalid impact level: {0}")]
20    InvalidImpact(String),
21
22    #[error("Skills path not found: {0}")]
23    PathNotFound(PathBuf),
24}
25
26/// Impact level of a rule
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Impact {
29    Critical,
30    High,
31    Medium,
32    Low,
33}
34
35impl Impact {
36    /// Parse impact from string
37    pub fn from_str(s: &str) -> Result<Self, SkillsError> {
38        match s.to_uppercase().as_str() {
39            "CRITICAL" => Ok(Impact::Critical),
40            "HIGH" => Ok(Impact::High),
41            "MEDIUM" => Ok(Impact::Medium),
42            "LOW" => Ok(Impact::Low),
43            _ => Err(SkillsError::InvalidImpact(s.to_string())),
44        }
45    }
46
47    /// Convert impact to string
48    pub fn as_str(&self) -> &'static str {
49        match self {
50            Impact::Critical => "CRITICAL",
51            Impact::High => "HIGH",
52            Impact::Medium => "MEDIUM",
53            Impact::Low => "LOW",
54        }
55    }
56}
57
58/// A rule extracted from the Skills repository
59#[derive(Debug, Clone)]
60pub struct Rule {
61    pub title: String,
62    pub impact: Impact,
63    pub description: String,
64    pub tags: Vec<String>,
65    pub content: String,
66    pub correct_examples: Vec<String>,
67    pub incorrect_examples: Vec<String>,
68}
69
70impl Rule {
71    /// Parse a rule from a markdown file
72    pub fn from_file(path: &Path) -> Result<Self, SkillsError> {
73        let content = fs::read_to_string(path)?;
74        Self::from_content(&content)
75    }
76
77    /// Parse a rule from markdown content
78    pub fn from_content(content: &str) -> Result<Self, SkillsError> {
79        let (frontmatter, body) = Self::parse_markdown(content)?;
80
81        let title = frontmatter
82            .get("title")
83            .cloned()
84            .unwrap_or_else(|| "Untitled".to_string());
85
86        let impact = frontmatter
87            .get("impact")
88            .map(|s| Impact::from_str(s))
89            .transpose()?
90            .unwrap_or(Impact::Medium);
91
92        let description = frontmatter
93            .get("description")
94            .cloned()
95            .unwrap_or_default();
96
97        let tags = frontmatter
98            .get("tags")
99            .map(|t| {
100                t.trim_matches(|c| c == '[' || c == ']')
101                    .split(',')
102                    .map(|s| s.trim().to_string())
103                    .filter(|s| !s.is_empty())
104                    .collect()
105            })
106            .unwrap_or_default();
107
108        let correct_examples = Self::extract_examples(&body, "✅");
109        let incorrect_examples = Self::extract_examples(&body, "❌");
110
111        Ok(Self {
112            title,
113            impact,
114            description,
115            tags,
116            content: body,
117            correct_examples,
118            incorrect_examples,
119        })
120    }
121
122    /// Parse markdown with frontmatter
123    fn parse_markdown(content: &str) -> Result<(HashMap<String, String>, String), SkillsError> {
124        let mut frontmatter = HashMap::new();
125        let body;
126
127        let lines: Vec<&str> = content.lines().collect();
128        let mut in_frontmatter = false;
129        let mut frontmatter_end = 0;
130
131        // Check if content starts with frontmatter
132        if lines.first().map(|l| l.trim()) == Some("---") {
133            in_frontmatter = true;
134            frontmatter_end = 1;
135
136            // Parse frontmatter
137            for (i, line) in lines.iter().enumerate().skip(1) {
138                if line.trim() == "---" {
139                    frontmatter_end = i + 1;
140                    break;
141                }
142
143                if let Some((key, value)) = line.split_once(':') {
144                    frontmatter.insert(
145                        key.trim().to_string(),
146                        value.trim().to_string(),
147                    );
148                }
149            }
150        }
151
152        // Extract body
153        if in_frontmatter && frontmatter_end < lines.len() {
154            body = lines[frontmatter_end..].join("\n");
155        } else {
156            body = content.to_string();
157        }
158
159        Ok((frontmatter, body))
160    }
161
162    /// Extract code examples following a marker (✅ or ❌)
163    fn extract_examples(content: &str, marker: &str) -> Vec<String> {
164        let mut examples = Vec::new();
165        let lines: Vec<&str> = content.lines().collect();
166        let mut i = 0;
167
168        while i < lines.len() {
169            let line = lines[i];
170
171            // Look for marker
172            if line.contains(marker) {
173                // Look for code block after marker
174                let mut j = i + 1;
175                while j < lines.len() {
176                    let next_line = lines[j].trim();
177
178                    // Found code block start
179                    if next_line.starts_with("```") {
180                        let mut code = String::new();
181                        j += 1;
182
183                        // Extract code until closing ```
184                        while j < lines.len() {
185                            let code_line = lines[j];
186                            if code_line.trim().starts_with("```") {
187                                break;
188                            }
189                            code.push_str(code_line);
190                            code.push('\n');
191                            j += 1;
192                        }
193
194                        if !code.trim().is_empty() {
195                            examples.push(code.trim().to_string());
196                        }
197                        break;
198                    }
199
200                    // Stop if we hit another marker or section
201                    if next_line.contains("✅") || next_line.contains("❌") || next_line.starts_with("##") {
202                        break;
203                    }
204
205                    j += 1;
206                }
207            }
208
209            i += 1;
210        }
211
212        examples
213    }
214}
215
216/// Extractor for Composio Skills content
217#[derive(Debug, Clone)]
218pub struct SkillsExtractor {
219    skills_path: PathBuf,
220}
221
222impl SkillsExtractor {
223    /// Create a new skills extractor
224    ///
225    /// # Arguments
226    ///
227    /// * `skills_path` - Path to the Skills repository (e.g., "vendor/skills/skills/composio")
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// use composio_sdk::wizard::SkillsExtractor;
233    ///
234    /// let extractor = SkillsExtractor::new("vendor/skills/skills/composio");
235    /// ```
236    pub fn new(skills_path: impl Into<PathBuf>) -> Self {
237        Self {
238            skills_path: skills_path.into(),
239        }
240    }
241
242    /// Verify that the skills path exists
243    pub fn verify_path(&self) -> Result<(), SkillsError> {
244        if !self.skills_path.exists() {
245            return Err(SkillsError::PathNotFound(self.skills_path.clone()));
246        }
247        Ok(())
248    }
249
250    /// Extract Tool Router rules (tr-*.md files)
251    ///
252    /// # Returns
253    ///
254    /// A vector of rules extracted from files matching the pattern `tr-*.md`
255    ///
256    /// # Example
257    ///
258    /// ```no_run
259    /// use composio_sdk::wizard::SkillsExtractor;
260    ///
261    /// let extractor = SkillsExtractor::new("vendor/skills/skills/composio");
262    /// let rules = extractor.get_tool_router_rules().unwrap();
263    /// println!("Found {} Tool Router rules", rules.len());
264    /// ```
265    pub fn get_tool_router_rules(&self) -> Result<Vec<Rule>, SkillsError> {
266        let rules_dir = self.skills_path.join("rules");
267        self.get_rules_by_prefix(&rules_dir, "tr-")
268    }
269
270    /// Extract Trigger rules (triggers-*.md files)
271    ///
272    /// # Returns
273    ///
274    /// A vector of rules extracted from files matching the pattern `triggers-*.md`
275    ///
276    /// # Example
277    ///
278    /// ```no_run
279    /// use composio_sdk::wizard::SkillsExtractor;
280    ///
281    /// let extractor = SkillsExtractor::new("vendor/skills/skills/composio");
282    /// let rules = extractor.get_trigger_rules().unwrap();
283    /// println!("Found {} Trigger rules", rules.len());
284    /// ```
285    pub fn get_trigger_rules(&self) -> Result<Vec<Rule>, SkillsError> {
286        let rules_dir = self.skills_path.join("rules");
287        self.get_rules_by_prefix(&rules_dir, "triggers-")
288    }
289
290    /// Extract rules by filename prefix
291    fn get_rules_by_prefix(&self, rules_dir: &Path, prefix: &str) -> Result<Vec<Rule>, SkillsError> {
292        let mut rules = Vec::new();
293
294        if !rules_dir.exists() {
295            return Ok(rules);
296        }
297
298        for entry in fs::read_dir(rules_dir)? {
299            let entry = entry?;
300            let path = entry.path();
301
302            if path.extension().and_then(|s| s.to_str()) == Some("md") {
303                if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
304                    // Skip template files
305                    if filename.starts_with('_') {
306                        continue;
307                    }
308                    
309                    if filename.starts_with(prefix) {
310                        match Rule::from_file(&path) {
311                            Ok(rule) => rules.push(rule),
312                            Err(e) => {
313                                eprintln!("Warning: Failed to parse rule from {:?}: {}", path, e);
314                            }
315                        }
316                    }
317                }
318            }
319        }
320
321        Ok(rules)
322    }
323
324    /// Extract rules filtered by tag
325    ///
326    /// # Arguments
327    ///
328    /// * `tag` - The tag to filter by (e.g., "sessions", "authentication")
329    ///
330    /// # Returns
331    ///
332    /// A vector of rules that contain the specified tag
333    ///
334    /// # Example
335    ///
336    /// ```no_run
337    /// use composio_sdk::wizard::SkillsExtractor;
338    ///
339    /// let extractor = SkillsExtractor::new("vendor/skills/skills/composio");
340    /// let session_rules = extractor.get_rules_by_tag("sessions").unwrap();
341    /// ```
342    pub fn get_rules_by_tag(&self, tag: &str) -> Result<Vec<Rule>, SkillsError> {
343        let all_rules = self.get_all_rules()?;
344        Ok(all_rules
345            .into_iter()
346            .filter(|r| r.tags.iter().any(|t| t == tag))
347            .collect())
348    }
349
350    /// Get all rules from the rules directory
351    pub fn get_all_rules(&self) -> Result<Vec<Rule>, SkillsError> {
352        let rules_dir = self.skills_path.join("rules");
353        let mut rules = Vec::new();
354
355        if !rules_dir.exists() {
356            return Ok(rules);
357        }
358
359        for entry in fs::read_dir(rules_dir)? {
360            let entry = entry?;
361            let path = entry.path();
362
363            if path.extension().and_then(|s| s.to_str()) == Some("md") {
364                // Skip template files
365                if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
366                    if filename.starts_with('_') {
367                        continue;
368                    }
369                }
370                
371                match Rule::from_file(&path) {
372                    Ok(rule) => rules.push(rule),
373                    Err(e) => {
374                        eprintln!("Warning: Failed to parse rule from {:?}: {}", path, e);
375                    }
376                }
377            }
378        }
379
380        Ok(rules)
381    }
382
383    /// Get consolidated AGENTS.md content
384    ///
385    /// # Returns
386    ///
387    /// The full content of the AGENTS.md file (150+ KB consolidated reference)
388    ///
389    /// # Example
390    ///
391    /// ```no_run
392    /// use composio_sdk::wizard::SkillsExtractor;
393    ///
394    /// let extractor = SkillsExtractor::new("vendor/skills/skills/composio");
395    /// let content = extractor.get_consolidated_content().unwrap();
396    /// println!("AGENTS.md size: {} bytes", content.len());
397    /// ```
398    pub fn get_consolidated_content(&self) -> Result<String, SkillsError> {
399        let agents_path = self.skills_path.join("AGENTS.md");
400        Ok(fs::read_to_string(agents_path)?)
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_impact_from_str() {
410        assert_eq!(Impact::from_str("CRITICAL").unwrap(), Impact::Critical);
411        assert_eq!(Impact::from_str("critical").unwrap(), Impact::Critical);
412        assert_eq!(Impact::from_str("HIGH").unwrap(), Impact::High);
413        assert_eq!(Impact::from_str("MEDIUM").unwrap(), Impact::Medium);
414        assert_eq!(Impact::from_str("LOW").unwrap(), Impact::Low);
415        assert!(Impact::from_str("INVALID").is_err());
416    }
417
418    #[test]
419    fn test_impact_as_str() {
420        assert_eq!(Impact::Critical.as_str(), "CRITICAL");
421        assert_eq!(Impact::High.as_str(), "HIGH");
422        assert_eq!(Impact::Medium.as_str(), "MEDIUM");
423        assert_eq!(Impact::Low.as_str(), "LOW");
424    }
425
426    #[test]
427    fn test_parse_frontmatter() {
428        let content = r#"---
429title: Test Rule
430impact: CRITICAL
431description: A test rule
432tags: [tool-router, sessions]
433---
434
435# Content
436
437This is the body."#;
438
439        let (frontmatter, body) = Rule::parse_markdown(content).unwrap();
440
441        assert_eq!(frontmatter.get("title"), Some(&"Test Rule".to_string()));
442        assert_eq!(frontmatter.get("impact"), Some(&"CRITICAL".to_string()));
443        assert_eq!(frontmatter.get("description"), Some(&"A test rule".to_string()));
444        assert!(body.contains("# Content"));
445        assert!(body.contains("This is the body."));
446    }
447
448    #[test]
449    fn test_extract_examples() {
450        let content = r#"
451## Examples
452
453✅ **Correct:**
454
455```rust
456let session = client.create_session("user_123");
457```
458
459❌ **Incorrect:**
460
461```rust
462let session = client.create_session("default");
463```
464"#;
465
466        let correct = Rule::extract_examples(content, "✅");
467        let incorrect = Rule::extract_examples(content, "❌");
468
469        assert_eq!(correct.len(), 1);
470        assert!(correct[0].contains("user_123"));
471
472        assert_eq!(incorrect.len(), 1);
473        assert!(incorrect[0].contains("default"));
474    }
475
476    #[test]
477    fn test_rule_from_content() {
478        let content = r#"---
479title: Session Management
480impact: CRITICAL
481description: Best practices for session management
482tags: [tool-router, sessions]
483---
484
485# Session Management
486
487✅ **Correct:**
488
489```rust
490let session = client.create_session("user_123");
491```
492
493❌ **Incorrect:**
494
495```rust
496let session = client.create_session("default");
497```
498"#;
499
500        let rule = Rule::from_content(content).unwrap();
501
502        assert_eq!(rule.title, "Session Management");
503        assert_eq!(rule.impact, Impact::Critical);
504        assert_eq!(rule.description, "Best practices for session management");
505        assert_eq!(rule.tags, vec!["tool-router", "sessions"]);
506        assert_eq!(rule.correct_examples.len(), 1);
507        assert_eq!(rule.incorrect_examples.len(), 1);
508    }
509}