Skip to main content

fastskill_core/core/
metadata.rs

1//! Metadata and discovery service implementation
2
3use crate::core::service::{ServiceError, SkillId};
4use crate::core::skill_manager::{SkillDefinition, SkillManagementService};
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SkillMetadata {
11    pub id: SkillId,
12    pub name: String,
13    pub description: String,
14    pub version: String,
15    pub author: Option<String>,
16    pub enabled: bool,
17    pub token_estimate: usize,
18    pub last_updated: chrono::DateTime<chrono::Utc>,
19}
20
21impl From<&SkillDefinition> for SkillMetadata {
22    fn from(skill: &SkillDefinition) -> Self {
23        Self {
24            id: skill.id.clone(),
25            name: skill.name.clone(),
26            description: skill.description.clone(),
27            version: skill.version.clone(),
28            author: skill.author.clone(),
29            enabled: skill.enabled,
30            token_estimate: skill.description.len() / 4, // Rough estimate
31            last_updated: skill.updated_at,
32        }
33    }
34}
35
36/// Structured YAML frontmatter extracted from SKILL.md files
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SkillFrontmatter {
39    pub name: String,
40    pub description: String,
41    #[serde(default)]
42    pub version: Option<String>,
43    pub author: Option<String>,
44    pub license: Option<String>,
45    pub compatibility: Option<String>,
46    pub metadata: Option<std::collections::HashMap<String, String>>,
47    pub allowed_tools: Option<String>,
48    #[serde(flatten)]
49    pub extra: std::collections::HashMap<String, serde_yaml::Value>,
50}
51
52#[async_trait]
53pub trait MetadataService: Send + Sync {
54    async fn discover_skills(&self, query: &str) -> Result<Vec<SkillMetadata>, ServiceError>;
55    async fn search_skills(&self, query: &str) -> Result<Vec<SkillMetadata>, ServiceError>;
56    async fn get_skill_frontmatter(&self, skill_id: &str)
57        -> Result<SkillFrontmatter, ServiceError>;
58}
59
60pub struct MetadataServiceImpl {
61    skill_manager: Arc<dyn SkillManagementService>,
62}
63
64impl MetadataServiceImpl {
65    pub fn new(skill_manager: Arc<dyn SkillManagementService>) -> Self {
66        Self { skill_manager }
67    }
68
69    /// Simple text search implementation
70    fn matches_query(&self, skill: &SkillDefinition, query: &str) -> bool {
71        let query_lower = query.to_lowercase();
72
73        // Check if query is contained in name or description (exact match)
74        if skill.name.to_lowercase().contains(&query_lower)
75            || skill.description.to_lowercase().contains(&query_lower)
76        {
77            return true;
78        }
79
80        // Tokenize query and check for word matches
81        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
82        for word in query_words {
83            if word.len() > 2 {
84                // Only match words longer than 2 characters
85                if skill.name.to_lowercase().contains(word)
86                    || skill.description.to_lowercase().contains(word)
87                {
88                    return true;
89                }
90            }
91        }
92
93        false
94    }
95
96    /// Score skills based on relevance to query
97    fn score_skill(&self, skill: &SkillDefinition, query: &str) -> f32 {
98        let query_lower = query.to_lowercase();
99        let mut score = 0.0;
100
101        // Exact name match gets highest score
102        if skill.name.to_lowercase() == query_lower {
103            score += 1.0;
104        } else if skill.name.to_lowercase().contains(&query_lower) {
105            score += 0.8;
106        }
107
108        // Description match
109        if skill.description.to_lowercase().contains(&query_lower) {
110            score += 0.6;
111        }
112
113        score
114    }
115
116    /// Parse YAML frontmatter from SKILL.md content
117    fn parse_frontmatter(&self, content: &str) -> Result<SkillFrontmatter, ServiceError> {
118        parse_yaml_frontmatter(content)
119    }
120}
121
122/// Parse YAML frontmatter from SKILL.md content (standalone function)
123/// This can be used by CLI and other modules that need to parse skill frontmatter
124pub fn parse_yaml_frontmatter(content: &str) -> Result<SkillFrontmatter, ServiceError> {
125    let lines: Vec<&str> = content.lines().collect();
126    let mut frontmatter_lines = Vec::new();
127    let mut in_frontmatter = false;
128    let _frontmatter_end;
129
130    // Find frontmatter boundaries (between --- delimiters)
131    for (i, line) in lines.iter().enumerate() {
132        if line.trim() == "---" {
133            if !in_frontmatter {
134                in_frontmatter = true;
135            } else {
136                _frontmatter_end = Some(i);
137                break;
138            }
139        } else if in_frontmatter {
140            frontmatter_lines.push(*line);
141        }
142    }
143
144    // If no frontmatter found, create from existing metadata or return error
145    if frontmatter_lines.is_empty() {
146        // Try to parse as YAML without delimiters (some skills might not use ---)
147        // For now, return an error if no frontmatter delimiters found
148        return Err(ServiceError::Custom(
149            "No YAML frontmatter found in SKILL.md".to_string(),
150        ));
151    }
152
153    // Parse YAML frontmatter
154    let frontmatter_str = frontmatter_lines.join("\n");
155    let mut frontmatter: std::collections::HashMap<String, serde_yaml::Value> =
156        serde_yaml::from_str(&frontmatter_str).map_err(|e| {
157            ServiceError::Custom(format!("Failed to parse YAML frontmatter: {}", e))
158        })?;
159
160    // Extract known fields
161    let name = frontmatter
162        .remove("name")
163        .and_then(|v| serde_yaml::from_value(v).ok())
164        .unwrap_or_else(|| "Unknown".to_string());
165
166    let description = frontmatter
167        .remove("description")
168        .and_then(|v| serde_yaml::from_value(v).ok())
169        .unwrap_or_else(|| "No description".to_string());
170
171    // Parse metadata first to use as fallback for version/author
172    let metadata_value = frontmatter.remove("metadata");
173    let metadata: Option<std::collections::HashMap<String, serde_yaml::Value>> = metadata_value
174        .as_ref()
175        .and_then(|v| serde_yaml::from_value(v.clone()).ok());
176
177    // Get top-level version/author
178    let top_level_version = frontmatter
179        .remove("version")
180        .and_then(|v| serde_yaml::from_value(v).ok());
181
182    let top_level_author = frontmatter
183        .remove("author")
184        .and_then(|v| serde_yaml::from_value(v).ok());
185
186    // Use metadata as fallback for version/author
187    let version = top_level_version.or_else(|| {
188        metadata
189            .as_ref()
190            .and_then(|m| m.get("version").and_then(|v| v.as_str().map(String::from)))
191    });
192
193    let author = top_level_author.or_else(|| {
194        metadata
195            .as_ref()
196            .and_then(|m| m.get("author").and_then(|v| v.as_str().map(String::from)))
197    });
198
199    Ok(SkillFrontmatter {
200        name,
201        description,
202        version,
203        author,
204        license: frontmatter
205            .remove("license")
206            .and_then(|v| serde_yaml::from_value(v).ok()),
207        compatibility: frontmatter
208            .remove("compatibility")
209            .and_then(|v| serde_yaml::from_value(v).ok()),
210        metadata: metadata.and_then(|m| {
211            // Convert HashMap<String, Value> to HashMap<String, String> for SkillFrontmatter
212            let string_map: std::collections::HashMap<String, String> = m
213                .into_iter()
214                .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
215                .collect();
216            if string_map.is_empty() {
217                None
218            } else {
219                Some(string_map)
220            }
221        }),
222        allowed_tools: frontmatter
223            .remove("allowed_tools")
224            .and_then(|v| serde_yaml::from_value(v).ok()),
225        extra: frontmatter,
226    })
227}
228
229#[async_trait]
230impl MetadataService for MetadataServiceImpl {
231    async fn discover_skills(&self, query: &str) -> Result<Vec<SkillMetadata>, ServiceError> {
232        let all_skills = self.skill_manager.list_skills(None).await?;
233
234        // Filter and score skills based on query relevance
235        let mut scored_skills: Vec<(f32, &SkillDefinition)> = all_skills
236            .iter()
237            .filter(|skill| self.matches_query(skill, query))
238            .map(|skill| (self.score_skill(skill, query), skill))
239            .collect();
240
241        // Sort by score (highest first)
242        // Handle potential NaN values gracefully
243        scored_skills.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
244
245        // Convert to metadata and return top matches
246        let results: Vec<SkillMetadata> = scored_skills
247            .into_iter()
248            .take(10) // Limit to top 10 results
249            .map(|(_, skill)| SkillMetadata::from(skill))
250            .collect();
251
252        Ok(results)
253    }
254
255    async fn search_skills(&self, query: &str) -> Result<Vec<SkillMetadata>, ServiceError> {
256        // For now, use the same logic as discover_skills
257        self.discover_skills(query).await
258    }
259
260    async fn get_skill_frontmatter(
261        &self,
262        skill_id: &str,
263    ) -> Result<SkillFrontmatter, ServiceError> {
264        // Get skill definition to find the SKILL.md file path
265        let skill_id_parsed = crate::core::service::SkillId::new(skill_id.to_string())?;
266        let skill = self
267            .skill_manager
268            .get_skill(&skill_id_parsed)
269            .await?
270            .ok_or_else(|| ServiceError::Custom(format!("Skill not found: {}", skill_id)))?;
271
272        let skill_file = &skill.skill_file;
273
274        // Read SKILL.md file
275        if !skill_file.exists() {
276            return Err(ServiceError::Custom(format!(
277                "Skill file not found: {}",
278                skill_file.display()
279            )));
280        }
281
282        let content = tokio::fs::read_to_string(skill_file).await?;
283
284        // Extract and parse YAML frontmatter
285        self.parse_frontmatter(&content)
286    }
287}