返回 DeepSeek-Reasonix
skill_extra_test.go
根目录 / internal / skill / skill_extra_test.go
1 package skill
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 // --- IsValidName ---
11
12 func TestIsValidName(t *testing.T) {
13 cases := []struct {
14 name string
15 want bool
16 }{
17 {"valid-name", true},
18 {"CamelCase", true},
19 {"with.dot", true},
20 {"with_underscore", true},
21 {"a", true},
22 {"A123", true},
23 {"", false},
24 {"-starts-dash", false},
25 {"has space", false},
26 {"has/slash", false},
27 {strings.Repeat("a", 65), false}, // too long
28 {strings.Repeat("a", 64), true}, // max length
29 }
30 for _, c := range cases {
31 if got := IsValidName(c.name); got != c.want {
32 t.Errorf("IsValidName(%q) = %v, want %v", c.name, got, c.want)
33 }
34 }
35 }
36
37 // --- splitFrontmatter ---
38
39 func TestSplitFrontmatterNoFence(t *testing.T) {
40 fm, body := splitFrontmatter("just body")
41 if len(fm) != 0 {
42 t.Errorf("expected empty fm, got %v", fm)
43 }
44 if body != "just body" {
45 t.Errorf("body = %q", body)
46 }
47 }
48
49 func TestSplitFrontmatterUnclosed(t *testing.T) {
50 fm, body := splitFrontmatter("---\nkey: val\n\nno closing")
51 if len(fm) != 0 {
52 t.Errorf("unclosed fence should return empty fm, got %v", fm)
53 }
54 if !strings.Contains(body, "---") {
55 t.Errorf("body should contain original: %q", body)
56 }
57 }
58
59 func TestSplitFrontmatterEmpty(t *testing.T) {
60 fm, body := splitFrontmatter("")
61 if len(fm) != 0 {
62 t.Errorf("empty input should return empty fm, got %v", fm)
63 }
64 if body != "" {
65 t.Errorf("body = %q", body)
66 }
67 }
68
69 func TestSplitFrontmatterQuotedValues(t *testing.T) {
70 fm, _ := splitFrontmatter("---\ndescription: \"quoted\"\n---\n")
71 if fm["description"] != "quoted" {
72 t.Errorf("description = %q", fm["description"])
73 }
74 }
75
76 // --- parseAllowedTools ---
77
78 func TestParseAllowedToolsEmpty(t *testing.T) {
79 if got := parseAllowedTools(""); got != nil {
80 t.Errorf("empty = %v, want nil", got)
81 }
82 if got := parseAllowedTools(" "); got != nil {
83 t.Errorf("whitespace = %v, want nil", got)
84 }
85 }
86
87 func TestParseAllowedToolsSingle(t *testing.T) {
88 got := parseAllowedTools("bash")
89 if len(got) != 1 || got[0] != "bash" {
90 t.Errorf("single = %v", got)
91 }
92 }
93
94 func TestParseAllowedToolsMultiple(t *testing.T) {
95 got := parseAllowedTools("read_file, grep, bash")
96 if len(got) != 3 {
97 t.Errorf("count = %d, want 3", len(got))
98 }
99 if got[0] != "read_file" || got[1] != "grep" || got[2] != "bash" {
100 t.Errorf("tools = %v", got)
101 }
102 }
103
104 func TestParseAllowedToolsTrailingComma(t *testing.T) {
105 got := parseAllowedTools("bash,")
106 if len(got) != 1 || got[0] != "bash" {
107 t.Errorf("trailing comma = %v", got)
108 }
109 }
110
111 func TestParseAllowedToolsExtraSpaces(t *testing.T) {
112 got := parseAllowedTools(" bash , grep ")
113 if len(got) != 2 || got[0] != "bash" || got[1] != "grep" {
114 t.Errorf("extra spaces = %v", got)
115 }
116 }
117
118 // --- parseRunAs ---
119
120 func TestParseRunAsExplicit(t *testing.T) {
121 if parseRunAs("subagent", "", "") != RunSubagent {
122 t.Error("explicit subagent should return RunSubagent")
123 }
124 if parseRunAs("inline", "", "") != RunInline {
125 t.Error("explicit inline should return RunInline")
126 }
127 }
128
129 func TestParseRunAsContextFork(t *testing.T) {
130 if parseRunAs("", "fork", "") != RunSubagent {
131 t.Error("context: fork should return RunSubagent")
132 }
133 if parseRunAs("", "FORK", "") != RunSubagent {
134 t.Error("context: FORK should return RunSubagent")
135 }
136 }
137
138 func TestParseRunAsAgent(t *testing.T) {
139 if parseRunAs("", "", "some-agent") != RunSubagent {
140 t.Error("non-empty agent should return RunSubagent")
141 }
142 }
143
144 func TestParseRunAsDefault(t *testing.T) {
145 if parseRunAs("", "", "") != RunInline {
146 t.Error("all empty should default to RunInline")
147 }
148 if parseRunAs("unknown", "", "") != RunInline {
149 t.Error("unknown runAs should default to RunInline")
150 }
151 }
152
153 // --- resolveCustomPaths ---
154
155 func TestResolveCustomPathsTilde(t *testing.T) {
156 home := t.TempDir()
157 got := resolveCustomPaths([]string{"~/skills"}, "/base", home)
158 if len(got) != 1 || got[0] != filepath.Join(home, "skills") {
159 t.Errorf("tilde expansion = %v", got)
160 }
161 }
162
163 func TestResolveCustomPathsRelative(t *testing.T) {
164 base := t.TempDir()
165 got := resolveCustomPaths([]string{"./my-skills"}, base, "/home")
166 if len(got) != 1 || got[0] != filepath.Join(base, "my-skills") {
167 t.Errorf("relative = %v", got)
168 }
169 }
170
171 func TestResolveCustomPathsAbsolute(t *testing.T) {
172 abs := filepath.Join(t.TempDir(), "absolute", "path")
173 got := resolveCustomPaths([]string{abs}, "/base", "/home")
174 if len(got) != 1 || got[0] != abs {
175 t.Errorf("absolute = %v", got)
176 }
177 }
178
179 func TestResolveCustomPathsEmpty(t *testing.T) {
180 got := resolveCustomPaths([]string{"", " "}, "/base", "/home")
181 if len(got) != 0 {
182 t.Errorf("empty paths should be filtered, got %v", got)
183 }
184 }
185
186 // --- dedupePaths ---
187
188 func TestDedupePaths(t *testing.T) {
189 got := dedupePaths([]string{"/a", "/b", "/a", "/c", "/b"})
190 if len(got) != 3 || got[0] != "/a" || got[1] != "/b" || got[2] != "/c" {
191 t.Errorf("deduped = %v", got)
192 }
193 }
194
195 func TestDedupePathsEmpty(t *testing.T) {
196 got := dedupePaths(nil)
197 if len(got) != 0 {
198 t.Errorf("nil = %v", got)
199 }
200 }
201
202 // --- stubBody ---
203
204 func TestStubBody(t *testing.T) {
205 body := stubBody("my-skill")
206 if !strings.Contains(body, "name: my-skill") {
207 t.Error("stub should contain the skill name")
208 }
209 if !strings.Contains(body, "description:") {
210 t.Error("stub should contain description field")
211 }
212 if !strings.Contains(body, "# my-skill") {
213 t.Error("stub should contain the skill name as heading")
214 }
215 }
216
217 // --- Read edge cases ---
218
219 func TestReadInvalidName(t *testing.T) {
220 home := t.TempDir()
221 st := New(Options{HomeDir: home, DisableBuiltins: true})
222 _, ok := st.Read("invalid name!")
223 if ok {
224 t.Error("invalid name should return ok=false")
225 }
226 }
227
228 func TestReadNotFound(t *testing.T) {
229 home := t.TempDir()
230 st := New(Options{HomeDir: home, DisableBuiltins: true})
231 _, ok := st.Read("nonexistent")
232 if ok {
233 t.Error("nonexistent skill should return ok=false")
234 }
235 }
236
237 // --- Create edge cases ---
238
239 func TestCreateInvalidName(t *testing.T) {
240 home := t.TempDir()
241 st := New(Options{HomeDir: home, DisableBuiltins: true})
242 _, err := st.Create("invalid name!", ScopeGlobal)
243 if err == nil {
244 t.Error("invalid name should error")
245 }
246 }
247
248 func TestCreateProjectScopeRequiresRoot(t *testing.T) {
249 home := t.TempDir()
250 st := New(Options{HomeDir: home, DisableBuiltins: true})
251 _, err := st.Create("test", ScopeProject)
252 if err == nil {
253 t.Error("project scope without root should error")
254 }
255 }
256
257 func TestCreateDirectoryLayoutSkill(t *testing.T) {
258 home := t.TempDir()
259 skillsRoot := filepath.Join(home, ".reasonix", "skills", "existing", "SKILL.md")
260 os.MkdirAll(filepath.Dir(skillsRoot), 0o755)
261 os.WriteFile(skillsRoot, []byte("---\ndescription: exists\n---\nbody"), 0o644)
262 st := New(Options{HomeDir: home, DisableBuiltins: true})
263 _, err := st.Create("existing", ScopeGlobal)
264 if err == nil {
265 t.Error("should refuse to overwrite directory-layout skill")
266 }
267 }
268
269 func TestUpdateContentOverwritesExistingSkill(t *testing.T) {
270 home := t.TempDir()
271 st := New(Options{HomeDir: home, DisableBuiltins: true})
272 if _, err := st.CreateWithContent("editable", ScopeGlobal, "---\ndescription: v1\n---\nold body"); err != nil {
273 t.Fatalf("CreateWithContent: %v", err)
274 }
275 if err := st.UpdateContent("editable", ScopeGlobal, "---\ndescription: v2\n---\nnew body"); err != nil {
276 t.Fatalf("UpdateContent: %v", err)
277 }
278 sk, ok := st.Read("editable")
279 if !ok {
280 t.Fatal("skill missing after update")
281 }
282 if sk.Description != "v2" || sk.Body != "new body" {
283 t.Fatalf("update did not apply: description=%q body=%q", sk.Description, sk.Body)
284 }
285 }
286
287 func TestUpdateContentRefusesBuiltin(t *testing.T) {
288 st := New(Options{HomeDir: t.TempDir()})
289 if err := st.UpdateContent("explore", ScopeBuiltin, "---\ndescription: x\n---\nbody"); err == nil {
290 t.Error("updating a builtin should error")
291 }
292 }
293
294 func TestUpdateContentRefusesMissingSkill(t *testing.T) {
295 st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
296 if err := st.UpdateContent("does-not-exist", ScopeGlobal, "---\ndescription: x\n---\nbody"); err == nil {
297 t.Error("updating a nonexistent skill should error")
298 }
299 }
300
301 func TestUpdateContentRefusesScopeMismatch(t *testing.T) {
302 home := t.TempDir()
303 st := New(Options{HomeDir: home, DisableBuiltins: true})
304 if _, err := st.CreateWithContent("scoped2", ScopeGlobal, "---\ndescription: v1\n---\nbody"); err != nil {
305 t.Fatalf("CreateWithContent: %v", err)
306 }
307 if err := st.UpdateContent("scoped2", ScopeProject, "---\ndescription: v2\n---\nbody"); err == nil {
308 t.Error("updating with the wrong scope should error")
309 }
310 sk, ok := st.Read("scoped2")
311 if !ok || sk.Description != "v1" {
312 t.Fatalf("skill should be unchanged after a refused scope-mismatched update, got description=%q ok=%v", sk.Description, ok)
313 }
314 }
315
316 func TestUpdateContentRefusesSymlinkedFlatSkill(t *testing.T) {
317 home := t.TempDir()
318 outside := filepath.Join(t.TempDir(), "outside.md")
319 original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
320 if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
321 t.Fatal(err)
322 }
323 root := filepath.Join(home, ".reasonix", SkillsDirname)
324 if err := os.MkdirAll(root, 0o755); err != nil {
325 t.Fatal(err)
326 }
327 if err := os.Symlink(outside, filepath.Join(root, "linked.md")); err != nil {
328 t.Skipf("symlinks unavailable: %v", err)
329 }
330 st := New(Options{HomeDir: home, DisableBuiltins: true})
331 if _, ok := st.Read("linked"); !ok {
332 t.Fatal("symlinked flat skill should remain readable")
333 }
334 if err := st.UpdateContent("linked", ScopeGlobal, "changed"); err == nil {
335 t.Fatal("updating a symlinked flat skill should fail")
336 }
337 got, err := os.ReadFile(outside)
338 if err != nil || string(got) != original {
339 t.Fatalf("outside target changed: content=%q err=%v", got, err)
340 }
341 }
342
343 func TestUpdateContentRefusesSymlinkedDirectorySkill(t *testing.T) {
344 home := t.TempDir()
345 outsideDir := t.TempDir()
346 outside := filepath.Join(outsideDir, SkillFile)
347 original := "---\ndescription: outside\nrunAs: subagent\ninvocation: manual\n---\noriginal"
348 if err := os.WriteFile(outside, []byte(original), 0o644); err != nil {
349 t.Fatal(err)
350 }
351 root := filepath.Join(home, ".reasonix", SkillsDirname)
352 if err := os.MkdirAll(root, 0o755); err != nil {
353 t.Fatal(err)
354 }
355 if err := os.Symlink(outsideDir, filepath.Join(root, "linked-dir")); err != nil {
356 t.Skipf("symlinks unavailable: %v", err)
357 }
358 st := New(Options{HomeDir: home, DisableBuiltins: true})
359 if _, ok := st.Read("linked-dir"); !ok {
360 t.Fatal("symlinked directory skill should remain readable")
361 }
362 if err := st.UpdateContent("linked-dir", ScopeGlobal, "changed"); err == nil {
363 t.Fatal("updating a symlinked directory skill should fail")
364 }
365 got, err := os.ReadFile(outside)
366 if err != nil || string(got) != original {
367 t.Fatalf("outside target changed: content=%q err=%v", got, err)
368 }
369 }
370
371 func TestDeleteSymlinkedSkillsRemovesLinksNotTargets(t *testing.T) {
372 home := t.TempDir()
373 root := filepath.Join(home, ".reasonix", SkillsDirname)
374 if err := os.MkdirAll(root, 0o755); err != nil {
375 t.Fatal(err)
376 }
377 outsideDir := t.TempDir()
378 flatTarget := filepath.Join(outsideDir, "flat-target.md")
379 dirTarget := filepath.Join(outsideDir, "directory-target")
380 if err := os.MkdirAll(dirTarget, 0o755); err != nil {
381 t.Fatal(err)
382 }
383 content := []byte("---\ndescription: linked\nrunAs: subagent\ninvocation: manual\n---\nbody")
384 if err := os.WriteFile(flatTarget, content, 0o644); err != nil {
385 t.Fatal(err)
386 }
387 if err := os.WriteFile(filepath.Join(dirTarget, SkillFile), content, 0o644); err != nil {
388 t.Fatal(err)
389 }
390 flatLink := filepath.Join(root, "flat-link.md")
391 dirLink := filepath.Join(root, "dir-link")
392 if err := os.Symlink(flatTarget, flatLink); err != nil {
393 t.Skipf("symlinks unavailable: %v", err)
394 }
395 if err := os.Symlink(dirTarget, dirLink); err != nil {
396 t.Skipf("symlinks unavailable: %v", err)
397 }
398 st := New(Options{HomeDir: home, DisableBuiltins: true})
399 for _, name := range []string{"flat-link", "dir-link"} {
400 if err := st.Delete(name, ScopeGlobal); err != nil {
401 t.Fatalf("Delete(%q): %v", name, err)
402 }
403 }
404 if _, err := os.Lstat(flatLink); !os.IsNotExist(err) {
405 t.Fatalf("flat link still exists: %v", err)
406 }
407 if _, err := os.Lstat(dirLink); !os.IsNotExist(err) {
408 t.Fatalf("directory link still exists: %v", err)
409 }
410 if got, err := os.ReadFile(flatTarget); err != nil || string(got) != string(content) {
411 t.Fatalf("flat target changed: content=%q err=%v", got, err)
412 }
413 if got, err := os.ReadFile(filepath.Join(dirTarget, SkillFile)); err != nil || string(got) != string(content) {
414 t.Fatalf("directory target changed: content=%q err=%v", got, err)
415 }
416 }
417
418 func TestDeleteRemovesDirectoryLayoutSkill(t *testing.T) {
419 home := t.TempDir()
420 st := New(Options{HomeDir: home, DisableBuiltins: true})
421 path, err := st.CreateWithContent("throwaway", ScopeGlobal, "---\ndescription: x\n---\nbody")
422 if err != nil {
423 t.Fatalf("CreateWithContent: %v", err)
424 }
425 if err := st.Delete("throwaway", ScopeGlobal); err != nil {
426 t.Fatalf("Delete: %v", err)
427 }
428 if _, ok := st.Read("throwaway"); ok {
429 t.Fatal("skill should be gone after Delete")
430 }
431 if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
432 t.Fatalf("skill directory should be removed, stat err=%v", err)
433 }
434 }
435
436 func TestDeleteRefusesBuiltin(t *testing.T) {
437 st := New(Options{HomeDir: t.TempDir()})
438 if err := st.Delete("explore", ScopeBuiltin); err == nil {
439 t.Error("deleting a builtin should error")
440 }
441 }
442
443 func TestDeleteRefusesMissingSkill(t *testing.T) {
444 st := New(Options{HomeDir: t.TempDir(), DisableBuiltins: true})
445 if err := st.Delete("does-not-exist", ScopeGlobal); err == nil {
446 t.Error("deleting a nonexistent skill should error")
447 }
448 }
449
450 func TestDeleteRefusesScopeMismatch(t *testing.T) {
451 home := t.TempDir()
452 st := New(Options{HomeDir: home, DisableBuiltins: true})
453 if _, err := st.CreateWithContent("scoped", ScopeGlobal, "---\ndescription: x\n---\nbody"); err != nil {
454 t.Fatalf("CreateWithContent: %v", err)
455 }
456 // The skill actually lives at ScopeGlobal; a ScopeProject delete request
457 // for the same name must refuse rather than silently no-op or, worse,
458 // resolve to an unrelated file.
459 if err := st.Delete("scoped", ScopeProject); err == nil {
460 t.Error("deleting with the wrong scope should error")
461 }
462 if _, ok := st.Read("scoped"); !ok {
463 t.Fatal("skill should survive a refused scope-mismatched delete")
464 }
465 }
466
467 // --- New edge cases ---
468
469 func TestNewWithCustomPaths(t *testing.T) {
470 custom := t.TempDir()
471 st := New(Options{HomeDir: t.TempDir(), CustomPaths: []string{custom}, DisableBuiltins: true})
472 roots := st.Roots()
473 found := false
474 for _, r := range roots {
475 if r.Dir == custom && r.Scope == ScopeCustom {
476 found = true
477 break
478 }
479 }
480 if !found {
481 t.Error("custom path not in roots")
482 }
483 }
484
485 func TestHasProjectScope(t *testing.T) {
486 st1 := New(Options{HomeDir: t.TempDir(), ProjectRoot: "/some/project"})
487 if !st1.HasProjectScope() {
488 t.Error("with project root should return true")
489 }
490 st2 := New(Options{HomeDir: t.TempDir()})
491 if st2.HasProjectScope() {
492 t.Error("without project root should return false")
493 }
494 }
495
495 lines GO