creamtop 0.1.0

MCP server that manages a GitHub-backed shared skill library for Claude Code.
//! Skill metadata parsing, search, and formatting.

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>>,
}

/// Parse optional YAML frontmatter from `SKILL.md` content. Falls back to
/// deriving metadata from the directory name and the first paragraph.
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);
    };
    // Accept both `\n` and `\r\n` after the opening fence.
    let rest = stripped.trim_start_matches('\r').strip_prefix('\n');
    let Some(rest) = rest else {
        return (None, content);
    };
    // Find the closing `---` on its own line.
    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);
    };

    // Re-walk to compute byte offsets.
    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");
    }
}