skills_test.go

  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, "&amp;") // 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: `&lt;tag attr=&quot;x&amp;y&quot;&gt;&apos;z&apos;&lt;/tag&gt;`,
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}