Skip to main content

wowlab_docgen_cli/context/collectors/
skills.rs

1use serde::{Deserialize, Serialize};
2
3use super::{ContextEntry, to_value};
4use crate::{HasName, RenderCtx, sort_by_name};
5
6/// A skill entry for template rendering.
7#[derive(Debug, Serialize)]
8// #t(rust_similar_structs) skill template rows and child-directory rows have separate collection and rendering contracts
9pub struct Skill {
10    pub name: String,
11    pub description: String,
12}
13
14impl HasName for Skill {
15    fn name(&self) -> &str {
16        &self.name
17    }
18}
19
20#[derive(Debug, Deserialize)]
21// #t(rust_similar_structs) parsed frontmatter is an input schema distinct from rendered skill and directory rows
22struct SkillFrontmatter {
23    name: String,
24    #[serde(default)]
25    description: String,
26}
27
28/// Collect skills from `.claude/skills/*/SKILL.md` frontmatter at the workspace root.
29#[must_use]
30pub fn collect(ctx: &RenderCtx<'_>) -> Vec<ContextEntry> {
31    if !ctx.is_root() {
32        return Vec::new();
33    }
34
35    let skills_dir = ctx.root.join(".claude/skills");
36    let mut skills: Vec<Skill> = ctx
37        .workspace
38        .child_dirs(&skills_dir)
39        .filter_map(|path| {
40            let skill_file = path.join("SKILL.md");
41            let content = ctx.workspace.contents(&skill_file)?;
42            let fm = parse_frontmatter(content)?;
43
44            Some(Skill {
45                name: fm.name,
46                description: fm.description,
47            })
48        })
49        .collect();
50
51    sort_by_name(&mut skills);
52
53    vec![("skills", to_value(&skills))]
54}
55
56fn parse_frontmatter(content: &str) -> Option<SkillFrontmatter> {
57    let content = content
58        .strip_prefix("---\n")
59        .or_else(|| content.strip_prefix("---\r\n"))?;
60    let end = content.match_indices("\n---").find_map(|(index, _)| {
61        let rest = content.get(index + "\n---".len()..)?;
62
63        (rest.is_empty() || rest.starts_with('\n') || rest.starts_with("\r\n")).then_some(index)
64    })?;
65    // BOUNDS: `find` returns a valid byte offset and the delimiter begins with ASCII newline.
66    let yaml = &content[..end];
67
68    serde_norway::from_str(yaml).ok()
69}
70
71#[cfg(test)]
72mod tests {
73    use googletest::prelude::*;
74
75    use super::*;
76
77    #[gtest]
78    fn parse_frontmatter_full() -> Result<()> {
79        let content = "---\nname: rust-quality\ndescription: Rust code quality.\nallowed-tools: Read\n---\n# Title\n";
80        let fm = parse_frontmatter(content).or_fail()?;
81
82        verify_eq!(fm.name, "rust-quality")?;
83
84        verify_eq!(fm.description, "Rust code quality.")
85    }
86
87    #[gtest]
88    fn parse_frontmatter_missing_description() -> Result<()> {
89        let content = "---\nname: test\n---\n";
90        let fm = parse_frontmatter(content).or_fail()?;
91
92        verify_eq!(fm.name, "test")?;
93
94        verify_eq!(fm.description, "")
95    }
96
97    #[gtest]
98    fn parse_frontmatter_no_frontmatter() -> Result<()> {
99        verify_that!(parse_frontmatter("# No frontmatter"), none())
100    }
101
102    #[gtest]
103    fn parse_frontmatter_allows_dashes_inside_values() -> Result<()> {
104        let content = "---\nname: testing\ndescription: pre---post\n---\n# Title\n";
105        let fm = parse_frontmatter(content).or_fail()?;
106
107        verify_eq!(fm.description, "pre---post")
108    }
109
110    #[gtest]
111    fn parse_frontmatter_requires_an_exact_closing_delimiter() -> Result<()> {
112        let content = "---\nname: testing\ndescription: line one\n----not-a-close\nline two\n---\n";
113
114        verify_that!(parse_frontmatter(content), none())
115    }
116
117    #[gtest]
118    fn parse_frontmatter_accepts_crlf_delimiters() -> Result<()> {
119        let content = "---\r\nname: testing\r\ndescription: works\r\n---\r\n";
120        let fm = parse_frontmatter(content).or_fail()?;
121
122        verify_eq!(fm.name, "testing")
123    }
124}