skills_test.go

  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, "&amp;") // 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}