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: "frontmatter with utf8 bom",
68 content: "\uFEFF---\n" +
69 "name: bom-skill\n" +
70 "description: Skill with bom.\n" +
71 "---\n\n" +
72 "# BOM Skill\n",
73 wantName: "bom-skill",
74 wantDesc: "Skill with bom.",
75 wantInstr: "# BOM Skill",
76 },
77 {
78 name: "frontmatter with leading blank lines",
79 content: "\n\n---\n" +
80 "name: blank-prefix\n" +
81 "description: Skill with leading blank lines.\n" +
82 "---\n\n" +
83 "# Blank Prefix\n",
84 wantName: "blank-prefix",
85 wantDesc: "Skill with leading blank lines.",
86 wantInstr: "# Blank Prefix",
87 },
88 {
89 name: "frontmatter delimiter with trailing spaces",
90 content: "--- \n" +
91 "name: spaced-delimiter\n" +
92 "description: Delimiter has spaces.\n" +
93 "--- \n\n" +
94 "# Spaced Delimiter\n",
95 wantName: "spaced-delimiter",
96 wantDesc: "Delimiter has spaces.",
97 wantInstr: "# Spaced Delimiter",
98 },
99 {
100 name: "no frontmatter",
101 content: "# Just Markdown\n\nNo frontmatter here.",
102 wantErr: true,
103 },
104 }
105
106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108 t.Parallel()
109
110 // Write content to temp file.
111 dir := t.TempDir()
112 path := filepath.Join(dir, "SKILL.md")
113 require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
114
115 skill, err := Parse(path)
116 if tt.wantErr {
117 require.Error(t, err)
118 return
119 }
120 require.NoError(t, err)
121
122 require.Equal(t, tt.wantName, skill.Name)
123 require.Equal(t, tt.wantDesc, skill.Description)
124 require.Equal(t, tt.wantLicense, skill.License)
125 require.Equal(t, tt.wantCompat, skill.Compatibility)
126
127 if tt.wantMeta != nil {
128 require.Equal(t, tt.wantMeta, skill.Metadata)
129 }
130
131 require.Equal(t, tt.wantInstr, skill.Instructions)
132 })
133 }
134}
135
136func TestSkillValidate(t *testing.T) {
137 t.Parallel()
138
139 tests := []struct {
140 name string
141 skill Skill
142 wantErr bool
143 errMsg string
144 }{
145 {
146 name: "valid skill",
147 skill: Skill{
148 Name: "pdf-processing",
149 Description: "Processes PDF files.",
150 Path: "/skills/pdf-processing",
151 },
152 },
153 {
154 name: "missing name",
155 skill: Skill{Description: "Some description."},
156 wantErr: true,
157 errMsg: "name is required",
158 },
159 {
160 name: "missing description",
161 skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
162 wantErr: true,
163 errMsg: "description is required",
164 },
165 {
166 name: "name too long",
167 skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
168 wantErr: true,
169 errMsg: "exceeds",
170 },
171 {
172 name: "valid name - mixed case",
173 skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
174 wantErr: false,
175 },
176 {
177 name: "invalid name - starts with hyphen",
178 skill: Skill{Name: "-my-skill", Description: "Some description."},
179 wantErr: true,
180 errMsg: "alphanumeric with hyphens",
181 },
182 {
183 name: "name doesn't match directory",
184 skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
185 wantErr: true,
186 errMsg: "must match directory",
187 },
188 {
189 name: "description too long",
190 skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
191 wantErr: true,
192 errMsg: "description exceeds",
193 },
194 {
195 name: "compatibility too long",
196 skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
197 wantErr: true,
198 errMsg: "compatibility exceeds",
199 },
200 }
201
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 t.Parallel()
205
206 err := tt.skill.Validate()
207 if tt.wantErr {
208 require.Error(t, err)
209 require.Contains(t, err.Error(), tt.errMsg)
210 } else {
211 require.NoError(t, err)
212 }
213 })
214 }
215}
216
217func TestDiscover(t *testing.T) {
218 t.Parallel()
219
220 tmpDir := t.TempDir()
221
222 // Create valid skill 1.
223 skill1Dir := filepath.Join(tmpDir, "skill-one")
224 require.NoError(t, os.MkdirAll(skill1Dir, 0o755))
225 require.NoError(t, os.WriteFile(filepath.Join(skill1Dir, "SKILL.md"), []byte(`---
226name: skill-one
227description: First test skill.
228---
229# Skill One
230`), 0o644))
231
232 // Create valid skill 2 in nested directory.
233 skill2Dir := filepath.Join(tmpDir, "nested", "skill-two")
234 require.NoError(t, os.MkdirAll(skill2Dir, 0o755))
235 require.NoError(t, os.WriteFile(filepath.Join(skill2Dir, "SKILL.md"), []byte(`---
236name: skill-two
237description: Second test skill.
238---
239# Skill Two
240`), 0o644))
241
242 // Create invalid skill (won't be included).
243 invalidDir := filepath.Join(tmpDir, "invalid-dir")
244 require.NoError(t, os.MkdirAll(invalidDir, 0o755))
245 require.NoError(t, os.WriteFile(filepath.Join(invalidDir, "SKILL.md"), []byte(`---
246name: wrong-name
247description: Name doesn't match directory.
248---
249`), 0o644))
250
251 skills := Discover([]string{tmpDir})
252 require.Len(t, skills, 2)
253 require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
254
255 names := make(map[string]bool)
256 for _, s := range skills {
257 names[s.Name] = true
258 }
259 require.True(t, names["skill-one"])
260 require.True(t, names["skill-two"])
261}
262
263func TestToPromptXML(t *testing.T) {
264 t.Parallel()
265
266 skills := []*Skill{
267 {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
268 {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
269 }
270
271 xml := ToPromptXML(skills)
272
273 require.Contains(t, xml, "<available_skills>")
274 require.Contains(t, xml, "<name>pdf-processing</name>")
275 require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
276 require.Contains(t, xml, "&") // XML escaping
277}
278
279func TestToPromptXMLEmpty(t *testing.T) {
280 t.Parallel()
281 require.Empty(t, ToPromptXML(nil))
282 require.Empty(t, ToPromptXML([]*Skill{}))
283}
284
285func TestEscape(t *testing.T) {
286 t.Parallel()
287
288 tests := []struct {
289 name string
290 in string
291 want string
292 }{
293 {
294 name: "escape xml special chars",
295 in: `<tag attr="x&y">'z'</tag>`,
296 want: `<tag attr="x&y">'z'</tag>`,
297 },
298 {
299 name: "plain text unchanged",
300 in: "hello world",
301 want: "hello world",
302 },
303 }
304
305 for _, tt := range tests {
306 t.Run(tt.name, func(t *testing.T) {
307 t.Parallel()
308 require.Equal(t, tt.want, escape(tt.in))
309 })
310 }
311}
312
313func TestToPromptXMLBuiltinType(t *testing.T) {
314 t.Parallel()
315
316 skills := []*Skill{
317 {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
318 {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
319 }
320 xml := ToPromptXML(skills)
321 require.Contains(t, xml, "<type>builtin</type>")
322 require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
323}
324
325func TestParseContent(t *testing.T) {
326 t.Parallel()
327
328 content := []byte(`---
329name: my-skill
330description: A test skill.
331---
332
333# My Skill
334
335Instructions here.
336`)
337 skill, err := ParseContent(content)
338 require.NoError(t, err)
339 require.Equal(t, "my-skill", skill.Name)
340 require.Equal(t, "A test skill.", skill.Description)
341 require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
342 require.Empty(t, skill.Path)
343 require.Empty(t, skill.SkillFilePath)
344}
345
346func TestParseContent_NoFrontmatter(t *testing.T) {
347 t.Parallel()
348
349 _, err := ParseContent([]byte("# Just Markdown"))
350 require.Error(t, err)
351}
352
353func TestDiscoverBuiltin(t *testing.T) {
354 t.Parallel()
355
356 discovered := DiscoverBuiltin()
357 require.NotEmpty(t, discovered)
358
359 var found bool
360 for _, s := range discovered {
361 if s.Name == "crush-config" {
362 found = true
363 require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
364 require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
365 require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
366 require.Equal(t, "crush://skills/crush-config", s.Path)
367 require.NotEmpty(t, s.Description)
368 require.NotEmpty(t, s.Instructions)
369 require.True(t, s.Builtin)
370 }
371 }
372 require.True(t, found, "crush-config builtin skill not found")
373}
374
375func TestDeduplicate(t *testing.T) {
376 t.Parallel()
377
378 tests := []struct {
379 name string
380 input []*Skill
381 wantLen int
382 wantName string
383 wantPath string
384 }{
385 {
386 name: "no duplicates",
387 input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
388 wantLen: 2,
389 },
390 {
391 name: "user overrides builtin",
392 input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
393 wantLen: 1,
394 wantName: "crush-config",
395 wantPath: "/user/crush-config",
396 },
397 {
398 name: "empty",
399 input: nil,
400 wantLen: 0,
401 },
402 }
403
404 for _, tt := range tests {
405 t.Run(tt.name, func(t *testing.T) {
406 t.Parallel()
407 result := Deduplicate(tt.input)
408 require.Len(t, result, tt.wantLen)
409 if tt.wantName != "" {
410 require.Equal(t, tt.wantName, result[0].Name)
411 require.Equal(t, tt.wantPath, result[0].Path)
412 }
413 })
414 }
415}
416
417func TestFilter(t *testing.T) {
418 t.Parallel()
419
420 all := []*Skill{
421 {Name: "a"},
422 {Name: "b"},
423 {Name: "c"},
424 }
425
426 tests := []struct {
427 name string
428 disabled []string
429 wantLen int
430 }{
431 {"no filter", nil, 3},
432 {"filter one", []string{"b"}, 2},
433 {"filter all", []string{"a", "b", "c"}, 0},
434 {"filter nonexistent", []string{"d"}, 3},
435 }
436
437 for _, tt := range tests {
438 t.Run(tt.name, func(t *testing.T) {
439 t.Parallel()
440 result := Filter(all, tt.disabled)
441 require.Len(t, result, tt.wantLen)
442 })
443 }
444}