use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Local,
Remote,
}
impl Source {
fn as_str(&self) -> &'static str {
match self {
Source::Local => "local",
Source::Remote => "remote",
}
}
}
#[derive(Debug, Clone)]
pub struct SkillMeta {
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub source: Source,
}
#[derive(Debug, Default, Deserialize)]
struct Frontmatter {
#[serde(default)]
description: Option<String>,
#[serde(default)]
tags: Option<Vec<String>>,
}
pub fn parse_skill_meta(name: &str, content: &str, source: Source) -> SkillMeta {
let (fm, body) = split_frontmatter(content);
let description = fm
.as_ref()
.and_then(|f| f.description.clone())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| derive_description(body));
let tags = fm.and_then(|f| f.tags).unwrap_or_default();
SkillMeta {
name: name.to_string(),
description,
tags,
source,
}
}
fn split_frontmatter(content: &str) -> (Option<Frontmatter>, &str) {
let Some(stripped) = content.strip_prefix("---") else {
return (None, content);
};
let rest = stripped.trim_start_matches('\r').strip_prefix('\n');
let Some(rest) = rest else {
return (None, content);
};
let mut end = None;
for (idx, line) in rest.split_inclusive('\n').enumerate() {
let trimmed = line.trim_end_matches(['\r', '\n']);
if trimmed == "---" {
end = Some(idx);
break;
}
}
let Some(end) = end else {
return (None, content);
};
let mut block_end = 0usize;
let mut body_start = 0usize;
for (idx, line) in rest.split_inclusive('\n').enumerate() {
if idx == end {
body_start = block_end + line.len();
break;
}
block_end += line.len();
}
let block = &rest[..block_end];
let body = rest[body_start..].trim_start_matches('\n');
let fm = serde_yaml::from_str::<Frontmatter>(block).ok();
(fm, body)
}
fn derive_description(body: &str) -> String {
for line in body.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
return if trimmed.len() > 200 {
format!("{}...", &trimmed[..200])
} else {
trimmed.to_string()
};
}
String::new()
}
pub fn search_skills<'a>(skills: &'a [SkillMeta], query: &str) -> Vec<&'a SkillMeta> {
let q = query.to_lowercase();
skills
.iter()
.filter(|s| {
s.name.to_lowercase().contains(&q)
|| s.description.to_lowercase().contains(&q)
|| s.tags.iter().any(|t| t.to_lowercase().contains(&q))
})
.collect()
}
pub fn format_skill_list<I, S>(skills: I) -> String
where
I: IntoIterator<Item = S>,
S: std::borrow::Borrow<SkillMeta>,
{
let lines: Vec<String> = skills
.into_iter()
.map(|s| {
let s = s.borrow();
let tags = if s.tags.is_empty() {
String::new()
} else {
format!(" [{}]", s.tags.join(", "))
};
format!(
"- **{}** ({}){}\n {}",
s.name,
s.source.as_str(),
tags,
s.description
)
})
.collect();
if lines.is_empty() {
"No skills found.".to_string()
} else {
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_frontmatter() {
let content = "---\ndescription: Does a thing\ntags: [a, b]\n---\n\n# Title\n\nBody.";
let meta = parse_skill_meta("demo", content, Source::Local);
assert_eq!(meta.description, "Does a thing");
assert_eq!(meta.tags, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn derives_description_without_frontmatter() {
let content = "# Title\n\nFirst paragraph here.";
let meta = parse_skill_meta("demo", content, Source::Local);
assert_eq!(meta.description, "First paragraph here.");
assert!(meta.tags.is_empty());
}
#[test]
fn search_matches_across_fields() {
let skills = vec![
parse_skill_meta("alpha", "# A\nfoo bar", Source::Local),
parse_skill_meta(
"beta",
"---\ndescription: zzz\ntags: [review]\n---\n# B\n",
Source::Local,
),
];
let out = search_skills(&skills, "review");
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "beta");
}
}