wowlab_docgen_cli/context/collectors/
skills.rs1use serde::{Deserialize, Serialize};
2
3use super::{ContextEntry, to_value};
4use crate::{HasName, RenderCtx, sort_by_name};
5
6#[derive(Debug, Serialize)]
8pub 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)]
21struct SkillFrontmatter {
23 name: String,
24 #[serde(default)]
25 description: String,
26}
27
28#[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 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}