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 bundled Skills directory (e.g., "composio-sdk/skills")
228    ///
229    /// # Example
230    ///
231    /// ```no_run
232    /// use composio_sdk::wizard::SkillsExtractor;
233    ///
234    /// // Skills are bundled within the SDK
235    /// let skills_path = concat!(env!("CARGO_MANIFEST_DIR"), "/skills");
236    /// let extractor = SkillsExtractor::new(skills_path);
237    /// ```
238    pub fn new(skills_path: impl Into<PathBuf>) -> Self {
239        Self {
240            skills_path: skills_path.into(),
241        }
242    }
243
244    /// Verify that the skills path exists
245    pub fn verify_path(&self) -> Result<(), SkillsError> {
246        if !self.skills_path.exists() {
247            return Err(SkillsError::PathNotFound(self.skills_path.clone()));
248        }
249        Ok(())
250    }
251
252    /// Extract Tool Router rules (tr-*.md files)
253    ///
254    /// # Returns
255    ///
256    /// A vector of rules extracted from files matching the pattern `tr-*.md`
257    ///
258    /// # Example
259    ///
260    /// ```no_run
261    /// use composio_sdk::wizard::SkillsExtractor;
262    ///
263    /// let skills_path = concat!(env!("CARGO_MANIFEST_DIR"), "/skills");
264    /// let extractor = SkillsExtractor::new(skills_path);
265    /// let rules = extractor.get_tool_router_rules().unwrap();
266    /// println!("Found {} Tool Router rules", rules.len());
267    /// ```
268    pub fn get_tool_router_rules(&self) -> Result<Vec<Rule>, SkillsError> {
269        let rules_dir = self.skills_path.join("rules");
270        self.get_rules_by_prefix(&rules_dir, "tr-")
271    }
272
273    /// Extract Trigger rules (triggers-*.md files)
274    ///
275    /// # Returns
276    ///
277    /// A vector of rules extracted from files matching the pattern `triggers-*.md`
278    ///
279    /// # Example
280    ///
281    /// ```no_run
282    /// use composio_sdk::wizard::SkillsExtractor;
283    ///
284    /// let skills_path = concat!(env!("CARGO_MANIFEST_DIR"), "/skills");
285    /// let extractor = SkillsExtractor::new(skills_path);
286    /// let rules = extractor.get_trigger_rules().unwrap();
287    /// println!("Found {} Trigger rules", rules.len());
288    /// ```
289    pub fn get_trigger_rules(&self) -> Result<Vec<Rule>, SkillsError> {
290        let rules_dir = self.skills_path.join("rules");
291        self.get_rules_by_prefix(&rules_dir, "triggers-")
292    }
293
294    /// Extract rules by filename prefix
295    fn get_rules_by_prefix(&self, rules_dir: &Path, prefix: &str) -> Result<Vec<Rule>, SkillsError> {
296        let mut rules = Vec::new();
297
298        if !rules_dir.exists() {
299            return Ok(rules);
300        }
301
302        for entry in fs::read_dir(rules_dir)? {
303            let entry = entry?;
304            let path = entry.path();
305
306            if path.extension().and_then(|s| s.to_str()) == Some("md") {
307                if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
308                    // Skip template files
309                    if filename.starts_with('_') {
310                        continue;
311                    }
312                    
313                    if filename.starts_with(prefix) {
314                        match Rule::from_file(&path) {
315                            Ok(rule) => rules.push(rule),
316                            Err(e) => {
317                                eprintln!("Warning: Failed to parse rule from {:?}: {}", path, e);
318                            }
319                        }
320                    }
321                }
322            }
323        }
324
325        Ok(rules)
326    }
327
328    /// Extract rules filtered by tag
329    ///
330    /// # Arguments
331    ///
332    /// * `tag` - The tag to filter by (e.g., "sessions", "authentication")
333    ///
334    /// # Returns
335    ///
336    /// A vector of rules that contain the specified tag
337    ///
338    /// # Example
339    ///
340    /// ```no_run
341    /// use composio_sdk::wizard::SkillsExtractor;
342    ///
343    /// let skills_path = concat!(env!("CARGO_MANIFEST_DIR"), "/skills");
344    /// let extractor = SkillsExtractor::new(skills_path);
345    /// let session_rules = extractor.get_rules_by_tag("sessions").unwrap();
346    /// ```
347    pub fn get_rules_by_tag(&self, tag: &str) -> Result<Vec<Rule>, SkillsError> {
348        let all_rules = self.get_all_rules()?;
349        Ok(all_rules
350            .into_iter()
351            .filter(|r| r.tags.iter().any(|t| t == tag))
352            .collect())
353    }
354
355    /// Get all rules from the rules directory
356    pub fn get_all_rules(&self) -> Result<Vec<Rule>, SkillsError> {
357        let rules_dir = self.skills_path.join("rules");
358        let mut rules = Vec::new();
359
360        if !rules_dir.exists() {
361            return Ok(rules);
362        }
363
364        for entry in fs::read_dir(rules_dir)? {
365            let entry = entry?;
366            let path = entry.path();
367
368            if path.extension().and_then(|s| s.to_str()) == Some("md") {
369                // Skip template files
370                if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
371                    if filename.starts_with('_') {
372                        continue;
373                    }
374                }
375                
376                match Rule::from_file(&path) {
377                    Ok(rule) => rules.push(rule),
378                    Err(e) => {
379                        eprintln!("Warning: Failed to parse rule from {:?}: {}", path, e);
380                    }
381                }
382            }
383        }
384
385        Ok(rules)
386    }
387
388    /// Get consolidated AGENTS.md content
389    ///
390    /// # Returns
391    ///
392    /// The full content of the AGENTS.md file (150+ KB consolidated reference)
393    ///
394    /// # Example
395    ///
396    /// ```no_run
397    /// use composio_sdk::wizard::SkillsExtractor;
398    ///
399    /// let skills_path = concat!(env!("CARGO_MANIFEST_DIR"), "/skills");
400    /// let extractor = SkillsExtractor::new(skills_path);
401    /// let content = extractor.get_consolidated_content().unwrap();
402    /// println!("AGENTS.md size: {} bytes", content.len());
403    /// ```
404    pub fn get_consolidated_content(&self) -> Result<String, SkillsError> {
405        let agents_path = self.skills_path.join("AGENTS.md");
406        Ok(fs::read_to_string(agents_path)?)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_impact_from_str() {
416        assert_eq!(Impact::from_str("CRITICAL").unwrap(), Impact::Critical);
417        assert_eq!(Impact::from_str("critical").unwrap(), Impact::Critical);
418        assert_eq!(Impact::from_str("HIGH").unwrap(), Impact::High);
419        assert_eq!(Impact::from_str("MEDIUM").unwrap(), Impact::Medium);
420        assert_eq!(Impact::from_str("LOW").unwrap(), Impact::Low);
421        assert!(Impact::from_str("INVALID").is_err());
422    }
423
424    #[test]
425    fn test_impact_as_str() {
426        assert_eq!(Impact::Critical.as_str(), "CRITICAL");
427        assert_eq!(Impact::High.as_str(), "HIGH");
428        assert_eq!(Impact::Medium.as_str(), "MEDIUM");
429        assert_eq!(Impact::Low.as_str(), "LOW");
430    }
431
432    #[test]
433    fn test_parse_frontmatter() {
434        let content = r#"---
435title: Test Rule
436impact: CRITICAL
437description: A test rule
438tags: [tool-router, sessions]
439---
440
441# Content
442
443This is the body."#;
444
445        let (frontmatter, body) = Rule::parse_markdown(content).unwrap();
446
447        assert_eq!(frontmatter.get("title"), Some(&"Test Rule".to_string()));
448        assert_eq!(frontmatter.get("impact"), Some(&"CRITICAL".to_string()));
449        assert_eq!(frontmatter.get("description"), Some(&"A test rule".to_string()));
450        assert!(body.contains("# Content"));
451        assert!(body.contains("This is the body."));
452    }
453
454    #[test]
455    fn test_extract_examples() {
456        let content = r#"
457## Examples
458
459✅ **Correct:**
460
461```rust
462let session = client.create_session("user_123");
463```
464
465❌ **Incorrect:**
466
467```rust
468let session = client.create_session("default");
469```
470"#;
471
472        let correct = Rule::extract_examples(content, "✅");
473        let incorrect = Rule::extract_examples(content, "❌");
474
475        assert_eq!(correct.len(), 1);
476        assert!(correct[0].contains("user_123"));
477
478        assert_eq!(incorrect.len(), 1);
479        assert!(incorrect[0].contains("default"));
480    }
481
482    #[test]
483    fn test_rule_from_content() {
484        let content = r#"---
485title: Session Management
486impact: CRITICAL
487description: Best practices for session management
488tags: [tool-router, sessions]
489---
490
491# Session Management
492
493✅ **Correct:**
494
495```rust
496let session = client.create_session("user_123");
497```
498
499❌ **Incorrect:**
500
501```rust
502let session = client.create_session("default");
503```
504"#;
505
506        let rule = Rule::from_content(content).unwrap();
507
508        assert_eq!(rule.title, "Session Management");
509        assert_eq!(rule.impact, Impact::Critical);
510        assert_eq!(rule.description, "Best practices for session management");
511        assert_eq!(rule.tags, vec!["tool-router", "sessions"]);
512        assert_eq!(rule.correct_examples.len(), 1);
513        assert_eq!(rule.incorrect_examples.len(), 1);
514    }
515}