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 {
237 Self {
238 skills_path: skills_path.into(),
239 }
240 }
241
242 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 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 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 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 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 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 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 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 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}