返回 CodeWhale
tests.rs
根目录 / crates / tui / src / skills / tests.rs
1 use tempfile::TempDir;
2
3 fn create_skill_dir(tmpdir: &TempDir, skill_name: &str, skill_content: &str) {
4 let skill_dir = tmpdir.path().join("skills").join(skill_name);
5 std::fs::create_dir_all(&skill_dir).unwrap();
6 std::fs::write(skill_dir.join("SKILL.md"), skill_content).unwrap();
7 }
8
9 #[test]
10 fn discovery_metrics_reset_and_snapshot_are_exact() {
11 super::reset_discovery_metrics();
12 assert_eq!(
13 super::discovery_metrics_snapshot(),
14 super::SkillDiscoveryMetrics::default()
15 );
16
17 let tmpdir = TempDir::new().unwrap();
18 let skills_root = tmpdir.path().join("skills");
19 let vendor_root = skills_root.join("vendor");
20 write_skill(&vendor_root, "demo", "A demo skill", "Instructions");
21
22 let registry = super::SkillRegistry::discover(&skills_root);
23 assert_eq!(registry.len(), 1);
24 assert_eq!(
25 super::discovery_metrics_snapshot(),
26 super::SkillDiscoveryMetrics {
27 root_discovery_calls: 1,
28 directories_visited: 2,
29 skill_md_read_attempts: 2,
30 }
31 );
32
33 super::reset_discovery_metrics();
34 let missing_root = tmpdir.path().join("missing");
35 let _registry = super::SkillRegistry::discover(&missing_root);
36 assert_eq!(
37 super::discovery_metrics_snapshot(),
38 super::SkillDiscoveryMetrics {
39 root_discovery_calls: 1,
40 directories_visited: 0,
41 skill_md_read_attempts: 0,
42 }
43 );
44
45 super::reset_discovery_metrics();
46 assert_eq!(
47 super::discovery_metrics_snapshot(),
48 super::SkillDiscoveryMetrics::default()
49 );
50 }
51
52 #[test]
53 fn prompt_warning_sanitizer_scrubs_stale_conventional_home_roots() {
54 let workspace = std::path::Path::new("/tmp/workspace");
55 let warning = "Skill at /Users/private-name/.agents/skills/a/SKILL.md is shadowed by /home/other/.skills/a/SKILL.md";
56 let sanitized = super::sanitize_prompt_path_text(warning, workspace);
57 assert_eq!(
58 sanitized,
59 "Skill at ~/.agents/skills/a/SKILL.md is shadowed by ~/.skills/a/SKILL.md"
60 );
61 }
62
63 #[test]
64 fn render_available_skills_context_lists_paths_and_usage() {
65 let tmpdir = TempDir::new().unwrap();
66 create_skill_dir(
67 &tmpdir,
68 "test-skill",
69 "---\nname: test-skill\ndescription: A test skill\n---\nDo something special",
70 );
71
72 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
73 .expect("skill context");
74
75 // #4632: paths render relative to the skills base dir (privacy-safe),
76 // so the assertion checks the workspace-relative form.
77 let expected_path = std::path::Path::new("test-skill")
78 .join("SKILL.md")
79 .display()
80 .to_string();
81
82 assert!(rendered.contains("## Skills"));
83 assert!(rendered.contains("- test-skill: A test skill"));
84 assert!(rendered.contains("load the exact skill before applying it"));
85 assert!(rendered.contains("do not expand tool, approval, or trust authority"));
86 assert!(
87 rendered.contains(&expected_path),
88 "expected path {expected_path:?} not in rendered output"
89 );
90 assert!(!rendered.contains(tmpdir.path().to_str().unwrap_or("/nonexistent")));
91 assert!(rendered.contains("### Usage"));
92 }
93
94 #[test]
95 fn workspace_prompt_omits_disabled_skills_without_configured_directory() {
96 let _env_lock = crate::test_support::lock_test_env();
97 let tmpdir = TempDir::new().unwrap();
98 let home = tmpdir.path().join("home");
99 let workspace = tmpdir.path().join("workspace");
100 let skills_root = workspace.join(".agents").join("skills");
101 std::fs::create_dir_all(&home).unwrap();
102 write_skill(
103 &skills_root,
104 "enabled-skill",
105 "Enabled skill",
106 "Instructions",
107 );
108 write_skill(
109 &skills_root,
110 "disabled-skill",
111 "Disabled skill",
112 "Instructions",
113 );
114 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
115 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
116 let _codewhale_home =
117 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
118
119 let mut state = crate::skill_state::SkillStateStore::load_default().unwrap();
120 state.set_enabled("disabled-skill", false).unwrap();
121 super::clear_skill_discovery_cache();
122
123 let rendered = super::render_available_skills_context_for_workspace_with_mode_and_plugins(
124 &workspace,
125 super::SkillDiscoveryMode::Compatible,
126 "en",
127 None,
128 )
129 .expect("enabled skill context");
130
131 assert!(rendered.contains("enabled-skill"));
132 assert!(!rendered.contains("disabled-skill"));
133 }
134
135 #[test]
136 fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() {
137 // Regression: when a community-installed or manually-placed skill
138 // lives in a directory whose name differs from its frontmatter
139 // `name`, the rendered prompt must point to the real on-disk file
140 // path, not <skills_dir>/<frontmatter-name>/SKILL.md (which does
141 // not exist).
142 let tmpdir = TempDir::new().unwrap();
143 create_skill_dir(
144 &tmpdir,
145 "weird-dir-name",
146 "---\nname: friendly-name\ndescription: drift case\n---\nbody",
147 );
148
149 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
150 .expect("skill context");
151
152 // #4632: rendered relative to the skills base dir; the regression
153 // intent (real dir name, not frontmatter name) is unchanged.
154 let real_path = std::path::Path::new("weird-dir-name")
155 .join("SKILL.md")
156 .display()
157 .to_string();
158 let stale_path = std::path::Path::new("friendly-name")
159 .join("SKILL.md")
160 .display()
161 .to_string();
162
163 assert!(
164 rendered.contains(&real_path),
165 "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}"
166 );
167 assert!(
168 !rendered.contains(&stale_path),
169 "rendered output must not invent a path under the frontmatter name:\n{rendered}"
170 );
171 }
172
173 #[test]
174 fn render_available_skills_context_returns_none_when_empty() {
175 let tmpdir = TempDir::new().unwrap();
176 let empty = tmpdir.path().join("skills");
177 std::fs::create_dir_all(&empty).unwrap();
178 assert!(crate::skills::render_available_skills_context(&empty).is_none());
179
180 let missing = tmpdir.path().join("does-not-exist");
181 assert!(crate::skills::render_available_skills_context(&missing).is_none());
182 }
183
184 #[test]
185 fn render_skills_block_surfaces_warnings_when_no_skill_loaded() {
186 let tmpdir = TempDir::new().unwrap();
187 let mut registry = super::SkillRegistry::default();
188 registry
189 .warnings
190 .push("broken skill could not be parsed".to_string());
191
192 let rendered =
193 super::render_skills_block(&registry, "en", tmpdir.path()).expect("warning-only block");
194
195 assert!(rendered.contains("### Skill load warnings"));
196 assert!(rendered.contains("broken skill could not be parsed"));
197 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
198 }
199
200 #[test]
201 fn render_available_skills_context_truncates_long_descriptions() {
202 let tmpdir = TempDir::new().unwrap();
203 let long_desc = "x".repeat(2_000);
204 let body = format!("---\nname: bigdesc\ndescription: {long_desc}\n---\nbody");
205 create_skill_dir(&tmpdir, "bigdesc", &body);
206
207 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
208 .expect("skill context");
209
210 let max = super::MAX_SKILL_DESCRIPTION_CHARS;
211 assert!(rendered.contains('…'), "expected truncation marker");
212 assert!(
213 !rendered.contains(&"x".repeat(max + 1)),
214 "untruncated long run should not appear"
215 );
216 }
217
218 #[test]
219 fn render_available_skills_context_collapses_internal_whitespace() {
220 let tmpdir = TempDir::new().unwrap();
221 create_skill_dir(
222 &tmpdir,
223 "spaced-skill",
224 "---\nname: spaced-skill\ndescription: alpha \t beta gamma\n---\nbody",
225 );
226
227 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
228 .expect("skill context");
229
230 let line = rendered
231 .lines()
232 .find(|l| l.starts_with("- spaced-skill:"))
233 .expect("skill line");
234 assert!(line.contains("alpha beta gamma"), "got: {line:?}");
235 }
236
237 #[test]
238 fn render_available_skills_context_omits_overflowing_skills() {
239 let tmpdir = TempDir::new().unwrap();
240 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
241 for i in 0..200 {
242 let body = format!("---\nname: skill-{i:03}\ndescription: {big_desc}\n---\nbody");
243 create_skill_dir(&tmpdir, &format!("skill-{i:03}"), &body);
244 }
245
246 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
247 .expect("skill context");
248
249 assert!(
250 rendered.contains("additional skills omitted"),
251 "expected overflow notice"
252 );
253 assert!(
254 rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS,
255 "rendered length must stay within the complete block budget"
256 );
257 }
258
259 #[test]
260 fn render_skills_block_holds_budget_with_five_digit_omission_counts() {
261 let tmpdir = TempDir::new().unwrap();
262 let mut registry = super::SkillRegistry::default();
263 for i in 0..11_000 {
264 registry.skills.push(super::Skill {
265 name: format!("skill-{i:05}"),
266 description: "x".to_string(),
267 localized_descriptions: std::collections::HashMap::new(),
268 invocation: super::SkillInvocation::ModelAndUser,
269 aliases: Vec::new(),
270 body: "body".to_string(),
271 path: tmpdir.path().join(format!("skill-{i:05}/SKILL.md")),
272 source: super::SkillSource::Native,
273 });
274 registry.warnings.push(format!("warning {i:05}"));
275 }
276
277 let rendered =
278 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
279 let omitted_skills = rendered
280 .lines()
281 .find(|line| line.contains("additional skills omitted"))
282 .and_then(|line| line.split_whitespace().nth(2))
283 .and_then(|count| count.parse::<usize>().ok())
284 .expect("skill omission count");
285 let omitted_warnings = rendered
286 .lines()
287 .find(|line| line.contains("additional warnings omitted"))
288 .and_then(|line| line.split_whitespace().nth(2))
289 .and_then(|count| count.parse::<usize>().ok())
290 .expect("warning omission count");
291
292 assert!(omitted_skills > 9_999, "fixture must exercise five digits");
293 assert!(
294 omitted_warnings > 9_999,
295 "fixture must exercise five digits"
296 );
297 assert!(rendered.chars().count() <= super::MAX_AVAILABLE_SKILLS_CHARS);
298 }
299
300 #[test]
301 fn explicit_only_skills_do_not_reduce_ambient_index_capacity() {
302 let tmpdir = TempDir::new().unwrap();
303 let mut registry = super::SkillRegistry::default();
304 for i in 0..6 {
305 registry.skills.push(super::Skill {
306 name: format!("visible-{i:03}"),
307 description: "x".repeat(246),
308 localized_descriptions: std::collections::HashMap::new(),
309 invocation: super::SkillInvocation::ModelAndUser,
310 aliases: Vec::new(),
311 body: "body".to_string(),
312 path: tmpdir.path().join(format!("visible-{i:03}/SKILL.md")),
313 source: super::SkillSource::Native,
314 });
315 }
316
317 let baseline =
318 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
319 assert!(!baseline.contains("additional skills omitted"));
320
321 let mut with_explicit_only = registry.clone();
322 for i in 0..10_000 {
323 with_explicit_only.skills.push(super::Skill {
324 name: format!("explicit-{i:05}"),
325 description: String::new(),
326 localized_descriptions: std::collections::HashMap::new(),
327 invocation: super::SkillInvocation::ExplicitOnly,
328 aliases: Vec::new(),
329 body: "body".to_string(),
330 path: tmpdir.path().join(format!("explicit-{i:05}/SKILL.md")),
331 source: super::SkillSource::Native,
332 });
333 }
334
335 let rendered = super::render_skills_block(&with_explicit_only, "en", tmpdir.path())
336 .expect("skill context");
337 assert_eq!(rendered, baseline);
338 }
339
340 #[test]
341 fn render_skills_block_preserves_registry_precedence_under_prompt_budget() {
342 let tmpdir = TempDir::new().unwrap();
343 let mut registry = super::SkillRegistry::default();
344 registry.skills.push(super::Skill {
345 name: "workspace-priority".to_string(),
346 description: "must survive truncation".to_string(),
347 localized_descriptions: std::collections::HashMap::new(),
348 invocation: super::SkillInvocation::ModelAndUser,
349 aliases: Vec::new(),
350 body: "body".to_string(),
351 path: tmpdir
352 .path()
353 .join(".claude")
354 .join("skills")
355 .join("workspace-priority")
356 .join("SKILL.md"),
357 source: super::SkillSource::Native,
358 });
359
360 let big_desc = "y".repeat(super::MAX_SKILL_DESCRIPTION_CHARS - 20);
361 for i in 0..200 {
362 registry.skills.push(super::Skill {
363 name: format!("aaa-global-{i:03}"),
364 description: big_desc.clone(),
365 localized_descriptions: std::collections::HashMap::new(),
366 invocation: super::SkillInvocation::ModelAndUser,
367 aliases: Vec::new(),
368 body: "body".to_string(),
369 path: tmpdir
370 .path()
371 .join(".deepseek")
372 .join("skills")
373 .join(format!("aaa-global-{i:03}"))
374 .join("SKILL.md"),
375 source: super::SkillSource::Native,
376 });
377 }
378
379 let rendered =
380 super::render_skills_block(&registry, "en", tmpdir.path()).expect("skill context");
381 assert!(
382 rendered.contains("workspace-priority"),
383 "higher-precedence workspace skills must not be reordered behind globals:\n{rendered}"
384 );
385 assert!(
386 rendered.contains("additional skills omitted"),
387 "fixture should exceed prompt budget"
388 );
389 }
390
391 // --- Localized skill descriptions (#3354) ------------------------------
392
393 #[test]
394 fn parse_skill_collects_localized_description_frontmatter() {
395 let content = "---\n\
396 name: demo\n\
397 description: A demo skill\n\
398 description_zh: 一个演示技能\n\
399 description_zh-Hant: 一個示範技能\n\
400 ---\n\
401 body";
402 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
403 .expect("parse should succeed");
404 assert_eq!(skill.description, "A demo skill");
405 assert_eq!(
406 skill.localized_descriptions.get("zh").map(String::as_str),
407 Some("一个演示技能")
408 );
409 // Frontmatter keys are lowercased, so zh-Hant is stored as zh-hant.
410 assert_eq!(
411 skill
412 .localized_descriptions
413 .get("zh-hant")
414 .map(String::as_str),
415 Some("一個示範技能")
416 );
417 }
418
419 #[test]
420 fn parse_skill_exposes_invocation_and_alias_metadata() {
421 let content = "---\n\
422 name: spreadsheets\n\
423 description: Spreadsheet workflows\n\
424 invocation: explicit-only\n\
425 aliases-for: xlsx, spreadsheet\n\
426 ---\n\
427 body";
428 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), content)
429 .expect("parse should succeed");
430
431 assert_eq!(skill.invocation, super::SkillInvocation::ExplicitOnly);
432 assert_eq!(
433 skill.aliases,
434 vec!["xlsx".to_string(), "spreadsheet".to_string()]
435 );
436
437 let mut registry = super::SkillRegistry::default();
438 registry.skills.push(skill);
439 assert_eq!(
440 registry.get("spreadsheet").map(|s| s.name.as_str()),
441 Some("spreadsheets")
442 );
443 assert_eq!(
444 registry.get("xlsx").map(|s| s.name.as_str()),
445 Some("spreadsheets")
446 );
447
448 let rendered = super::render_skills_block(&registry, "en", std::path::Path::new("/"));
449 assert!(
450 rendered.is_some(),
451 "an explicit-only skill remains loadable"
452 );
453 assert!(
454 !rendered.unwrap_or_default().contains("spreadsheets"),
455 "explicit-only skills must not enter the model catalogue"
456 );
457 }
458
459 #[test]
460 fn missing_or_unknown_invocation_keeps_model_and_user_compatibility() {
461 for invocation in [None, Some("future-mode")] {
462 let invocation_line =
463 invocation.map_or(String::new(), |value| format!("invocation: {value}\n"));
464 let content =
465 format!("---\nname: compatible\ndescription: compatible\n{invocation_line}---\nbody");
466 let skill = super::SkillRegistry::parse_skill(std::path::Path::new("SKILL.md"), &content)
467 .expect("parse should succeed");
468 assert_eq!(skill.invocation, super::SkillInvocation::ModelAndUser);
469 }
470 }
471
472 #[test]
473 fn description_for_locale_matches_exact_then_primary_then_falls_back() {
474 let mut localized = std::collections::HashMap::new();
475 localized.insert("zh".to_string(), "中文描述".to_string());
476 localized.insert("ja".to_string(), "日本語の説明".to_string());
477 let skill = super::Skill {
478 name: "demo".to_string(),
479 description: "English description".to_string(),
480 localized_descriptions: localized,
481 invocation: super::SkillInvocation::ModelAndUser,
482 aliases: Vec::new(),
483 body: String::new(),
484 path: std::path::PathBuf::new(),
485 source: super::SkillSource::Native,
486 };
487
488 assert_eq!(skill.description_for_locale("zh"), "中文描述"); // exact
489 assert_eq!(skill.description_for_locale("ZH"), "中文描述"); // case-insensitive
490 assert_eq!(skill.description_for_locale("zh-CN"), "中文描述"); // Simplified region → zh
491 assert_eq!(skill.description_for_locale("zh-Hans"), "中文描述"); // Simplified script → zh
492 assert_eq!(skill.description_for_locale("ja"), "日本語の説明");
493 assert_eq!(skill.description_for_locale("fr"), "English description"); // fallback
494 assert_eq!(skill.description_for_locale("en"), "English description");
495
496 // Traditional Chinese must NOT borrow the Simplified `zh` description:
497 // with no exact zh-hant key authored, it falls back to the default.
498 assert_eq!(
499 skill.description_for_locale("zh-Hant"),
500 "English description"
501 );
502 assert_eq!(skill.description_for_locale("zh-TW"), "English description");
503 assert_eq!(skill.description_for_locale("zh-HK"), "English description");
504 }
505
506 #[test]
507 fn description_for_locale_uses_exact_traditional_key_when_authored() {
508 let mut localized = std::collections::HashMap::new();
509 localized.insert("zh".to_string(), "简体描述".to_string());
510 localized.insert("zh-hant".to_string(), "繁體描述".to_string());
511 let skill = super::Skill {
512 name: "demo".to_string(),
513 description: "English".to_string(),
514 localized_descriptions: localized,
515 invocation: super::SkillInvocation::ModelAndUser,
516 aliases: Vec::new(),
517 body: String::new(),
518 path: std::path::PathBuf::new(),
519 source: super::SkillSource::Native,
520 };
521 // Exact Traditional key wins for a Traditional session.
522 assert_eq!(skill.description_for_locale("zh-Hant"), "繁體描述");
523 // Simplified session still gets the Simplified description.
524 assert_eq!(skill.description_for_locale("zh-Hans"), "简体描述");
525 assert_eq!(skill.description_for_locale("zh"), "简体描述");
526 }
527
528 #[test]
529 fn description_for_locale_uses_default_when_no_localized_variants() {
530 let skill = super::Skill {
531 name: "demo".to_string(),
532 description: "only english".to_string(),
533 localized_descriptions: std::collections::HashMap::new(),
534 invocation: super::SkillInvocation::ModelAndUser,
535 aliases: Vec::new(),
536 body: String::new(),
537 path: std::path::PathBuf::new(),
538 source: super::SkillSource::Native,
539 };
540 assert_eq!(skill.description_for_locale("zh"), "only english");
541 }
542
543 #[test]
544 fn render_skills_block_selects_description_by_locale() {
545 let mut registry = super::SkillRegistry::default();
546 let mut localized = std::collections::HashMap::new();
547 localized.insert("zh".to_string(), "压缩日志的技能".to_string());
548 registry.skills.push(super::Skill {
549 name: "compress".to_string(),
550 description: "Compress logs to save space".to_string(),
551 localized_descriptions: localized,
552 invocation: super::SkillInvocation::ModelAndUser,
553 aliases: Vec::new(),
554 body: "body".to_string(),
555 path: std::path::PathBuf::from("/skills/compress/SKILL.md"),
556 source: super::SkillSource::Native,
557 });
558
559 let zh = super::render_skills_block(&registry, "zh-Hans", std::path::Path::new("/"))
560 .expect("zh block");
561 assert!(
562 zh.contains("压缩日志的技能"),
563 "zh session should get the zh description:\n{zh}"
564 );
565 assert!(!zh.contains("Compress logs to save space"));
566
567 let en =
568 super::render_skills_block(&registry, "en", std::path::Path::new("/")).expect("en block");
569 assert!(
570 en.contains("Compress logs to save space"),
571 "en session keeps default:\n{en}"
572 );
573 }
574
575 fn write_skill(dir: &std::path::Path, name: &str, description: &str, body: &str) {
576 let skill_dir = dir.join(name);
577 std::fs::create_dir_all(&skill_dir).unwrap();
578 std::fs::write(
579 skill_dir.join("SKILL.md"),
580 format!("---\nname: {name}\ndescription: {description}\n---\n{body}\n"),
581 )
582 .unwrap();
583 }
584
585 #[cfg(unix)]
586 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
587 std::os::unix::fs::symlink(target, link)
588 }
589
590 #[cfg(windows)]
591 fn create_dir_symlink(target: &std::path::Path, link: &std::path::Path) -> std::io::Result<()> {
592 std::os::windows::fs::symlink_dir(target, link)
593 }
594
595 #[test]
596 fn skills_directories_returns_existing_dirs_in_precedence_order() {
597 let tmpdir = TempDir::new().unwrap();
598 let workspace = tmpdir.path();
599
600 // Create four of the five workspace candidate dirs (skip `.opencode`).
601 std::fs::create_dir_all(workspace.join(".agents").join("skills")).unwrap();
602 std::fs::create_dir_all(workspace.join("skills")).unwrap();
603 std::fs::create_dir_all(workspace.join(".claude").join("skills")).unwrap();
604 std::fs::create_dir_all(workspace.join(".cursor").join("skills")).unwrap();
605
606 let dirs = super::skills_directories_for_mode(workspace, super::SkillDiscoveryMode::Compatible);
607 // We don't assert on the global default position because it's
608 // host-dependent (may not exist on the test machine).
609 let mut idx = 0;
610 let agents = workspace.join(".agents").join("skills");
611 let local = workspace.join("skills");
612 let claude = workspace.join(".claude").join("skills");
613 let cursor = workspace.join(".cursor").join("skills");
614
615 assert_eq!(dirs.get(idx), Some(&agents), "agents must come first");
616 idx += 1;
617 assert_eq!(dirs.get(idx), Some(&local), "local must come second");
618 idx += 1;
619 // .opencode/skills was not created — it must NOT appear.
620 assert!(
621 !dirs
622 .iter()
623 .any(|p| p == &workspace.join(".opencode").join("skills")),
624 "missing dir must be omitted, got: {dirs:?}"
625 );
626 assert_eq!(dirs.get(idx), Some(&claude), "claude must come after local");
627 idx += 1;
628 assert_eq!(
629 dirs.get(idx),
630 Some(&cursor),
631 "cursor must come after claude"
632 );
633 }
634
635 #[test]
636 fn existing_skill_dirs_orders_globals_agents_then_claude_then_deepseek() {
637 // Pins the precedence among the three global skill roots (#902).
638 // Workspace candidates are tested separately above; here we only
639 // exercise the global ordering at the existing_skill_dirs level
640 // so the assertion is host-independent.
641 let tmpdir = TempDir::new().unwrap();
642 let agents_global = tmpdir.path().join(".agents").join("skills");
643 let claude_global = tmpdir.path().join(".claude").join("skills");
644 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
645 std::fs::create_dir_all(&agents_global).unwrap();
646 std::fs::create_dir_all(&claude_global).unwrap();
647 std::fs::create_dir_all(&deepseek_global).unwrap();
648
649 let dirs = super::existing_skill_dirs(vec![
650 agents_global.clone(),
651 claude_global.clone(),
652 deepseek_global.clone(),
653 ]);
654
655 assert_eq!(dirs, vec![agents_global, claude_global, deepseek_global]);
656 }
657
658 #[test]
659 fn existing_skill_dirs_keeps_agents_global_before_deepseek_global() {
660 let tmpdir = TempDir::new().unwrap();
661 let agents_global = tmpdir.path().join(".agents").join("skills");
662 let deepseek_global = tmpdir.path().join(".deepseek").join("skills");
663 let missing = tmpdir.path().join("missing").join("skills");
664 std::fs::create_dir_all(&agents_global).unwrap();
665 std::fs::create_dir_all(&deepseek_global).unwrap();
666
667 let dirs = super::existing_skill_dirs(vec![
668 missing,
669 agents_global.clone(),
670 deepseek_global.clone(),
671 agents_global.clone(),
672 ]);
673
674 assert_eq!(dirs, vec![agents_global, deepseek_global]);
675 }
676
677 #[test]
678 fn discover_in_workspace_merges_with_first_wins_precedence() {
679 let tmpdir = TempDir::new().unwrap();
680 let workspace = tmpdir.path();
681
682 // Same skill name `shared` in two locations — the higher-precedence
683 // dir's version should win.
684 write_skill(
685 &workspace.join(".agents").join("skills"),
686 "shared",
687 "agents wins",
688 "from agents",
689 );
690 write_skill(
691 &workspace.join(".claude").join("skills"),
692 "shared",
693 "claude loses",
694 "from claude",
695 );
696 // Unique skill in claude — should still be discovered.
697 write_skill(
698 &workspace.join(".claude").join("skills"),
699 "unique-claude",
700 "only here",
701 "claude-only",
702 );
703
704 let registry = super::discover_in_workspace(workspace);
705 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
706 assert!(
707 names.contains(&"shared"),
708 "shared must be present: {names:?}"
709 );
710 assert!(names.contains(&"unique-claude"));
711
712 let shared = registry.get("shared").expect("shared present");
713 assert_eq!(
714 shared.description, "agents wins",
715 "first-wins precedence should keep .agents/skills version"
716 );
717 assert!(
718 shared.path.starts_with(workspace.join(".agents")),
719 "shared.path should be from .agents/skills, got {:?}",
720 shared.path
721 );
722 assert!(
723 registry
724 .warnings()
725 .iter()
726 .any(|warning| warning.contains("shared") && warning.contains("shadowed by")),
727 "duplicate shadowing should warn, got {:?}",
728 registry.warnings()
729 );
730 }
731
732 #[test]
733 fn same_root_slug_collision_warns_and_keeps_one() {
734 let tmpdir = TempDir::new().unwrap();
735 let root = tmpdir.path();
736 // Two sibling directories under one root whose frontmatter names
737 // slugify to the same command name ("my-skill"). Only one can be
738 // reachable by name; the other must warn rather than silently coexist
739 // as an unreachable duplicate (#3919 same-root gap).
740 write_skill(root, "My Skill", "first", "body");
741 write_skill(root, "my_skill", "second", "body");
742
743 let registry = super::SkillRegistry::discover(root);
744 let claimants = registry
745 .list()
746 .iter()
747 .filter(|s| s.name == "my-skill")
748 .count();
749 assert_eq!(
750 claimants,
751 1,
752 "exactly one skill should claim `my-skill`, got {:?}",
753 registry.list().iter().map(|s| &s.name).collect::<Vec<_>>()
754 );
755 assert!(
756 registry
757 .warnings()
758 .iter()
759 .any(|w| w.contains("my-skill") && w.contains("shadowed by")),
760 "same-root slug collision should warn, got {:?}",
761 registry.warnings()
762 );
763 }
764
765 #[test]
766 fn discover_in_workspace_pulls_skills_from_opencode_dir() {
767 let tmpdir = TempDir::new().unwrap();
768 let workspace = tmpdir.path();
769 write_skill(
770 &workspace.join(".opencode").join("skills"),
771 "opencode-only",
772 "for interop",
773 "body",
774 );
775
776 let registry = super::discover_in_workspace(workspace);
777 assert!(
778 registry.get("opencode-only").is_some(),
779 ".opencode/skills must be scanned (#432)"
780 );
781 }
782
783 #[test]
784 fn discover_in_workspace_pulls_skills_from_cursor_dir() {
785 let tmpdir = TempDir::new().unwrap();
786 let workspace = tmpdir.path();
787 write_skill(
788 &workspace.join(".cursor").join("skills"),
789 "cursor-only",
790 "for cursor interop",
791 "body",
792 );
793
794 let registry = super::discover_in_workspace(workspace);
795 assert!(
796 registry.get("cursor-only").is_some(),
797 ".cursor/skills must be scanned"
798 );
799 }
800
801 #[test]
802 fn discover_accepts_plain_markdown_heading_without_frontmatter() {
803 let tmpdir = TempDir::new().unwrap();
804 let skill_dir = tmpdir.path().join("plain-skill");
805 std::fs::create_dir_all(&skill_dir).unwrap();
806 std::fs::write(
807 skill_dir.join("SKILL.md"),
808 "# Plain Skill\n\nUse this skill without YAML frontmatter.\n",
809 )
810 .unwrap();
811
812 let registry = super::SkillRegistry::discover(tmpdir.path());
813 let skill = registry.get("plain-skill").expect("plain skill parsed");
814 assert_eq!(skill.name, "plain-skill");
815 assert_eq!(skill.description, "");
816 assert!(skill.body.contains("Use this skill"));
817 assert!(
818 registry
819 .warnings()
820 .iter()
821 .any(|warning| warning.contains("using `plain-skill` instead")),
822 "expected slug warning, got {:?}",
823 registry.warnings()
824 );
825 }
826
827 #[test]
828 fn discover_slugifies_invalid_frontmatter_names_and_lookup_normalizes() {
829 let tmpdir = TempDir::new().unwrap();
830 let root = tmpdir.path().join("skills");
831 let skill_dir = root.join("my-skill");
832 std::fs::create_dir_all(&skill_dir).unwrap();
833 std::fs::write(
834 skill_dir.join("SKILL.md"),
835 "---\nname: My Skill\ndescription: spaced name\n---\nbody",
836 )
837 .unwrap();
838
839 let registry = super::SkillRegistry::discover(&root);
840 let skill = registry.get(" MY skill ").expect("normalized lookup");
841 assert_eq!(skill.name, "my-skill");
842 assert!(
843 registry
844 .warnings()
845 .iter()
846 .any(|warning| warning.contains("My Skill")
847 && warning.contains("using `my-skill` instead")),
848 "expected invalid-name warning, got {:?}",
849 registry.warnings()
850 );
851 }
852
853 #[test]
854 fn discover_warns_for_plain_markdown_without_heading() {
855 let tmpdir = TempDir::new().unwrap();
856 let skill_dir = tmpdir.path().join("plain-skill");
857 std::fs::create_dir_all(&skill_dir).unwrap();
858 std::fs::write(
859 skill_dir.join("SKILL.md"),
860 "Use this skill without a heading or YAML frontmatter.\n",
861 )
862 .unwrap();
863
864 let registry = super::SkillRegistry::discover(tmpdir.path());
865 assert!(registry.is_empty());
866 assert!(
867 registry
868 .warnings()
869 .iter()
870 .any(|warning| warning.contains("no `# Heading` found")),
871 "expected missing-heading warning, got {:?}",
872 registry.warnings()
873 );
874 }
875
876 #[test]
877 fn render_available_skills_context_for_workspace_picks_up_cross_tool_dirs() {
878 let tmpdir = TempDir::new().unwrap();
879 let workspace = tmpdir.path();
880 write_skill(
881 &workspace.join(".claude").join("skills"),
882 "from-claude",
883 "claude-style skill",
884 "body",
885 );
886 let rendered =
887 super::render_available_skills_context_for_workspace(workspace).expect("non-empty");
888 assert!(rendered.contains("from-claude"));
889 }
890
891 #[test]
892 fn codewhale_only_mode_ignores_cross_tool_skill_dirs() {
893 let tmpdir = TempDir::new().unwrap();
894 let workspace = tmpdir.path().join("workspace");
895 let home = tmpdir.path().join("home");
896 let configured_dir = home.join(".codewhale").join("skills");
897 std::fs::create_dir_all(&workspace).unwrap();
898 write_skill(
899 &workspace.join(".claude").join("skills"),
900 "from-claude",
901 "claude-style skill",
902 "body",
903 );
904 write_skill(
905 &workspace.join(".codewhale").join("skills"),
906 "from-codewhale",
907 "codewhale skill",
908 "body",
909 );
910 write_skill(
911 &home.join(".agents").join("skills"),
912 "from-agents",
913 "agents skill",
914 "body",
915 );
916 write_skill(
917 &configured_dir,
918 "configured-codewhale",
919 "configured skill",
920 "body",
921 );
922
923 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
924 &workspace,
925 &configured_dir,
926 Some(&home),
927 super::SkillDiscoveryMode::CodeWhaleOnly,
928 );
929 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
930
931 assert!(names.contains(&"from-codewhale"));
932 assert!(names.contains(&"configured-codewhale"));
933 assert!(
934 !names.contains(&"from-claude") && !names.contains(&"from-agents"),
935 "CodeWhale-only mode must not import cross-tool skills: {names:?}"
936 );
937 }
938
939 #[test]
940 fn codewhale_only_mode_still_honors_explicit_configured_dir() {
941 let tmpdir = TempDir::new().unwrap();
942 let workspace = tmpdir.path().join("workspace");
943 let home = tmpdir.path().join("home");
944 let configured_dir = tmpdir.path().join("my-skills");
945 std::fs::create_dir_all(&workspace).unwrap();
946 write_skill(
947 &configured_dir,
948 "configured-skill",
949 "explicit configured skill",
950 "body",
951 );
952
953 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
954 &workspace,
955 &configured_dir,
956 Some(&home),
957 super::SkillDiscoveryMode::CodeWhaleOnly,
958 );
959 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
960
961 assert_eq!(names, vec!["configured-skill"]);
962 }
963
964 #[test]
965 fn codewhale_only_mode_rejects_workspace_codewhale_symlink_escape() {
966 let tmpdir = TempDir::new().unwrap();
967 let workspace = tmpdir.path().join("workspace");
968 let home = tmpdir.path().join("home");
969 let escape_target = tmpdir.path().join("escape-target");
970 std::fs::create_dir_all(workspace.join(".codewhale")).unwrap();
971 write_skill(&escape_target, "escaped-skill", "escaped skill", "body");
972
973 let link_path = workspace.join(".codewhale").join("skills");
974 if let Err(err) = create_dir_symlink(&escape_target, &link_path) {
975 eprintln!("skipping symlink escape assertion: {err}");
976 return;
977 }
978
979 let registry = super::discover_for_workspace_and_dir_with_home_and_mode(
980 &workspace,
981 &tmpdir.path().join("missing-configured-skills"),
982 Some(&home),
983 super::SkillDiscoveryMode::CodeWhaleOnly,
984 );
985
986 assert!(
987 registry.get("escaped-skill").is_none(),
988 "CodeWhale-only mode must not follow workspace .codewhale/skills outside the workspace"
989 );
990 }
991
992 #[test]
993 fn discover_for_workspace_and_dir_merges_workspace_and_configured_sources() {
994 let tmpdir = TempDir::new().unwrap();
995 let workspace = tmpdir.path().join("workspace");
996 let home = tmpdir.path().join("home");
997 let configured_dir = tmpdir.path().join("configured-skills");
998 std::fs::create_dir_all(&workspace).unwrap();
999 write_skill(
1000 &workspace.join(".claude").join("skills"),
1001 "workspace-skill",
1002 "workspace visible skill",
1003 "body",
1004 );
1005 write_skill(
1006 &configured_dir,
1007 "configured-skill",
1008 "configured visible skill",
1009 "body",
1010 );
1011
1012 let registry =
1013 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1014 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1015
1016 assert!(names.contains(&"workspace-skill"));
1017 assert!(names.contains(&"configured-skill"));
1018 }
1019
1020 #[test]
1021 fn explicit_configured_skills_dir_precedes_global_defaults() {
1022 let tmpdir = TempDir::new().unwrap();
1023 let workspace = tmpdir.path().join("workspace");
1024 let home = tmpdir.path().join("home");
1025 let configured_dir = tmpdir.path().join("configured-skills");
1026 std::fs::create_dir_all(&workspace).unwrap();
1027 write_skill(
1028 &home.join(".agents").join("skills"),
1029 "shared-skill",
1030 "global skill",
1031 "global body",
1032 );
1033 write_skill(
1034 &configured_dir,
1035 "shared-skill",
1036 "configured skill",
1037 "configured body",
1038 );
1039
1040 let registry =
1041 super::discover_for_workspace_and_dir_with_home(&workspace, &configured_dir, Some(&home));
1042 let skill = registry
1043 .get("shared-skill")
1044 .expect("shared skill discovered");
1045
1046 assert_eq!(skill.description, "configured skill");
1047 }
1048
1049 /// Regression for the GitHub issue where users organize skills under
1050 /// vendor / category subdirectories (e.g. cloned skill repos that
1051 /// bundle several skills together). The old single-level `read_dir`
1052 /// only ever surfaced `<root>/<skill>/SKILL.md` and silently ignored
1053 /// `<root>/<vendor>/<skill>/SKILL.md`.
1054 #[test]
1055 fn discover_finds_skills_nested_under_vendor_subdirectory() {
1056 let tmpdir = TempDir::new().unwrap();
1057 let root = tmpdir.path().join("skills");
1058
1059 // Two-level nesting: `<root>/<vendor>/<skill>/SKILL.md`. This
1060 // matches the `clawhub-skills/clawhub/SKILL.md` layout in the
1061 // bug report.
1062 write_skill(
1063 &root.join("clawhub-skills"),
1064 "clawhub",
1065 "claw search",
1066 "body",
1067 );
1068 write_skill(
1069 &root.join("clawhub-skills"),
1070 "github",
1071 "github helpers",
1072 "body",
1073 );
1074 // Three-level nesting: `<root>/<org>/<repo>/<skill>/SKILL.md`.
1075 write_skill(
1076 &root.join("pasky").join("chrome-cdp-skill"),
1077 "chrome-cdp",
1078 "browser automation",
1079 "body",
1080 );
1081 // Mixed-depth: a flat skill alongside the nested layout still
1082 // works (this is what the bundled `skill-creator` looks like).
1083 write_skill(&root, "skill-creator", "make skills", "body");
1084
1085 let registry = super::SkillRegistry::discover(&root);
1086 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1087 assert!(names.contains(&"clawhub"), "vendor/skill missed: {names:?}");
1088 assert!(names.contains(&"github"), "vendor/skill missed: {names:?}");
1089 assert!(
1090 names.contains(&"chrome-cdp"),
1091 "deeply-nested skill missed: {names:?}"
1092 );
1093 assert!(
1094 names.contains(&"skill-creator"),
1095 "flat top-level skill must still load: {names:?}"
1096 );
1097 assert!(
1098 registry.warnings().is_empty(),
1099 "well-formed nested layout should not warn: {:?}",
1100 registry.warnings()
1101 );
1102 }
1103
1104 #[cfg(any(unix, windows))]
1105 #[test]
1106 fn discover_follows_symlinked_skill_directories() {
1107 let tmpdir = TempDir::new().unwrap();
1108 let source_root = tmpdir.path().join("claude-skills");
1109 let skills_root = tmpdir.path().join(".deepseek").join("skills");
1110 write_skill(&source_root, "agent-browser", "browser automation", "body");
1111 std::fs::create_dir_all(&skills_root).unwrap();
1112 let link_path = skills_root.join("agent-browser");
1113
1114 if let Err(err) = create_dir_symlink(&source_root.join("agent-browser"), &link_path) {
1115 eprintln!("skipping symlink discovery assertion: {err}");
1116 return;
1117 }
1118
1119 let registry = super::SkillRegistry::discover(&skills_root);
1120 let skill = registry
1121 .get("agent-browser")
1122 .expect("symlinked skill directory should be discovered");
1123 assert_eq!(skill.description, "browser automation");
1124 assert_eq!(skill.path, link_path.join("SKILL.md"));
1125 }
1126
1127 #[cfg(any(unix, windows))]
1128 #[test]
1129 fn discover_dedupes_symlink_cycles_by_canonical_directory() {
1130 let tmpdir = TempDir::new().unwrap();
1131 let root = tmpdir.path().join("skills");
1132 write_skill(&root, "real-skill", "ok", "body");
1133 let loop_parent = root.join("vendor");
1134 std::fs::create_dir_all(&loop_parent).unwrap();
1135
1136 if let Err(err) = create_dir_symlink(&root, &loop_parent.join("loop")) {
1137 eprintln!("skipping symlink cycle assertion: {err}");
1138 return;
1139 }
1140
1141 let registry = super::SkillRegistry::discover(&root);
1142 let matches = registry
1143 .list()
1144 .iter()
1145 .filter(|skill| skill.name == "real-skill")
1146 .count();
1147 assert_eq!(
1148 matches, 1,
1149 "symlink cycle should not rediscover the same canonical skill directory"
1150 );
1151 }
1152
1153 /// Once a directory is identified as a skill (has `SKILL.md`), the
1154 /// walker must NOT descend into it: any nested `SKILL.md` would be
1155 /// a fixture / example bundled with the parent skill, not a
1156 /// separately-installable one. This mirrors the contract that
1157 /// `tools::skill::collect_companion_files` already documents
1158 /// ("nested directory — skipped").
1159 #[test]
1160 fn discover_does_not_descend_into_a_skill_directory() {
1161 let tmpdir = TempDir::new().unwrap();
1162 let root = tmpdir.path().join("skills");
1163
1164 // Parent skill: <root>/parent/SKILL.md.
1165 write_skill(&root, "parent", "outer skill", "outer body");
1166 // Fixture bundled inside the parent's directory:
1167 // <root>/parent/examples/inner-fixture/SKILL.md. The walker
1168 // must NOT descend into <root>/parent/ after finding its
1169 // SKILL.md, so `inner-fixture` must not be loaded.
1170 write_skill(
1171 &root.join("parent").join("examples"),
1172 "inner-fixture",
1173 "should not load",
1174 "fixture body",
1175 );
1176
1177 let registry = super::SkillRegistry::discover(&root);
1178 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1179 assert!(names.contains(&"parent"));
1180 assert!(
1181 !names.contains(&"inner-fixture"),
1182 "nested SKILL.md inside an existing skill must be ignored: {names:?}"
1183 );
1184 }
1185
1186 /// Hidden subdirectories below the root (e.g. `.git`, `.cache`) must
1187 /// be skipped so a `skills_dir` that lives inside a checked-out repo
1188 /// doesn't accidentally load random `SKILL.md`-named fixtures from
1189 /// the VCS metadata. The root itself is exempt — the user explicitly
1190 /// pointed `skills_dir` at it.
1191 #[test]
1192 fn discover_skips_hidden_subdirectories_below_root() {
1193 let tmpdir = TempDir::new().unwrap();
1194 let root = tmpdir.path().join("skills");
1195
1196 write_skill(&root, "real-skill", "ok", "body");
1197 // A `<root>/.git/<junk>/SKILL.md` lookalike that mustn't load.
1198 // `.git` is a direct child of the user-provided root (depth 0
1199 // of the walk), which is exactly the case the old `depth > 0`
1200 // gate missed.
1201 write_skill(&root.join(".git"), "vcs-noise", "should not load", "body");
1202
1203 let registry = super::SkillRegistry::discover(&root);
1204 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1205 assert!(names.contains(&"real-skill"));
1206 assert!(
1207 !names.contains(&"vcs-noise"),
1208 "skills under hidden subdirs must be skipped: {names:?}"
1209 );
1210 }
1211
1212 /// The user explicitly chooses the root, so even a hidden path like
1213 /// `~/.agents/skills` (the layout in the bug report) must work.
1214 #[test]
1215 fn discover_honors_a_hidden_root_directory() {
1216 let tmpdir = TempDir::new().unwrap();
1217 let root = tmpdir.path().join(".agents").join("skills");
1218
1219 // Matches the bug report: skills_dir = "~/.agents/skills"
1220 // with a skill nested at <root>/custom-skills/git-conventions/SKILL.md.
1221 write_skill(
1222 &root.join("custom-skills"),
1223 "git-conventions",
1224 "conventions",
1225 "body",
1226 );
1227
1228 let registry = super::SkillRegistry::discover(&root);
1229 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1230 assert!(
1231 names.contains(&"git-conventions"),
1232 "hidden root must still be walked: {names:?}"
1233 );
1234 }
1235
1236 /// Mirrors the qa_pty `skills_menu_shows_local_and_global_skills`
1237 /// scenario without the PTY harness: a workspace-level skill in
1238 /// `.agents/skills/` and a global skill in `~/.codewhale/skills/`
1239 /// must both be discoverable.
1240 #[test]
1241 fn discover_finds_both_workspace_and_global_skills() {
1242 let tmpdir = TempDir::new().unwrap();
1243 let workspace = tmpdir.path().join("workspace");
1244 let home = tmpdir.path().join("home");
1245 std::fs::create_dir_all(&workspace).unwrap();
1246
1247 write_skill(
1248 &workspace.join(".agents").join("skills"),
1249 "workspace-beta",
1250 "Workspace beta skill",
1251 "body",
1252 );
1253 write_skill(
1254 &home.join(".codewhale").join("skills"),
1255 "global-alpha",
1256 "Global alpha skill",
1257 "body",
1258 );
1259
1260 let skills_dir = workspace.join(".agents").join("skills");
1261 let registry =
1262 super::discover_for_workspace_and_dir_with_home(&workspace, &skills_dir, Some(&home));
1263
1264 let names: Vec<&str> = registry.list().iter().map(|s| s.name.as_str()).collect();
1265 assert!(
1266 names.contains(&"workspace-beta"),
1267 "workspace-beta from .agents/skills must be discovered: {names:?}",
1268 );
1269 assert!(
1270 names.contains(&"global-alpha"),
1271 "global-alpha from ~/.codewhale/skills must be discovered: {names:?}",
1272 );
1273 }
1274
1275 // ── Block scalar parsing (YAML `>` and `|`) ────────────────
1276
1277 /// `>` (folded block scalar): subsequent indented lines are folded
1278 /// into a single line joined by spaces.
1279 #[test]
1280 fn parse_skill_folded_block_scalar() {
1281 let tmpdir = TempDir::new().unwrap();
1282 create_skill_dir(
1283 &tmpdir,
1284 "folded-skill",
1285 "---\nname: folded-skill\ndescription: >\n line one chinese\n line two chinese\n---\nbody",
1286 );
1287 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1288 .expect("skill context");
1289 assert!(
1290 rendered.contains("line one chinese line two chinese"),
1291 "folded block scalar should join lines with space, got:\n{rendered}"
1292 );
1293 }
1294
1295 /// `|` (literal block scalar): subsequent indented lines preserve
1296 /// newlines.
1297 #[test]
1298 fn parse_skill_literal_block_scalar() {
1299 let tmpdir = TempDir::new().unwrap();
1300 create_skill_dir(
1301 &tmpdir,
1302 "literal-skill",
1303 "---\nname: literal-skill\ndescription: |\n line one\n line two\n---\nbody",
1304 );
1305 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1306 .expect("skill context");
1307 // `truncate_for_prompt` collapses whitespace, so the newlines
1308 // become spaces. The key assertion is that the content is
1309 // captured (not just `|`).
1310 assert!(
1311 rendered.contains("line one line two"),
1312 "literal block scalar should preserve content, got:\n{rendered}"
1313 );
1314 }
1315
1316 /// `>-` (folded with strip chomping): same as `>` but trailing
1317 /// whitespace is stripped.
1318 #[test]
1319 fn parse_skill_folded_strip_block_scalar() {
1320 let tmpdir = TempDir::new().unwrap();
1321 create_skill_dir(
1322 &tmpdir,
1323 "strip-skill",
1324 "---\nname: strip-skill\ndescription: >-\n alpha\n beta\n\n---\nbody",
1325 );
1326 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1327 .expect("skill context");
1328 assert!(
1329 rendered.contains("alpha beta"),
1330 "strip-chomped folded block should join lines, got:\n{rendered}"
1331 );
1332 }
1333
1334 /// Regression: a single-line description (no block scalar) must
1335 /// still parse correctly after the parser rewrite.
1336 #[test]
1337 fn parse_skill_single_line_description_still_works() {
1338 let tmpdir = TempDir::new().unwrap();
1339 create_skill_dir(
1340 &tmpdir,
1341 "plain-skill",
1342 "---\nname: plain-skill\ndescription: A simple description\n---\nbody",
1343 );
1344 let rendered = crate::skills::render_available_skills_context(&tmpdir.path().join("skills"))
1345 .expect("skill context");
1346 assert!(
1347 rendered.contains("- plain-skill: A simple description"),
1348 "single-line description should still work, got:\n{rendered}"
1349 );
1350 }
1351
1352 /// Direct unit test on the parsed Skill struct (not through rendering)
1353 /// so we assert the exact description value.
1354 #[test]
1355 fn parse_skill_direct_folded_result() {
1356 let skill = super::SkillRegistry::parse_skill(
1357 std::path::Path::new(""),
1358 "---\nname: test\ndescription: >\n this is a test\n used to verify parsing\n---\nbody",
1359 )
1360 .expect("should parse");
1361 assert_eq!(skill.name, "test");
1362 assert_eq!(skill.description, "this is a test used to verify parsing");
1363 }
1364
1365 // ── Chomping behaviour ────────────────────────────────────
1366
1367 /// `>-` (strip): trailing empty lines are stripped. Paragraph
1368 /// breaks (empty line between text lines) are still folded to a
1369 /// single space in a block-scalar join (no newline — the simplified
1370 /// parser treats intra-block empty lines as paragraph breaks that
1371 /// become a single space in the folded output).
1372 #[test]
1373 fn parse_skill_strip_chomp_strips_trailing_empties() {
1374 let skill = super::SkillRegistry::parse_skill(
1375 std::path::Path::new(""),
1376 "---\nname: s\ndescription: >-\n hello\n world\n\n\n---\nbody",
1377 )
1378 .expect("should parse");
1379 // Trailing empty lines stripped: no whitespace at end, just folded text.
1380 assert_eq!(skill.description, "hello world");
1381 }
1382
1383 /// `>+` (keep): trailing empty lines are preserved. Each trailing
1384 /// empty line in the block becomes a newline in the description.
1385 #[test]
1386 fn parse_skill_keep_chomp_preserves_trailing_empties() {
1387 let skill = super::SkillRegistry::parse_skill(
1388 std::path::Path::new(""),
1389 "---\nname: s\ndescription: >+\n hello\n world\n\n\n---\nbody",
1390 )
1391 .expect("should parse");
1392 // Two trailing empty lines should become two newlines.
1393 assert_eq!(skill.description, "hello world\n\n");
1394 }
1395
1396 /// `>` (clip): trailing empty lines exceeding one are clipped.
1397 /// The result should have at most one trailing newline.
1398 #[test]
1399 fn parse_skill_clip_chomp_clips_excess_trailing_empties() {
1400 let skill = super::SkillRegistry::parse_skill(
1401 std::path::Path::new(""),
1402 "---\nname: s\ndescription: >\n hello\n world\n\n\n---\nbody",
1403 )
1404 .expect("should parse");
1405 // clip: 3 trailing empty lines → at most 1 trailing newline.
1406 assert_eq!(skill.description, "hello world\n");
1407 }
1408
1409 /// `>` with no trailing empty lines: clip should not add anything.
1410 #[test]
1411 fn parse_skill_clip_chomp_no_trailing_empties() {
1412 let skill = super::SkillRegistry::parse_skill(
1413 std::path::Path::new(""),
1414 "---\nname: s\ndescription: >\n hello\n world\n---\nbody",
1415 )
1416 .expect("should parse");
1417 assert_eq!(skill.description, "hello world");
1418 }
1419
1420 /// `>` with exactly one trailing empty line: clip keeps it.
1421 #[test]
1422 fn parse_skill_clip_chomp_one_trailing_empty() {
1423 let skill = super::SkillRegistry::parse_skill(
1424 std::path::Path::new(""),
1425 "---\nname: s\ndescription: >\n hello\n world\n\n---\nbody",
1426 )
1427 .expect("should parse");
1428 assert_eq!(skill.description, "hello world\n");
1429 }
1430
1431 /// `>-` strip vs `>+` keep: same block content, different
1432 /// trailing newline handling.
1433 #[test]
1434 fn parse_skill_strip_vs_keep_trailing() {
1435 let content = "---\nname: s\ndescription: >{}\n hello\n world\n\n\n---\nbody";
1436 let strip_skill =
1437 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "-"))
1438 .expect("strip parse");
1439 let keep_skill =
1440 super::SkillRegistry::parse_skill(std::path::Path::new(""), &content.replace("{}", "+"))
1441 .expect("keep parse");
1442 // strip drops trailing empties; keep preserves them.
1443 assert_eq!(strip_skill.description, "hello world");
1444 assert_eq!(keep_skill.description, "hello world\n\n");
1445 }
1446
1447 /// `|-` literal strip: trailing newlines are stripped.
1448 #[test]
1449 fn parse_skill_literal_strip_strips_trailing_newlines() {
1450 let skill = super::SkillRegistry::parse_skill(
1451 std::path::Path::new(""),
1452 "---\nname: s\ndescription: |-\n line one\n line two\n\n\n---\nbody",
1453 )
1454 .expect("should parse");
1455 // literal: newlines preserved between non-empty lines.
1456 // strip: trailing empty lines removed.
1457 assert_eq!(skill.description, "line one\nline two");
1458 }
1459
1460 /// `|+` literal keep: trailing newlines are preserved.
1461 #[test]
1462 fn parse_skill_literal_keep_preserves_trailing_newlines() {
1463 let skill = super::SkillRegistry::parse_skill(
1464 std::path::Path::new(""),
1465 "---\nname: s\ndescription: |+\n line one\n line two\n\n\n---\nbody",
1466 )
1467 .expect("should parse");
1468 // literal: newlines preserved between non-empty lines.
1469 // keep: trailing empty lines are preserved as newlines.
1470 assert_eq!(skill.description, "line one\nline two\n\n");
1471 }
1472
1473 /// Nested relative indentation is preserved in literal (`|`) block
1474 /// scalars: only the content-level indent (from the first non-empty
1475 /// line) is stripped, and any deeper indent stays as-is.
1476 #[test]
1477 fn parse_skill_literal_preserves_relative_indentation() {
1478 let skill = super::SkillRegistry::parse_skill(
1479 std::path::Path::new(""),
1480 "---\nname: s\ndescription: |\n Usage:\n $ deepseek --model auto\n $ deepseek doctor\n---\nbody",
1481 )
1482 .expect("should parse");
1483 assert_eq!(
1484 skill.description,
1485 "Usage:\n $ deepseek --model auto\n $ deepseek doctor"
1486 );
1487 }
1488
1489 /// Folded (`>`) block scalars also preserve relative indentation
1490 /// within lines (the extra spaces survive the fold).
1491 #[test]
1492 fn parse_skill_folded_preserves_relative_indentation() {
1493 let skill = super::SkillRegistry::parse_skill(
1494 std::path::Path::new(""),
1495 "---\nname: s\ndescription: >\n See also:\n the config file\n the env var\n---\nbody",
1496 )
1497 .expect("should parse");
1498 assert_eq!(
1499 skill.description,
1500 "See also: the config file the env var"
1501 );
1502 }
1503
1504 #[test]
1505 fn plugin_skills_are_qualified_and_denied_until_trusted_and_enabled() {
1506 let tmp = TempDir::new().unwrap();
1507 let plugin_root = tmp.path().join("plugins/demo");
1508 std::fs::create_dir_all(plugin_root.join("skills/hello-world")).unwrap();
1509 std::fs::write(
1510 plugin_root.join("plugin.toml"),
1511 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\n[skills]\npath = \"skills\"\n",
1512 )
1513 .unwrap();
1514 std::fs::write(
1515 plugin_root.join("skills/hello-world/SKILL.md"),
1516 "---\nname: hello-world\ndescription: hello\n---\nbody\n",
1517 )
1518 .unwrap();
1519 let config = crate::plugins::discovery::DiscoveryConfig {
1520 workspace: tmp.path().join("workspace"),
1521 user_plugins_dir: tmp.path().join("plugins"),
1522 workspace_plugins_dir: tmp.path().join("workspace-plugins"),
1523 builtin_plugin_dirs: Vec::new(),
1524 state_path: tmp.path().join("plugin-state/state.json"),
1525 };
1526 let mut plugins = crate::plugins::discovery::discover_with_config(&config);
1527
1528 let mut registry = super::SkillRegistry::default();
1529 super::merge_active_plugin_skills(&mut registry, &plugins);
1530 assert!(registry.get("demo:hello-world").is_none());
1531
1532 plugins.trust("demo").unwrap();
1533 super::merge_active_plugin_skills(&mut registry, &plugins);
1534 assert!(registry.get("demo:hello-world").is_none());
1535
1536 plugins.enable("demo").unwrap();
1537 super::merge_active_plugin_skills(&mut registry, &plugins);
1538 let skill = registry
1539 .get("Demo:Hello_World")
1540 .expect("qualified lookup should normalize each namespace segment");
1541 assert_eq!(skill.name, "demo:hello-world");
1542 assert!(matches!(
1543 skill.source,
1544 super::SkillSource::Plugin { ref plugin_name, .. } if plugin_name == "demo"
1545 ));
1546 let rendered = super::render_skills_block(&registry, "en", tmp.path()).unwrap();
1547 assert!(rendered.contains("reviewed plugin snapshot: demo"));
1548 assert!(rendered.contains("use load_skill"));
1549 assert!(
1550 !rendered.contains(&plugin_root.display().to_string()),
1551 "model prompt must not expose mutable plugin files after snapshot review"
1552 );
1553
1554 let mut fail_closed_input = registry.clone();
1555 fail_closed_input.skills.push(super::Skill {
1556 name: "native-recovery".to_string(),
1557 description: "native recovery skill".to_string(),
1558 localized_descriptions: std::collections::HashMap::new(),
1559 invocation: super::SkillInvocation::ModelAndUser,
1560 aliases: Vec::new(),
1561 body: "recovery".to_string(),
1562 path: tmp.path().join("native/SKILL.md"),
1563 source: super::SkillSource::Native,
1564 });
1565 let fail_closed = fail_closed_input.into_enabled_with_state(Err(anyhow::anyhow!(
1566 "injected activation-state read failure"
1567 )));
1568 assert!(fail_closed.get("native-recovery").is_some());
1569 assert!(
1570 fail_closed.get("demo:hello-world").is_none(),
1571 "reviewed plugin Skills must not fail open when activation state is unreadable"
1572 );
1573 assert!(
1574 fail_closed
1575 .warnings()
1576 .iter()
1577 .any(|warning| warning.contains("hidden fail-closed"))
1578 );
1579
1580 std::fs::remove_file(config.state_path.with_file_name("state.json.lock")).unwrap();
1581 let mut denied = super::SkillRegistry::default();
1582 super::merge_active_plugin_skills(&mut denied, &plugins);
1583 assert!(
1584 denied.get("demo:hello-world").is_none(),
1585 "a missing authority lock must remove plugin instructions from the prompt catalogue"
1586 );
1587 }
1588
1589 // --- #3921 merged discovery cache -----------------------------------------
1590
1591 fn discovery_delta_since(earlier: super::SkillDiscoveryMetrics) -> super::SkillDiscoveryMetrics {
1592 super::discovery_metrics_snapshot().delta_since(earlier)
1593 }
1594
1595 #[test]
1596 fn cached_discovery_reuses_unchanged_registry_without_rewalking() {
1597 super::clear_skill_discovery_cache();
1598 let tmpdir = TempDir::new().unwrap();
1599 let skills_root = tmpdir.path().join("skills");
1600 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1601 let dirs = vec![skills_root];
1602
1603 super::reset_discovery_metrics();
1604 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1605 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1606 let second = super::discover_from_directories_with_plugins(dirs, None);
1607 let rewalked = discovery_delta_since(walked);
1608
1609 assert_eq!(walked.root_discovery_calls, 1);
1610 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
1611 assert_eq!(first.len(), second.len());
1612 assert_eq!(first.list()[0].description, second.list()[0].description);
1613 }
1614
1615 #[test]
1616 fn cached_discovery_picks_up_added_skill_on_next_call() {
1617 super::clear_skill_discovery_cache();
1618 let tmpdir = TempDir::new().unwrap();
1619 let skills_root = tmpdir.path().join("skills");
1620 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1621 let dirs = vec![skills_root.clone()];
1622
1623 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1624 assert_eq!(first.len(), 1);
1625
1626 write_skill(&skills_root, "added", "A later skill", "More");
1627 std::thread::sleep(std::time::Duration::from_millis(10));
1628 let second = super::discover_from_directories_with_plugins(dirs, None);
1629 assert_eq!(second.len(), 2);
1630 assert!(second.get("added").is_some());
1631 }
1632
1633 #[test]
1634 fn cached_discovery_picks_up_skill_content_edits() {
1635 super::clear_skill_discovery_cache();
1636 let tmpdir = TempDir::new().unwrap();
1637 let skills_root = tmpdir.path().join("skills");
1638 write_skill(&skills_root, "demo", "Original description", "Instructions");
1639 let dirs = vec![skills_root.clone()];
1640
1641 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1642 assert_eq!(first.list()[0].description, "Original description");
1643
1644 write_skill(&skills_root, "demo", "Edited description", "Instructions");
1645 std::thread::sleep(std::time::Duration::from_millis(10));
1646 let second = super::discover_from_directories_with_plugins(dirs, None);
1647 assert_eq!(second.list()[0].description, "Edited description");
1648 }
1649
1650 #[test]
1651 fn cached_discovery_drops_removed_skills() {
1652 super::clear_skill_discovery_cache();
1653 let tmpdir = TempDir::new().unwrap();
1654 let skills_root = tmpdir.path().join("skills");
1655 write_skill(&skills_root, "keep", "Keep me", "Instructions");
1656 write_skill(&skills_root, "drop", "Drop me", "Instructions");
1657 let dirs = vec![skills_root.clone()];
1658
1659 let first = super::discover_from_directories_with_plugins(dirs.clone(), None);
1660 assert_eq!(first.len(), 2);
1661
1662 std::fs::remove_dir_all(skills_root.join("drop")).unwrap();
1663 std::thread::sleep(std::time::Duration::from_millis(10));
1664 let second = super::discover_from_directories_with_plugins(dirs, None);
1665 assert_eq!(second.len(), 1);
1666 assert!(second.get("drop").is_none());
1667 }
1668
1669 #[test]
1670 fn clear_skill_discovery_cache_forces_a_fresh_walk() {
1671 super::clear_skill_discovery_cache();
1672 let tmpdir = TempDir::new().unwrap();
1673 let skills_root = tmpdir.path().join("skills");
1674 write_skill(&skills_root, "demo", "A demo skill", "Instructions");
1675 let dirs = vec![skills_root];
1676
1677 let _ = super::discover_from_directories_with_plugins(dirs.clone(), None);
1678 super::clear_skill_discovery_cache();
1679
1680 super::reset_discovery_metrics();
1681 let _ = super::discover_from_directories_with_plugins(dirs, None);
1682 let rewalked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1683 assert_eq!(rewalked.root_discovery_calls, 1);
1684 }
1685
1686 #[test]
1687 fn workspace_and_dir_entry_point_shares_the_same_cache() {
1688 let _env_lock = crate::test_support::lock_test_env();
1689 super::clear_skill_discovery_cache();
1690 let tmpdir = TempDir::new().unwrap();
1691 let home = tmpdir.path().join("home");
1692 let workspace = tmpdir.path().join("workspace");
1693 let skills_dir = tmpdir.path().join("configured-skills");
1694 std::fs::create_dir_all(&home).unwrap();
1695 std::fs::create_dir_all(&workspace).unwrap();
1696 write_skill(
1697 &skills_dir,
1698 "configured",
1699 "Configured skill",
1700 "Instructions",
1701 );
1702 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1703 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1704 let _codewhale_home =
1705 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale"));
1706
1707 super::reset_discovery_metrics();
1708 let first = super::discover_for_workspace_and_dir_with_mode_and_plugins(
1709 &workspace,
1710 &skills_dir,
1711 super::SkillDiscoveryMode::Compatible,
1712 None,
1713 );
1714 let walked = discovery_delta_since(super::SkillDiscoveryMetrics::default());
1715 let second = super::discover_for_workspace_and_dir_with_mode_and_plugins(
1716 &workspace,
1717 &skills_dir,
1718 super::SkillDiscoveryMode::Compatible,
1719 None,
1720 );
1721 let rewalked = discovery_delta_since(walked);
1722
1723 assert!(walked.root_discovery_calls >= 1);
1724 assert_eq!(rewalked, super::SkillDiscoveryMetrics::default());
1725 assert_eq!(first.len(), second.len());
1726 assert!(second.get("configured").is_some());
1727 }
1728
1729 #[test]
1730 fn global_skill_roots_come_from_the_os_home_only() {
1731 // §2.5: global skill roots resolve under the OS user's home (or an
1732 // explicit `$CODEWHALE_HOME`), never an account/GitHub handle. A wrong
1733 // home once produced `Failed to read /Users/<handle>/.codewhale/skills/
1734 // delegate/SKILL.md`; pin the source so every global root is provably
1735 // under the faked OS home.
1736 let _env_lock = crate::test_support::lock_test_env();
1737 let tmpdir = TempDir::new().unwrap();
1738 let home = tmpdir.path().join("os-home");
1739 let workspace = tmpdir.path().join("workspace");
1740 std::fs::create_dir_all(home.join(".codewhale").join("skills")).unwrap();
1741 std::fs::create_dir_all(&workspace).unwrap();
1742 let _home = crate::test_support::EnvVarGuard::set("HOME", &home);
1743 let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home);
1744 let _codewhale_home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
1745
1746 let dirs =
1747 super::skills_directories_for_mode(&workspace, super::SkillDiscoveryMode::Compatible);
1748
1749 assert!(
1750 dirs.iter().any(|dir| dir.starts_with(&home)),
1751 "expected at least one global root under the OS home: {dirs:?}"
1752 );
1753 assert!(
1754 dirs.iter()
1755 .all(|dir| dir.starts_with(&home) || dir.starts_with(&workspace)),
1756 "every runtime root is under the OS home or the workspace: {dirs:?}"
1757 );
1758 }
1759
1759 lines RUST