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