1use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Impact {
29 Critical,
30 High,
31 Medium,
32 Low,
33}
34
35impl Impact {
36 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 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#[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 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 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 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 if lines.first().map(|l| l.trim()) == Some("---") {
133 in_frontmatter = true;
134 frontmatter_end = 1;
135
136 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 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 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 if line.contains(marker) {
173 let mut j = i + 1;
175 while j < lines.len() {
176 let next_line = lines[j].trim();
177
178 if next_line.starts_with("```") {
180 let mut code = String::new();
181 j += 1;
182
183 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 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#[derive(Debug, Clone)]
218pub struct SkillsExtractor {
219 skills_path: PathBuf,
220}
221
222impl SkillsExtractor {
223 pub fn new(skills_path: impl Into<PathBuf>) -> Self {
239 Self {
240 skills_path: skills_path.into(),
241 }
242 }
243
244 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 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 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 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 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 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 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 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 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}