1package skills
2
3import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/require"
11)
12
13func TestParse(t *testing.T) {
14 t.Parallel()
15
16 tests := []struct {
17 name string
18 content string
19 wantName string
20 wantDesc string
21 wantLicense string
22 wantCompat string
23 wantMeta map[string]string
24 wantTools string
25 wantInstr string
26 wantErr bool
27 }{
28 {
29 name: "full skill",
30 content: `---
31name: pdf-processing
32description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.
33license: Apache-2.0
34compatibility: Requires python 3.8+, pdfplumber, pdfrw libraries
35metadata:
36 author: example-org
37 version: "1.0"
38---
39
40# PDF Processing
41
42## When to use this skill
43Use this skill when the user needs to work with PDF files.
44`,
45 wantName: "pdf-processing",
46 wantDesc: "Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs.",
47 wantLicense: "Apache-2.0",
48 wantCompat: "Requires python 3.8+, pdfplumber, pdfrw libraries",
49 wantMeta: map[string]string{"author": "example-org", "version": "1.0"},
50 wantInstr: "# PDF Processing\n\n## When to use this skill\nUse this skill when the user needs to work with PDF files.",
51 },
52 {
53 name: "minimal skill",
54 content: `---
55name: my-skill
56description: A simple skill for testing.
57---
58
59# My Skill
60
61Instructions here.
62`,
63 wantName: "my-skill",
64 wantDesc: "A simple skill for testing.",
65 wantInstr: "# My Skill\n\nInstructions here.",
66 },
67 {
68 name: "frontmatter with utf8 bom",
69 content: "\uFEFF---\n" +
70 "name: bom-skill\n" +
71 "description: Skill with bom.\n" +
72 "---\n\n" +
73 "# BOM Skill\n",
74 wantName: "bom-skill",
75 wantDesc: "Skill with bom.",
76 wantInstr: "# BOM Skill",
77 },
78 {
79 name: "frontmatter with leading blank lines",
80 content: "\n\n---\n" +
81 "name: blank-prefix\n" +
82 "description: Skill with leading blank lines.\n" +
83 "---\n\n" +
84 "# Blank Prefix\n",
85 wantName: "blank-prefix",
86 wantDesc: "Skill with leading blank lines.",
87 wantInstr: "# Blank Prefix",
88 },
89 {
90 name: "frontmatter delimiter with trailing spaces",
91 content: "--- \n" +
92 "name: spaced-delimiter\n" +
93 "description: Delimiter has spaces.\n" +
94 "--- \n\n" +
95 "# Spaced Delimiter\n",
96 wantName: "spaced-delimiter",
97 wantDesc: "Delimiter has spaces.",
98 wantInstr: "# Spaced Delimiter",
99 },
100 {
101 name: "no frontmatter",
102 content: "# Just Markdown\n\nNo frontmatter here.",
103 wantErr: true,
104 },
105 }
106
107 for _, tt := range tests {
108 t.Run(tt.name, func(t *testing.T) {
109 t.Parallel()
110
111 // Write content to temp file.
112 dir := t.TempDir()
113 path := filepath.Join(dir, "SKILL.md")
114 require.NoError(t, os.WriteFile(path, []byte(tt.content), 0o644))
115
116 skill, err := Parse(path)
117 if tt.wantErr {
118 require.Error(t, err)
119 return
120 }
121 require.NoError(t, err)
122
123 require.Equal(t, tt.wantName, skill.Name)
124 require.Equal(t, tt.wantDesc, skill.Description)
125 require.Equal(t, tt.wantLicense, skill.License)
126 require.Equal(t, tt.wantCompat, skill.Compatibility)
127
128 if tt.wantMeta != nil {
129 require.Equal(t, tt.wantMeta, skill.Metadata)
130 }
131
132 require.Equal(t, tt.wantInstr, skill.Instructions)
133 })
134 }
135}
136
137func TestSkillValidate(t *testing.T) {
138 t.Parallel()
139
140 tests := []struct {
141 name string
142 skill Skill
143 wantErr bool
144 errMsg string
145 }{
146 {
147 name: "valid skill",
148 skill: Skill{
149 Name: "pdf-processing",
150 Description: "Processes PDF files.",
151 Path: "/skills/pdf-processing",
152 },
153 },
154 {
155 name: "missing name",
156 skill: Skill{Description: "Some description."},
157 wantErr: true,
158 errMsg: "name is required",
159 },
160 {
161 name: "missing description",
162 skill: Skill{Name: "my-skill", Path: "/skills/my-skill"},
163 wantErr: true,
164 errMsg: "description is required",
165 },
166 {
167 name: "name too long",
168 skill: Skill{Name: strings.Repeat("a", 65), Description: "Some description."},
169 wantErr: true,
170 errMsg: "exceeds",
171 },
172 {
173 name: "valid name - mixed case",
174 skill: Skill{Name: "MySkill", Description: "Some description.", Path: "/skills/MySkill"},
175 wantErr: false,
176 },
177 {
178 name: "invalid name - starts with hyphen",
179 skill: Skill{Name: "-my-skill", Description: "Some description."},
180 wantErr: true,
181 errMsg: "alphanumeric with hyphens",
182 },
183 {
184 name: "name doesn't match directory",
185 skill: Skill{Name: "my-skill", Description: "Some description.", Path: "/skills/other-skill"},
186 wantErr: true,
187 errMsg: "must match directory",
188 },
189 {
190 name: "description too long",
191 skill: Skill{Name: "my-skill", Description: strings.Repeat("a", 1025), Path: "/skills/my-skill"},
192 wantErr: true,
193 errMsg: "description exceeds",
194 },
195 {
196 name: "compatibility too long",
197 skill: Skill{Name: "my-skill", Description: "desc", Compatibility: strings.Repeat("a", 501), Path: "/skills/my-skill"},
198 wantErr: true,
199 errMsg: "compatibility exceeds",
200 },
201 }
202
203 for _, tt := range tests {
204 t.Run(tt.name, func(t *testing.T) {
205 t.Parallel()
206
207 err := tt.skill.Validate()
208 if tt.wantErr {
209 require.Error(t, err)
210 require.Contains(t, err.Error(), tt.errMsg)
211 } else {
212 require.NoError(t, err)
213 }
214 })
215 }
216}
217
218func TestDiscover(t *testing.T) {
219 // Not parallel: shares global broker with other Discover tests.
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 ctx, cancel := context.WithCancel(context.Background())
252 defer cancel()
253 ch := SubscribeEvents(ctx)
254
255 skills := Discover([]string{tmpDir})
256
257 evt := <-ch
258 states := evt.Payload.States
259 var normalCount int
260 var errorCount int
261 var hasInvalidDir bool
262 for _, state := range states {
263 if state.State == StateNormal {
264 normalCount++
265 }
266 if state.State == StateError {
267 errorCount++
268 if strings.Contains(state.Path, "invalid-dir") {
269 hasInvalidDir = true
270 }
271 }
272 }
273 require.Equal(t, 2, normalCount)
274 require.Equal(t, 1, errorCount)
275 require.True(t, hasInvalidDir)
276 require.Len(t, skills, 2)
277 require.Equal(t, []string{"skill-two", "skill-one"}, []string{skills[0].Name, skills[1].Name})
278
279 names := make(map[string]bool)
280 for _, s := range skills {
281 names[s.Name] = true
282 }
283 require.True(t, names["skill-one"])
284 require.True(t, names["skill-two"])
285}
286
287func TestDiscoverEmptyDir(t *testing.T) {
288 // Not parallel: shares global broker with other Discover tests.
289
290 tmpDir := t.TempDir()
291
292 ctx, cancel := context.WithCancel(context.Background())
293 defer cancel()
294 ch := SubscribeEvents(ctx)
295
296 skills := Discover([]string{tmpDir})
297
298 evt := <-ch
299 require.Empty(t, evt.Payload.States)
300 require.Empty(t, skills)
301}
302
303func TestDiscoverMissingPath(t *testing.T) {
304 // Not parallel: shares global broker with other Discover tests.
305
306 ctx, cancel := context.WithCancel(context.Background())
307 defer cancel()
308 ch := SubscribeEvents(ctx)
309
310 skills := Discover([]string{filepath.Join(t.TempDir(), "missing")})
311
312 evt := <-ch
313 require.Empty(t, evt.Payload.States)
314 require.Empty(t, skills)
315}
316
317func TestToPromptXML(t *testing.T) {
318 t.Parallel()
319
320 skills := []*Skill{
321 {Name: "pdf-processing", Description: "Extracts text from PDFs.", SkillFilePath: "/skills/pdf-processing/SKILL.md"},
322 {Name: "data-analysis", Description: "Analyzes datasets & charts.", SkillFilePath: "/skills/data-analysis/SKILL.md"},
323 }
324
325 xml := ToPromptXML(skills)
326
327 require.Contains(t, xml, "<available_skills>")
328 require.Contains(t, xml, "<name>pdf-processing</name>")
329 require.Contains(t, xml, "<description>Extracts text from PDFs.</description>")
330 require.Contains(t, xml, "&") // XML escaping
331}
332
333func TestToPromptXMLEmpty(t *testing.T) {
334 t.Parallel()
335 require.Empty(t, ToPromptXML(nil))
336 require.Empty(t, ToPromptXML([]*Skill{}))
337}
338
339func TestEscape(t *testing.T) {
340 t.Parallel()
341
342 tests := []struct {
343 name string
344 in string
345 want string
346 }{
347 {
348 name: "escape xml special chars",
349 in: `<tag attr="x&y">'z'</tag>`,
350 want: `<tag attr="x&y">'z'</tag>`,
351 },
352 {
353 name: "plain text unchanged",
354 in: "hello world",
355 want: "hello world",
356 },
357 }
358
359 for _, tt := range tests {
360 t.Run(tt.name, func(t *testing.T) {
361 t.Parallel()
362 require.Equal(t, tt.want, escape(tt.in))
363 })
364 }
365}
366
367func TestToPromptXMLBuiltinType(t *testing.T) {
368 t.Parallel()
369
370 skills := []*Skill{
371 {Name: "builtin-skill", Description: "A builtin.", SkillFilePath: "crush://skills/builtin-skill/SKILL.md", Builtin: true},
372 {Name: "user-skill", Description: "A user skill.", SkillFilePath: "/home/user/.config/crush/skills/user-skill/SKILL.md"},
373 }
374 xml := ToPromptXML(skills)
375 require.Contains(t, xml, "<type>builtin</type>")
376 require.Equal(t, 1, strings.Count(xml, "<type>builtin</type>"))
377}
378
379func TestParseContent(t *testing.T) {
380 t.Parallel()
381
382 content := []byte(`---
383name: my-skill
384description: A test skill.
385---
386
387# My Skill
388
389Instructions here.
390`)
391 skill, err := ParseContent(content)
392 require.NoError(t, err)
393 require.Equal(t, "my-skill", skill.Name)
394 require.Equal(t, "A test skill.", skill.Description)
395 require.Equal(t, "# My Skill\n\nInstructions here.", skill.Instructions)
396 require.Empty(t, skill.Path)
397 require.Empty(t, skill.SkillFilePath)
398}
399
400func TestParseContent_NoFrontmatter(t *testing.T) {
401 t.Parallel()
402
403 _, err := ParseContent([]byte("# Just Markdown"))
404 require.Error(t, err)
405}
406
407func TestDiscoverBuiltin(t *testing.T) {
408 t.Parallel()
409
410 discovered := DiscoverBuiltin()
411 require.NotEmpty(t, discovered)
412
413 var found bool
414 for _, s := range discovered {
415 if s.Name == "crush-config" {
416 found = true
417 require.True(t, strings.HasPrefix(s.SkillFilePath, BuiltinPrefix))
418 require.True(t, strings.HasPrefix(s.Path, BuiltinPrefix))
419 require.Equal(t, "crush://skills/crush-config/SKILL.md", s.SkillFilePath)
420 require.Equal(t, "crush://skills/crush-config", s.Path)
421 require.NotEmpty(t, s.Description)
422 require.NotEmpty(t, s.Instructions)
423 require.True(t, s.Builtin)
424 }
425 }
426 require.True(t, found, "crush-config builtin skill not found")
427
428 var foundJQ bool
429 for _, s := range discovered {
430 if s.Name == "jq" {
431 foundJQ = true
432 require.Equal(t, "crush://skills/jq/SKILL.md", s.SkillFilePath)
433 require.Equal(t, "crush://skills/jq", s.Path)
434 require.NotEmpty(t, s.Description)
435 require.NotEmpty(t, s.Instructions)
436 require.True(t, s.Builtin)
437 }
438 }
439 require.True(t, foundJQ, "jq builtin skill not found")
440
441 var foundHooks bool
442 for _, s := range discovered {
443 if s.Name == "crush-hooks" {
444 foundHooks = true
445 require.Equal(t, "crush://skills/crush-hooks/SKILL.md", s.SkillFilePath)
446 require.Equal(t, "crush://skills/crush-hooks", s.Path)
447 require.NotEmpty(t, s.Description)
448 require.NotEmpty(t, s.Instructions)
449 require.True(t, s.Builtin)
450 }
451 }
452 require.True(t, foundHooks, "crush-hooks builtin skill not found")
453}
454
455func TestDeduplicate(t *testing.T) {
456 t.Parallel()
457
458 tests := []struct {
459 name string
460 input []*Skill
461 wantLen int
462 wantName string
463 wantPath string
464 }{
465 {
466 name: "no duplicates",
467 input: []*Skill{{Name: "a", Path: "/a"}, {Name: "b", Path: "/b"}},
468 wantLen: 2,
469 },
470 {
471 name: "user overrides builtin",
472 input: []*Skill{{Name: "crush-config", Path: "crush://skills/crush-config"}, {Name: "crush-config", Path: "/user/crush-config"}},
473 wantLen: 1,
474 wantName: "crush-config",
475 wantPath: "/user/crush-config",
476 },
477 {
478 name: "empty",
479 input: nil,
480 wantLen: 0,
481 },
482 }
483
484 for _, tt := range tests {
485 t.Run(tt.name, func(t *testing.T) {
486 t.Parallel()
487 result := Deduplicate(tt.input)
488 require.Len(t, result, tt.wantLen)
489 if tt.wantName != "" {
490 require.Equal(t, tt.wantName, result[0].Name)
491 require.Equal(t, tt.wantPath, result[0].Path)
492 }
493 })
494 }
495}
496
497func TestFilter(t *testing.T) {
498 t.Parallel()
499
500 all := []*Skill{
501 {Name: "a"},
502 {Name: "b"},
503 {Name: "c"},
504 }
505
506 tests := []struct {
507 name string
508 disabled []string
509 wantLen int
510 }{
511 {"no filter", nil, 3},
512 {"filter one", []string{"b"}, 2},
513 {"filter all", []string{"a", "b", "c"}, 0},
514 {"filter nonexistent", []string{"d"}, 3},
515 }
516
517 for _, tt := range tests {
518 t.Run(tt.name, func(t *testing.T) {
519 t.Parallel()
520 result := Filter(all, tt.disabled)
521 require.Len(t, result, tt.wantLen)
522 })
523 }
524}