1package skills
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8
9 "github.com/stretchr/testify/require"
10)
11
12func TestParse(t *testing.T) {
13 t.Parallel()
14
15 tests := []struct {
16 name string
17 content string
18 wantName string
19 wantDesc string
20 wantLicense string
21 wantCompat string
22 wantMeta map[string]string
23 wantTools string
24 wantInstr string
25 wantErr bool
26 }{
27 {
28 name: "full skill",
29 content: `---
30name: pdf-processing
31description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
32license: Apache-2.0
33compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
34metadata:
35 author: example-org
36 version: "1.0"
37---
38
39# PDF Processing
40
41## When to use this skill
42Use this skill when the user needs to work with PDF files.
43`,
44 wantName: "pdf-processing",
45 wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
46 wantLicense: "Apache-2.0",
47 wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
48 wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
49 wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
50 },
51 {
52 name: "minimal skill",
53 content: `---
54name: my-skill
55description: A simple skill for testing.
56---
57
58# My Skill
59
60Instructions here.
61`,
62 wantName: "my-skill",
63 wantDesc: "A simple skill for testing.",
64 wantInstr: "# My Skill\n\nInstructions here.",
65 },
66 {
67 name: "no frontmatter",
68 content: "# Just Markdown\n\nNo frontmatter here.",
69 wantErr: true,
70 },
71 }
72
73 for _, tt := range tests {
74 t.Run(tt.name, func(t *testing.T) {
75 t.Parallel()
76
77 // Write content to temp file.
78 dir := t.TempDir()
79 path := filepath.Join(dir, "SKILL.md")
80 require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
81
82 skill, err := Parse(path)
83 if tt.wantErr {
84 require.Error(t, err)
85 return
86 }
87 require.NoError(t, err)
88
89 require.Equal(t, tt.wantName, skill.Name)
90 require.Equal(t, tt.wantDesc, skill.Description)
91 require.Equal(t, tt.wantLicense, skill.License)
92 require.Equal(t, tt.wantCompat, skill.Compatibility)
93
94 if tt.wantMeta != nil {
95 require.Equal(t, tt.wantMeta, skill.Metadata)
96 }
97
98 require.Equal(t, tt.wantInstr, skill.Instructions)
99 })
100 }
101}
102
103func TestSkillValidate(t *testing.T) {
104 t.Parallel()
105
106 tests := []struct {
107 name string
108 skill Skill
109 wantErr bool
110 errMsg string
111 }{
112 {
113 name: "valid skill",
114 skill: Skill{
115 Name: "pdf-processing",
116 Description: "Processes PDF files.",
117 Path: "/skills/pdf-processing",
118 },
119 },
120 {
121 name: "missing name",
122 skill: Skill{Description: "Some description."},
123 wantErr: true,
124 errMsg: "name is required",
125 },
126 {
127 name: "missing description",
128 skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
129 wantErr: true,
130 errMsg: "description is required",
131 },
132 {
133 name: "name too long",
134 skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
135 wantErr: true,
136 errMsg: "exceeds",
137 },
138 {
139 name: "valid name - mixed case",
140 skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
141 wantErr: false,
142 },
143 {
144 name: "invalid name - starts with hyphen",
145 skill: Skill{Name: "-my-skill", Description: "Some description."},
146 wantErr: true,
147 errMsg: "alphanumeric with hyphens",
148 },
149 {
150 name: "name doesn't match directory",
151 skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
152 wantErr: true,
153 errMsg: "must match directory",
154 },
155 {
156 name: "description too long",
157 skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
158 wantErr: true,
159 errMsg: "description exceeds",
160 },
161 {
162 name: "compatibility too long",
163 skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
164 wantErr: true,
165 errMsg: "compatibility exceeds",
166 },
167 }
168
169 for _, tt := range tests {
170 t.Run(tt.name, func(t *testing.T) {
171 t.Parallel()
172
173 err := tt.skill.Validate()
174 if tt.wantErr {
175 require.Error(t, err)
176 require.Contains(t, err.Error(), tt.errMsg)
177 } else {
178 require.NoError(t, err)
179 }
180 })
181 }
182}
183
184func TestDiscover(t *testing.T) {
185 t.Parallel()
186
187 tmpDir := t.TempDir()
188
189 // Create valid skill 1.
190 skill1Dir := filepath.Join(tmpDir, "skill-one")
191 require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
192 require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
193name: skill-one
194description: First test skill.
195---
196# Skill One
197`), 0o644))
198
199 // Create valid skill 2 in nested directory.
200 skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
201 require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
202 require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
203name: skill-two
204description: Second test skill.
205---
206# Skill Two
207`), 0o644))
208
209 // Create invalid skill (won't be included).
210 invalidDir := filepath.Join(tmpDir, "invalid-dir")
211 require.NoError(t, os.MkdirAll(invalidDir, 0o755))
212 require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
213name: wrong-name
214description: Name doesn't match directory.
215---
216`), 0o644))
217
218 skills := Discover([]string{tmpDir})
219 require.Len(t, skills, 2)
220
221 names := make(map[string]bool)
222 for _, s := range skills {
223 names[s.Name] = true
224 }
225 require.True(t, names["skill-one"])
226 require.True(t, names["skill-two"])
227}
228
229func TestToPromptXML(t *testing.T) {
230 t.Parallel()
231
232 skills := []*Skill{
233 {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
234 {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
235 }
236
237 xml := ToPromptXML(skills)
238
239 require.Contains(t, xml, "<available_skills>")
240 require.Contains(t, xml, "<name>pdf-processing</name>")
241 require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
242 require.Contains(t, xml, "&") // XML escaping
243}
244
245func TestToPromptXMLEmpty(t *testing.T) {
246 t.Parallel()
247 require.Empty(t, ToPromptXML(nil))
248 require.Empty(t, ToPromptXML([]*Skill{}))
249}
250
251func TestToPromptXMLBuiltinType(t *testing.T) {
252 t.Parallel()
253
254 skills := []*Skill{
255 {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
256 {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
257 }
258 xml := ToPromptXML(skills)
259 require.Contains(t, xml, "<type>builtin</type>")
260 require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
261}
262
263func TestParseContent(t *testing.T) {
264 t.Parallel()
265
266 content := []byte(`---
267name: my-skill
268description: A test skill.
269---
270
271# My Skill
272
273Instructions here.
274`)
275 skill, err := ParseContent(content)
276 require.NoError(t, err)
277 require.Equal(t, "my-skill", skill.Name)
278 require.Equal(t, "A test skill.", skill.Description)
279 require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
280 require.Empty(t, skill.Path)
281 require.Empty(t, skill.SkillFilePath)
282}
283
284func TestParseContent_NoFrontmatter(t *testing.T) {
285 t.Parallel()
286
287 _, err := ParseContent([]byte("# Just Markdown"))
288 require.Error(t, err)
289}
290
291func TestDiscoverBuiltin(t *testing.T) {
292 t.Parallel()
293
294 discovered := DiscoverBuiltin()
295 require.NotEmpty(t, discovered)
296
297 var found bool
298 for _, s := range discovered {
299 if s.Name == "crush-config" {
300 found = true
301 require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
302 require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
303 require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
304 require.Equal(t, "crush://skills/crush-config", s.Path)
305 require.NotEmpty(t, s.Description)
306 require.NotEmpty(t, s.Instructions)
307 require.True(t, s.Builtin)
308 }
309 }
310 require.True(t, found, "crush-config builtin skill not found")
311}
312
313func TestDeduplicate(t *testing.T) {
314 t.Parallel()
315
316 tests := []struct {
317 name string
318 input []*Skill
319 wantLen int
320 wantName string
321 wantPath string
322 }{
323 {
324 name: "no duplicates",
325 input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
326 wantLen: 2,
327 },
328 {
329 name: "user overrides builtin",
330 input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
331 wantLen: 1,
332 wantName: "crush-config",
333 wantPath: "/user/crush-config",
334 },
335 {
336 name: "empty",
337 input: nil,
338 wantLen: 0,
339 },
340 }
341
342 for _, tt := range tests {
343 t.Run(tt.name, func(t *testing.T) {
344 t.Parallel()
345 result := Deduplicate(tt.input)
346 require.Len(t, result, tt.wantLen)
347 if tt.wantName != "" {
348 require.Equal(t, tt.wantName, result[0].Name)
349 require.Equal(t, tt.wantPath, result[0].Path)
350 }
351 })
352 }
353}
354
355func TestFilter(t *testing.T) {
356 t.Parallel()
357
358 all := []*Skill{
359 {Name: "a"},
360 {Name: "b"},
361 {Name: "c"},
362 }
363
364 tests := []struct {
365 name string
366 disabled []string
367 wantLen int
368 }{
369 {"no filter", nil, 3},
370 {"filter one", []string{"b"}, 2},
371 {"filter all", []string{"a", "b", "c"}, 0},
372 {"filter nonexistent", []string{"d"}, 3},
373 }
374
375 for _, tt := range tests {
376 t.Run(tt.name, func(t *testing.T) {
377 t.Parallel()
378 result := Filter(all, tt.disabled)
379 require.Len(t, result, tt.wantLen)
380 })
381 }
382}