1use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Default)]
11pub enum SkillMode {
12 Always,
14 Trigger,
16 #[default]
18 Manual,
19 Globs,
23}
24
25#[derive(Debug, Clone)]
27pub struct Skill {
28 pub name: String,
30 pub description: String,
32 pub path: PathBuf,
34 pub mode: SkillMode,
36 pub triggers: Vec<String>,
38 pub hint: String,
41 pub depends_on: Vec<String>,
43 pub refs: Vec<SkillRef>,
45 pub params: Vec<SkillParam>,
47 pub scripts: Vec<SkillScript>,
49 pub sections: Vec<SkillSection>,
51 pub globs: Option<Vec<String>>,
55}
56
57#[derive(Debug, Clone)]
59pub struct SkillSection {
60 pub name: String,
62 pub content: String,
65}
66
67#[derive(Debug, Clone)]
69pub struct SkillRef {
70 pub name: String,
72 pub path: PathBuf,
74}
75
76#[derive(Debug, Clone)]
78pub struct SkillParam {
79 pub name: String,
81 pub description: String,
83 pub default: Option<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct SkillScript {
90 pub name: String,
92 pub path: PathBuf,
94 pub description: String,
96}
97
98pub fn discover_skills(search_paths: &[PathBuf]) -> Vec<Skill> {
108 let mut skills = Vec::new();
109
110 for base in search_paths {
111 if !base.is_dir() {
112 continue;
113 }
114
115 if let Ok(entries) = fs::read_dir(base) {
116 for entry in entries.flatten() {
117 let dir_path = entry.path();
118 if !dir_path.is_dir() {
119 continue;
120 }
121
122 let skill_file = dir_path.join("SKILL.md");
123 if !skill_file.is_file() {
124 continue;
125 }
126
127 if let Ok(content) = fs::read_to_string(&skill_file) {
128 let (name, description, mode, triggers, hint, depends_on, params, raw_globs) =
129 parse_skill_meta(&content, &dir_path);
130 let refs = discover_refs(&dir_path);
131 let scripts = discover_scripts(&dir_path);
132 let sections = parse_sections(&content);
133 let globs = if raw_globs.is_empty() {
134 None
135 } else {
136 Some(raw_globs)
137 };
138 skills.push(Skill {
139 name,
140 description,
141 path: skill_file,
142 mode,
143 triggers,
144 hint,
145 depends_on,
146 refs,
147 params,
148 scripts,
149 sections,
150 globs,
151 });
152 }
153 }
154 }
155 }
156
157 skills
158}
159
160fn parse_sections(content: &str) -> Vec<SkillSection> {
167 let body = extract_body(content);
168 let mut sections = Vec::new();
169 let mut lines = body.lines().peekable();
170
171 while let Some(line) = lines.next() {
172 if let Some(heading) = line.strip_prefix("## ") {
173 let name = heading.trim().to_string();
174 let mut section_lines = Vec::new();
175
176 while let Some(next) = lines.peek() {
178 if next.starts_with("## ") {
179 break;
180 }
181 #[allow(
182 clippy::unwrap_used,
183 reason = "peeked Some just above in while-let condition"
184 )]
185 section_lines.push(lines.next().unwrap());
186 }
187
188 let content = section_lines.join("\n").trim().to_string();
189 sections.push(SkillSection { name, content });
190 }
191 }
192
193 sections
194}
195
196fn discover_refs(skill_dir: &Path) -> Vec<SkillRef> {
198 let refs_dir = skill_dir.join("refs");
199 if !refs_dir.is_dir() {
200 return Vec::new();
201 }
202
203 let mut refs = Vec::new();
204 if let Ok(entries) = fs::read_dir(&refs_dir) {
205 for entry in entries.flatten() {
206 let path = entry.path();
207 if path.is_file() {
208 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
209 if ext == "md" || ext == "txt" {
210 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
211 refs.push(SkillRef {
212 name: stem.to_string(),
213 path,
214 });
215 }
216 }
217 }
218 }
219 }
220 }
221
222 refs
223}
224
225fn discover_scripts(skill_dir: &Path) -> Vec<SkillScript> {
231 let scripts_dir = skill_dir.join("scripts");
232 if !scripts_dir.is_dir() {
233 return Vec::new();
234 }
235
236 let mut scripts = Vec::new();
237 if let Ok(entries) = fs::read_dir(&scripts_dir) {
238 for entry in entries.flatten() {
239 let path = entry.path();
240 if !path.is_file() {
241 continue;
242 }
243
244 let is_exec = is_executable(&path);
246 let has_script_ext = path
247 .extension()
248 .and_then(|e| e.to_str())
249 .map(|e| matches!(e, "sh" | "py" | "rb" | "js"))
250 .unwrap_or(false);
251
252 if !is_exec && !has_script_ext {
253 continue;
254 }
255
256 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
257 let description = extract_script_description(&path);
258 scripts.push(SkillScript {
259 name: stem.to_string(),
260 path,
261 description,
262 });
263 }
264 }
265 }
266
267 scripts
268}
269
270#[cfg(unix)]
272fn is_executable(path: &Path) -> bool {
273 use std::os::unix::fs::PermissionsExt;
274 std::fs::metadata(path)
275 .map(|m| m.permissions().mode() & 0o111 != 0)
276 .unwrap_or(false)
277}
278
279#[cfg(not(unix))]
280fn is_executable(_path: &Path) -> bool {
281 false
282}
283
284fn extract_script_description(path: &Path) -> String {
290 let content = match fs::read_to_string(path) {
291 Ok(c) => c,
292 Err(_) => return String::new(),
293 };
294
295 let mut lines = content.lines();
296 let first = lines.next();
297
298 let target = match first {
300 Some(l) if l.starts_with("#!") => lines.next(),
301 other => other,
302 };
303
304 match target {
305 Some(l) if l.starts_with("# ") || l.starts_with("#") => l
306 .trim_start_matches("# ")
307 .trim_start_matches('#')
308 .trim()
309 .to_string(),
310 Some(l) if l.starts_with("// ") || l.starts_with("//") => l
311 .trim_start_matches("// ")
312 .trim_start_matches("//")
313 .trim()
314 .to_string(),
315 _ => String::new(),
316 }
317}
318
319#[allow(clippy::type_complexity)]
328pub fn parse_skill_meta(
329 content: &str,
330 dir_path: &Path,
331) -> (
332 String,
333 String,
334 SkillMode,
335 Vec<String>,
336 String,
337 Vec<String>,
338 Vec<SkillParam>,
339 Vec<String>,
340) {
341 if let Some(frontmatter) = content.strip_prefix("---") {
343 if let Some(end) = frontmatter.find("---") {
344 let yaml = &frontmatter[..end];
345 let body = frontmatter[end + 3..].trim();
346
347 let mut name = None;
349 let mut description = None;
350 let mut mode = SkillMode::Manual;
351 let mut triggers = Vec::new();
352 let mut hint = String::new();
353 let mut params = Vec::new();
354 let mut depends_on = Vec::new();
355 let mut globs: Vec<String> = Vec::new();
356
357 let lines: Vec<&str> = yaml.lines().collect();
358 let mut i = 0;
359 while i < lines.len() {
360 let line = lines[i].trim();
361 if let Some(stripped) = line.strip_prefix("name:") {
362 name = Some(stripped.trim().to_string());
363 } else if let Some(stripped) = line.strip_prefix("description:") {
364 description = Some(stripped.trim().to_string());
365 } else if let Some(stripped) = line.strip_prefix("hint:") {
366 hint = stripped.trim().to_string();
367 } else if let Some(stripped) = line.strip_prefix("mode:") {
368 let raw = stripped.trim().to_lowercase();
369 mode = match raw.as_str() {
370 "always" => SkillMode::Always,
371 "trigger" => SkillMode::Trigger,
372 "globs" => SkillMode::Globs,
373 _ => SkillMode::Manual,
374 };
375 } else if line == "globs:" {
376 i += 1;
378 while i < lines.len() {
379 let entry = lines[i].trim();
380 if let Some(pat) = entry.strip_prefix("- ") {
381 let p = pat.trim().trim_matches('"').trim_matches('\'');
382 if !p.is_empty() {
383 globs.push(p.to_string());
384 }
385 i += 1;
386 } else if entry.is_empty() {
387 i += 1;
388 } else {
389 break;
391 }
392 }
393 continue;
394 } else if let Some(stripped) = line.strip_prefix("triggers:") {
395 triggers = stripped
397 .split(',')
398 .map(|s| s.trim().to_string())
399 .filter(|s| !s.is_empty())
400 .collect();
401 } else if let Some(stripped) = line.strip_prefix("depends_on:") {
402 depends_on = stripped
404 .split(',')
405 .map(|s| s.trim().to_string())
406 .filter(|s| !s.is_empty())
407 .collect();
408 } else if line == "params:" {
409 i += 1;
411 while i < lines.len() {
412 let entry_line = lines[i];
413 if let Some(after_dash) = entry_line.trim().strip_prefix("- name:") {
415 let param_name = after_dash.trim().to_string();
416 let mut param_description = String::new();
417 let mut param_default = None;
418
419 i += 1;
421 while i < lines.len() {
422 let sub_line = lines[i];
423 let trimmed = sub_line.trim();
424 if !sub_line.starts_with(' ') && !sub_line.starts_with('\t') {
426 break;
427 }
428 if let Some(val) = trimmed.strip_prefix("description:") {
429 param_description = val.trim().to_string();
430 } else if let Some(val) = trimmed.strip_prefix("default:") {
431 param_default = Some(val.trim().to_string());
432 } else if trimmed.starts_with("- name:") {
433 i -= 1;
435 break;
436 } else if !trimmed.is_empty() && !trimmed.starts_with('-') {
437 }
439 i += 1;
440 }
441
442 params.push(SkillParam {
443 name: param_name,
444 description: param_description,
445 default: param_default,
446 });
447 } else {
448 i += 1;
450 }
451 }
452 break;
454 }
455 i += 1;
456 }
457
458 let final_name = name.unwrap_or_else(|| {
460 dir_path
461 .file_name()
462 .and_then(|n| n.to_str())
463 .unwrap_or("unnamed")
464 .to_string()
465 });
466
467 let final_description = description.unwrap_or_else(|| {
468 body.lines()
469 .find(|l| !l.trim().is_empty())
470 .map(|l| l.trim().to_string())
471 .unwrap_or_default()
472 });
473
474 if hint.is_empty() && mode == SkillMode::Trigger {
476 hint = format!("{}: {}", final_name, final_description);
477 }
478
479 let final_globs = if globs.is_empty() { None } else { Some(globs) };
480 let final_mode = if mode == SkillMode::Globs && final_globs.is_none() {
482 SkillMode::Manual
483 } else {
484 mode
485 };
486 return (
487 final_name,
488 final_description,
489 final_mode,
490 triggers,
491 hint,
492 depends_on,
493 params,
494 final_globs.unwrap_or_default(),
495 );
496 }
497 }
498
499 let name = dir_path
501 .file_name()
502 .and_then(|n| n.to_str())
503 .unwrap_or("unnamed")
504 .to_string();
505
506 let description = content
507 .lines()
508 .find(|l| !l.trim().is_empty())
509 .map(|l| l.trim().to_string())
510 .unwrap_or_default();
511
512 (
513 name,
514 description,
515 SkillMode::Manual,
516 Vec::new(),
517 String::new(),
518 Vec::new(),
519 Vec::new(),
520 Vec::new(),
521 )
522}
523
524pub fn skills_for_injection(skills: &[Skill], goal: &str) -> Vec<(String, String)> {
531 let mut result: Vec<(String, String)> = Vec::new();
532
533 for skill in skills {
534 match skill.mode {
535 SkillMode::Always => {
536 if let Ok(content) = fs::read_to_string(&skill.path) {
538 let body = extract_body(&content);
539 result.push((skill.name.clone(), body.to_string()));
540 }
541 }
542 SkillMode::Trigger => {
543 let goal_lower = goal.to_lowercase();
545 let matched = skill
546 .triggers
547 .iter()
548 .any(|t| goal_lower.contains(&t.to_lowercase()));
549 if matched {
550 result.push((skill.name.clone(), skill.hint.clone()));
552 }
553 }
554 SkillMode::Manual | SkillMode::Globs => {
555 }
558 }
559 }
560
561 result
562}
563
564pub fn extract_skill_body(content: &str) -> &str {
566 extract_body(content)
567}
568
569fn extract_body(content: &str) -> &str {
570 if let Some(frontmatter) = content.strip_prefix("---") {
571 if let Some(end) = frontmatter.find("---") {
572 return frontmatter[end + 3..].trim();
573 }
574 }
575 content.trim()
576}
577
578pub fn default_skill_index_budget() -> usize {
583 std::env::var("RECURSIVE_SKILL_INDEX_BUDGET")
584 .ok()
585 .and_then(|s| s.parse::<usize>().ok())
586 .filter(|n| *n > 0)
587 .unwrap_or(8000)
588}
589
590const SKILL_INDEX_PER_ENTRY_DESC_BYTES: usize = 250;
594
595pub fn skill_index(skills: &[Skill]) -> String {
603 skill_index_with_budget(skills, default_skill_index_budget())
604}
605
606pub fn skill_reminder(skills: &[Skill]) -> String {
618 if skills.is_empty() {
619 return String::new();
620 }
621 let idx = skill_index_with_budget(skills, default_skill_index_budget());
622 format!("<system-reminder>\n# Available skills\n\n{idx}\n</system-reminder>")
623}
624
625pub fn skill_index_with_budget(skills: &[Skill], byte_budget: usize) -> String {
637 if skills.is_empty() {
638 return String::new();
639 }
640
641 let full = render_skill_index(skills, |s| s.description.clone());
643 if full.len() <= byte_budget {
644 return full;
645 }
646
647 let truncated = render_skill_index(skills, |s| {
650 if s.description.len() > SKILL_INDEX_PER_ENTRY_DESC_BYTES {
651 format!(
652 "{}…",
653 crate::truncate_str(&s.description, SKILL_INDEX_PER_ENTRY_DESC_BYTES)
654 )
655 } else {
656 s.description.clone()
657 }
658 });
659 if truncated.len() <= byte_budget {
660 return truncated;
661 }
662
663 render_skill_index(skills, |_| String::new())
667}
668
669fn render_skill_index<F>(skills: &[Skill], desc: F) -> String
674where
675 F: Fn(&Skill) -> String,
676{
677 let mut lines = vec![
678 "".to_string(),
679 "Available skills (use `load_skill` to activate):".to_string(),
680 ];
681
682 for skill in skills {
683 let mut suffix_parts = Vec::new();
684
685 let mode_tag = match skill.mode {
687 SkillMode::Always => "[always]",
688 SkillMode::Trigger => "[trigger]",
689 SkillMode::Globs => "[globs]",
690 SkillMode::Manual => "",
691 };
692
693 let ref_count = skill.refs.len();
695 if ref_count > 0 {
696 suffix_parts.push(format!("{ref_count} refs"));
697 }
698
699 if !skill.params.is_empty() {
701 let param_strs: Vec<String> = skill
702 .params
703 .iter()
704 .map(|p| {
705 if let Some(ref default) = p.default {
706 format!("{}={}", p.name, default)
707 } else {
708 p.name.clone()
709 }
710 })
711 .collect();
712 suffix_parts.push(format!("params: {}", param_strs.join(", ")));
713 }
714
715 if !skill.sections.is_empty() {
717 let section_names: Vec<&str> = skill.sections.iter().map(|s| s.name.as_str()).collect();
718 suffix_parts.push(format!("sections: {}", section_names.join(", ")));
719 }
720
721 if !skill.depends_on.is_empty() {
723 suffix_parts.push(format!("depends_on: {}", skill.depends_on.join(", ")));
724 }
725
726 let script_names: Vec<&str> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
728
729 let description = desc(skill);
730 let prefix = if description.is_empty() {
731 if mode_tag.is_empty() {
733 format!("- {}", skill.name)
734 } else {
735 format!("- {} {}", mode_tag, skill.name)
736 }
737 } else if mode_tag.is_empty() {
738 format!("- {}: {}", skill.name, description)
739 } else {
740 format!("- {} {}: {}", mode_tag, skill.name, description)
741 };
742
743 if suffix_parts.is_empty() {
744 lines.push(prefix);
745 } else {
746 lines.push(format!("{} ({})", prefix, suffix_parts.join(", ")));
747 }
748
749 if !script_names.is_empty() {
751 #[allow(
752 clippy::unwrap_used,
753 reason = "lines is non-empty: prior push guarantees at least one element"
754 )]
755 let last = lines.last_mut().unwrap();
756 *last = format!("{} [scripts: {}]", last, script_names.join(", "));
757 }
758 }
759
760 lines.push("".to_string());
761 lines.join("\n")
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use std::io::Write;
768
769 #[test]
770 fn discover_skills_parses_frontmatter() {
771 let tmp = tempfile::TempDir::new().unwrap();
772 let base = tmp.path();
773
774 let rust_dir = base.join("rust-traits");
776 fs::create_dir(&rust_dir).unwrap();
777 let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
778 writeln!(
779 file,
780 "---\
781 \nname: rust-traits\
782 \ndescription: Explain Rust trait design patterns\
783 \n---\
784 \n\nWhen asked about Rust traits, walk the codebase..."
785 )
786 .unwrap();
787
788 let python_dir = base.join("python-api");
790 fs::create_dir(&python_dir).unwrap();
791 let mut file = fs::File::create(python_dir.join("SKILL.md")).unwrap();
792 writeln!(file, "First line of description\n\nMore content...").unwrap();
793
794 let skills = discover_skills(&[base.to_path_buf()]);
795 assert_eq!(skills.len(), 2);
796
797 let rust = skills.iter().find(|s| s.name == "rust-traits").unwrap();
798 assert_eq!(rust.description, "Explain Rust trait design patterns");
799
800 let python = skills.iter().find(|s| s.name == "python-api").unwrap();
801 assert_eq!(python.description, "First line of description");
802 }
803
804 #[test]
805 fn skill_index_empty_for_no_skills() {
806 let result = skill_index(&[]);
807 assert_eq!(result, "");
808 }
809
810 #[test]
811 fn skill_index_renders_correctly() {
812 let skills = vec![
813 Skill {
814 name: "rust-traits".to_string(),
815 description: "Explain Rust trait design".to_string(),
816 path: PathBuf::from("/tmp/skills/rust-traits/SKILL.md"),
817 mode: SkillMode::Manual,
818 triggers: vec![],
819 hint: String::new(),
820 depends_on: vec![],
821 refs: vec![],
822 params: vec![],
823 scripts: vec![],
824 sections: vec![],
825 globs: None,
826 },
827 Skill {
828 name: "python-api".to_string(),
829 description: "Python API patterns".to_string(),
830 path: PathBuf::from("/tmp/skills/python-api/SKILL.md"),
831 mode: SkillMode::Manual,
832 triggers: vec![],
833 hint: String::new(),
834 depends_on: vec![],
835 refs: vec![],
836 params: vec![],
837 scripts: vec![],
838 sections: vec![],
839 globs: None,
840 },
841 ];
842
843 let result = skill_index(&skills);
844 assert!(result.contains("Available skills"));
845 assert!(result.contains("- rust-traits: Explain Rust trait design"));
846 assert!(result.contains("- python-api: Python API patterns"));
847 }
848
849 #[test]
850 fn discover_skills_populates_refs() {
851 let tmp = tempfile::TempDir::new().unwrap();
852 let base = tmp.path();
853
854 let skill_dir = base.join("my-skill");
856 fs::create_dir(&skill_dir).unwrap();
857 fs::write(
858 skill_dir.join("SKILL.md"),
859 "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
860 )
861 .unwrap();
862
863 let refs_dir = skill_dir.join("refs");
865 fs::create_dir(&refs_dir).unwrap();
866 fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
867 fs::write(refs_dir.join("examples.txt"), "Example 1\nExample 2").unwrap();
868 fs::write(refs_dir.join("notes.json"), "{}").unwrap();
870
871 let skills = discover_skills(&[base.to_path_buf()]);
872 assert_eq!(skills.len(), 1);
873
874 let skill = &skills[0];
875 assert_eq!(skill.name, "my-skill");
876 assert_eq!(
877 skill.refs.len(),
878 2,
879 "should find 2 ref files (md + txt), ignoring json"
880 );
881
882 let api_spec = skill.refs.iter().find(|r| r.name == "api-spec").unwrap();
883 assert!(api_spec.path.ends_with("api-spec.md"));
884
885 let examples = skill.refs.iter().find(|r| r.name == "examples").unwrap();
886 assert!(examples.path.ends_with("examples.txt"));
887 }
888
889 #[test]
890 fn discover_skills_handles_no_refs_dir() {
891 let tmp = tempfile::TempDir::new().unwrap();
892 let base = tmp.path();
893
894 let skill_dir = base.join("simple-skill");
896 fs::create_dir(&skill_dir).unwrap();
897 fs::write(
898 skill_dir.join("SKILL.md"),
899 "---\nname: simple-skill\ndescription: No refs\n---\n\nBody",
900 )
901 .unwrap();
902
903 let skills = discover_skills(&[base.to_path_buf()]);
904 assert_eq!(skills.len(), 1);
905 assert!(
906 skills[0].refs.is_empty(),
907 "skill with no refs/ dir should have empty refs"
908 );
909 }
910
911 #[test]
912 fn skill_index_shows_ref_count() {
913 let skills = vec![
914 Skill {
915 name: "with-refs".to_string(),
916 description: "Has references".to_string(),
917 path: PathBuf::from("/tmp/skills/with-refs/SKILL.md"),
918 mode: SkillMode::Manual,
919 triggers: vec![],
920 hint: String::new(),
921 depends_on: vec![],
922 refs: vec![
923 SkillRef {
924 name: "api-spec".to_string(),
925 path: PathBuf::from("/tmp/skills/with-refs/refs/api-spec.md"),
926 },
927 SkillRef {
928 name: "examples".to_string(),
929 path: PathBuf::from("/tmp/skills/with-refs/refs/examples.txt"),
930 },
931 ],
932 params: vec![],
933 scripts: vec![],
934 sections: vec![],
935 globs: None,
936 },
937 Skill {
938 name: "no-refs".to_string(),
939 description: "No references".to_string(),
940 path: PathBuf::from("/tmp/skills/no-refs/SKILL.md"),
941 mode: SkillMode::Manual,
942 triggers: vec![],
943 hint: String::new(),
944 depends_on: vec![],
945 refs: vec![],
946 params: vec![],
947 scripts: vec![],
948 sections: vec![],
949 globs: None,
950 },
951 ];
952
953 let result = skill_index(&skills);
954 assert!(result.contains("- with-refs: Has references (2 refs)"));
955 assert!(result.contains("- no-refs: No references"));
956 assert!(
957 !result.contains("(0 refs)"),
958 "should not show count for 0 refs"
959 );
960 }
961
962 #[test]
963 fn discover_skills_parses_params() {
964 let tmp = tempfile::TempDir::new().unwrap();
965 let base = tmp.path();
966
967 let skill_dir = base.join("code-review");
968 fs::create_dir(&skill_dir).unwrap();
969 fs::write(
970 skill_dir.join("SKILL.md"),
971 "---\n\
972 name: code-review\n\
973 description: Review code for quality issues\n\
974 params:\n\
975 \x20\x20- name: language\n\
976 \x20\x20 description: Target language\n\
977 \x20\x20 default: rust\n\
978 \x20\x20- name: strict\n\
979 \x20\x20 description: Enable strict mode\n\
980 ---\n\
981 \n\
982 Review {{language}} code {{#if strict}}strictly{{/if}}.",
983 )
984 .unwrap();
985
986 let skills = discover_skills(&[base.to_path_buf()]);
987 assert_eq!(skills.len(), 1);
988
989 let skill = &skills[0];
990 assert_eq!(skill.name, "code-review");
991 assert_eq!(skill.params.len(), 2);
992
993 let lang = skill.params.iter().find(|p| p.name == "language").unwrap();
994 assert_eq!(lang.description, "Target language");
995 assert_eq!(lang.default.as_deref(), Some("rust"));
996
997 let strict = skill.params.iter().find(|p| p.name == "strict").unwrap();
998 assert_eq!(strict.description, "Enable strict mode");
999 assert_eq!(strict.default, None);
1000 }
1001
1002 #[test]
1003 fn discover_skills_handles_no_params() {
1004 let tmp = tempfile::TempDir::new().unwrap();
1005 let base = tmp.path();
1006
1007 let skill_dir = base.join("simple");
1008 fs::create_dir(&skill_dir).unwrap();
1009 fs::write(
1010 skill_dir.join("SKILL.md"),
1011 "---\nname: simple\ndescription: No params\n---\n\nBody",
1012 )
1013 .unwrap();
1014
1015 let skills = discover_skills(&[base.to_path_buf()]);
1016 assert_eq!(skills.len(), 1);
1017 assert!(
1018 skills[0].params.is_empty(),
1019 "skill without params should have empty params"
1020 );
1021 }
1022
1023 #[test]
1024 fn skill_index_shows_params() {
1025 let skills = vec![
1026 Skill {
1027 name: "code-review".to_string(),
1028 description: "Review code".to_string(),
1029 path: PathBuf::from("/tmp/skills/code-review/SKILL.md"),
1030 mode: SkillMode::Manual,
1031 triggers: vec![],
1032 hint: String::new(),
1033 depends_on: vec![],
1034 refs: vec![],
1035 params: vec![
1036 SkillParam {
1037 name: "language".to_string(),
1038 description: "Target language".to_string(),
1039 default: Some("rust".to_string()),
1040 },
1041 SkillParam {
1042 name: "strict".to_string(),
1043 description: "Enable strict mode".to_string(),
1044 default: None,
1045 },
1046 ],
1047 scripts: vec![],
1048 sections: vec![],
1049 globs: None,
1050 },
1051 Skill {
1052 name: "simple".to_string(),
1053 description: "No params".to_string(),
1054 path: PathBuf::from("/tmp/skills/simple/SKILL.md"),
1055 mode: SkillMode::Manual,
1056 triggers: vec![],
1057 hint: String::new(),
1058 depends_on: vec![],
1059 refs: vec![],
1060 params: vec![],
1061 scripts: vec![],
1062 sections: vec![],
1063 globs: None,
1064 },
1065 ];
1066
1067 let result = skill_index(&skills);
1068 assert!(
1069 result.contains("params: language=rust, strict"),
1070 "should show params with defaults: {result}"
1071 );
1072 assert!(
1073 result.contains("- simple: No params"),
1074 "should show skill without params normally"
1075 );
1076 }
1077
1078 #[test]
1079 fn discover_skills_populates_scripts() {
1080 let tmp = tempfile::TempDir::new().unwrap();
1081 let base = tmp.path();
1082
1083 let skill_dir = base.join("my-skill");
1085 fs::create_dir(&skill_dir).unwrap();
1086 fs::write(
1087 skill_dir.join("SKILL.md"),
1088 "---\nname: my-skill\ndescription: A skill with scripts\n---\n\nBody",
1089 )
1090 .unwrap();
1091
1092 let scripts_dir = skill_dir.join("scripts");
1094 fs::create_dir(&scripts_dir).unwrap();
1095 fs::write(
1096 scripts_dir.join("lint.sh"),
1097 "#!/bin/sh\n# Run the linter\necho 'linting...'\n",
1098 )
1099 .unwrap();
1100 fs::write(
1101 scripts_dir.join("format.py"),
1102 "#!/usr/bin/env python3\n# Format the code\nprint('formatting')\n",
1103 )
1104 .unwrap();
1105 fs::write(scripts_dir.join("notes.txt"), "not a script").unwrap();
1107
1108 let skills = discover_skills(&[base.to_path_buf()]);
1109 assert_eq!(skills.len(), 1);
1110
1111 let skill = &skills[0];
1112 assert_eq!(skill.name, "my-skill");
1113 assert_eq!(
1114 skill.scripts.len(),
1115 2,
1116 "should find 2 scripts (sh + py), ignoring txt"
1117 );
1118
1119 let lint = skill.scripts.iter().find(|s| s.name == "lint").unwrap();
1120 assert!(lint.path.ends_with("lint.sh"));
1121 assert_eq!(lint.description, "Run the linter");
1122
1123 let format = skill.scripts.iter().find(|s| s.name == "format").unwrap();
1124 assert!(format.path.ends_with("format.py"));
1125 assert_eq!(format.description, "Format the code");
1126 }
1127
1128 #[test]
1129 fn discover_skills_handles_no_scripts_dir() {
1130 let tmp = tempfile::TempDir::new().unwrap();
1131 let base = tmp.path();
1132
1133 let skill_dir = base.join("simple-skill");
1135 fs::create_dir(&skill_dir).unwrap();
1136 fs::write(
1137 skill_dir.join("SKILL.md"),
1138 "---\nname: simple-skill\ndescription: No scripts\n---\n\nBody",
1139 )
1140 .unwrap();
1141
1142 let skills = discover_skills(&[base.to_path_buf()]);
1143 assert_eq!(skills.len(), 1);
1144 assert!(
1145 skills[0].scripts.is_empty(),
1146 "skill with no scripts/ dir should have empty scripts"
1147 );
1148 }
1149
1150 #[test]
1151 fn skill_index_shows_script_names() {
1152 let skills = vec![
1153 Skill {
1154 name: "with-scripts".to_string(),
1155 description: "Has scripts".to_string(),
1156 path: PathBuf::from("/tmp/skills/with-scripts/SKILL.md"),
1157 mode: SkillMode::Manual,
1158 triggers: vec![],
1159 hint: String::new(),
1160 depends_on: vec![],
1161 refs: vec![],
1162 params: vec![],
1163 scripts: vec![
1164 SkillScript {
1165 name: "lint".to_string(),
1166 path: PathBuf::from("/tmp/skills/with-scripts/scripts/lint.sh"),
1167 description: "Run the linter".to_string(),
1168 },
1169 SkillScript {
1170 name: "format".to_string(),
1171 path: PathBuf::from("/tmp/skills/with-scripts/scripts/format.py"),
1172 description: "Format the code".to_string(),
1173 },
1174 ],
1175 sections: vec![],
1176 globs: None,
1177 },
1178 Skill {
1179 name: "no-scripts".to_string(),
1180 description: "No scripts".to_string(),
1181 path: PathBuf::from("/tmp/skills/no-scripts/SKILL.md"),
1182 mode: SkillMode::Manual,
1183 triggers: vec![],
1184 hint: String::new(),
1185 depends_on: vec![],
1186 refs: vec![],
1187 params: vec![],
1188 scripts: vec![],
1189 sections: vec![],
1190 globs: None,
1191 },
1192 ];
1193
1194 let result = skill_index(&skills);
1195 assert!(result.contains("- with-scripts: Has scripts [scripts: lint, format]"));
1196 assert!(result.contains("- no-scripts: No scripts"));
1197 }
1198
1199 #[test]
1200 fn extract_script_description_shebang_then_comment() {
1201 let tmp = tempfile::TempDir::new().unwrap();
1202 let path = tmp.path().join("test.sh");
1203 fs::write(&path, "#!/bin/sh\n# Run the linter\necho hi\n").unwrap();
1204 assert_eq!(extract_script_description(&path), "Run the linter");
1205 }
1206
1207 #[test]
1208 fn extract_script_description_no_shebang() {
1209 let tmp = tempfile::TempDir::new().unwrap();
1210 let path = tmp.path().join("test.py");
1211 fs::write(&path, "# Format the code\nprint('hi')\n").unwrap();
1212 assert_eq!(extract_script_description(&path), "Format the code");
1213 }
1214
1215 #[test]
1216 fn extract_script_description_js_comment() {
1217 let tmp = tempfile::TempDir::new().unwrap();
1218 let path = tmp.path().join("test.js");
1219 fs::write(&path, "// Run the build\nconsole.log('hi')\n").unwrap();
1220 assert_eq!(extract_script_description(&path), "Run the build");
1221 }
1222
1223 #[test]
1224 fn extract_script_description_empty_when_no_comment() {
1225 let tmp = tempfile::TempDir::new().unwrap();
1226 let path = tmp.path().join("test.sh");
1227 fs::write(&path, "#!/bin/sh\necho hi\n").unwrap();
1228 assert_eq!(extract_script_description(&path), "");
1229 }
1230
1231 #[test]
1234 fn parse_skill_meta_parses_mode_always() {
1235 let tmp = tempfile::TempDir::new().unwrap();
1236 let dir = tmp.path().join("test-skill");
1237 fs::create_dir(&dir).unwrap();
1238 let content = "---\nname: test-skill\ndescription: A test\nmode: always\n---\n\nBody text";
1239 let (name, desc, mode, triggers, hint, _depends_on, params, _globs) =
1240 parse_skill_meta(content, &dir);
1241 assert_eq!(name, "test-skill");
1242 assert_eq!(desc, "A test");
1243 assert_eq!(mode, SkillMode::Always);
1244 assert!(triggers.is_empty());
1245 assert!(hint.is_empty());
1246 assert!(params.is_empty());
1247 }
1248
1249 #[test]
1250 fn parse_skill_meta_parses_mode_trigger_with_triggers() {
1251 let tmp = tempfile::TempDir::new().unwrap();
1252 let dir = tmp.path().join("test-skill");
1253 fs::create_dir(&dir).unwrap();
1254 let content =
1255 "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust, trait\n---\n\nBody text";
1256 let (name, desc, mode, triggers, hint, _depends_on, params, _globs) =
1257 parse_skill_meta(content, &dir);
1258 assert_eq!(name, "test-skill");
1259 assert_eq!(desc, "A test");
1260 assert_eq!(mode, SkillMode::Trigger);
1261 assert_eq!(triggers, vec!["rust", "trait"]);
1262 assert_eq!(hint, "test-skill: A test");
1264 assert!(params.is_empty());
1265 }
1266
1267 #[test]
1268 fn parse_skill_meta_parses_explicit_hint() {
1269 let tmp = tempfile::TempDir::new().unwrap();
1270 let dir = tmp.path().join("test-skill");
1271 fs::create_dir(&dir).unwrap();
1272 let content =
1273 "---\nname: test-skill\ndescription: A test\nmode: trigger\ntriggers: rust\nhint: Rust-related helper\n---\n\nBody text";
1274 let (name, desc, mode, triggers, hint, _depends_on, params, _globs) =
1275 parse_skill_meta(content, &dir);
1276 assert_eq!(name, "test-skill");
1277 assert_eq!(desc, "A test");
1278 assert_eq!(mode, SkillMode::Trigger);
1279 assert_eq!(triggers, vec!["rust"]);
1280 assert_eq!(hint, "Rust-related helper");
1281 assert!(params.is_empty());
1282 }
1283
1284 #[test]
1285 fn parse_skill_meta_defaults_to_manual() {
1286 let tmp = tempfile::TempDir::new().unwrap();
1287 let dir = tmp.path().join("test-skill");
1288 fs::create_dir(&dir).unwrap();
1289 let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
1290 let (_, _, mode, triggers, hint, _, _, _) = parse_skill_meta(content, &dir);
1291 assert_eq!(mode, SkillMode::Manual);
1292 assert!(triggers.is_empty());
1293 assert!(hint.is_empty());
1294 }
1295
1296 #[test]
1297 fn parse_skill_meta_no_frontmatter_defaults_manual() {
1298 let tmp = tempfile::TempDir::new().unwrap();
1299 let dir = tmp.path().join("test-skill");
1300 fs::create_dir(&dir).unwrap();
1301 let content = "Body text";
1302 let (_, _, mode, triggers, hint, _, _, _) = parse_skill_meta(content, &dir);
1303 assert_eq!(mode, SkillMode::Manual);
1304 assert!(triggers.is_empty());
1305 assert!(hint.is_empty());
1306 }
1307
1308 #[test]
1309 fn skills_for_injection_always_mode() {
1310 let tmp = tempfile::TempDir::new().unwrap();
1311 let dir = tmp.path().join("always-skill");
1312 fs::create_dir(&dir).unwrap();
1313 fs::write(
1314 dir.join("SKILL.md"),
1315 "---\nname: always-skill\ndescription: Always injected\nmode: always\n---\n\nAlways body content",
1316 )
1317 .unwrap();
1318
1319 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1320 let result = skills_for_injection(&skills, "anything");
1321 assert_eq!(result.len(), 1);
1322 assert_eq!(result[0].0, "always-skill");
1323 assert_eq!(result[0].1, "Always body content");
1324 }
1325
1326 #[test]
1327 fn skills_for_injection_trigger_matches() {
1328 let tmp = tempfile::TempDir::new().unwrap();
1329 let dir = tmp.path().join("rust-skill");
1330 fs::create_dir(&dir).unwrap();
1331 fs::write(
1332 dir.join("SKILL.md"),
1333 "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1334 )
1335 .unwrap();
1336
1337 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1338 let result = skills_for_injection(&skills, "I need help with Rust traits");
1339 assert_eq!(result.len(), 1);
1340 assert_eq!(result[0].0, "rust-skill");
1341 assert_eq!(result[0].1, "rust-skill: Rust helper");
1343 }
1344
1345 #[test]
1346 fn skills_for_injection_trigger_no_match() {
1347 let tmp = tempfile::TempDir::new().unwrap();
1348 let dir = tmp.path().join("rust-skill");
1349 fs::create_dir(&dir).unwrap();
1350 fs::write(
1351 dir.join("SKILL.md"),
1352 "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: rust, trait\n---\n\nRust body content",
1353 )
1354 .unwrap();
1355
1356 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1357 let result = skills_for_injection(&skills, "I need help with Python");
1358 assert!(result.is_empty());
1359 }
1360
1361 #[test]
1362 fn skills_for_injection_manual_never_injected() {
1363 let tmp = tempfile::TempDir::new().unwrap();
1364 let dir = tmp.path().join("manual-skill");
1365 fs::create_dir(&dir).unwrap();
1366 fs::write(
1367 dir.join("SKILL.md"),
1368 "---\nname: manual-skill\ndescription: Manual only\n---\n\nManual body",
1369 )
1370 .unwrap();
1371
1372 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1373 let result = skills_for_injection(&skills, "anything");
1374 assert!(result.is_empty());
1375 }
1376
1377 #[test]
1378 fn parse_skill_meta_parses_globs_mode() {
1379 let content =
1380 "---\nname: arch-sync\ndescription: sync docs\nmode: globs\nglobs:\n - src/tools/**\n - src/runtime.rs\n---\n\nBody";
1381 let dir = std::path::PathBuf::from("/tmp/skills/arch-sync");
1382 let (name, _, mode, _, _, _, _, globs) = parse_skill_meta(content, &dir);
1383 assert_eq!(name, "arch-sync");
1384 assert_eq!(mode, SkillMode::Globs);
1385 assert_eq!(globs, vec!["src/tools/**", "src/runtime.rs"]);
1386 }
1387
1388 #[test]
1389 fn skills_for_injection_globs_not_injected_at_session_start() {
1390 let tmp = tempfile::TempDir::new().unwrap();
1391 let dir = tmp.path().join("arch-sync");
1392 fs::create_dir(&dir).unwrap();
1393 fs::write(
1394 dir.join("SKILL.md"),
1395 "---\nname: arch-sync\ndescription: sync docs\nmode: globs\nglobs:\n - src/tools/**\n---\n\nBody",
1396 )
1397 .unwrap();
1398
1399 let skills = discover_skills(&[tmp.path().to_path_buf()]);
1400 let result = skills_for_injection(&skills, "anything src/tools/fs.rs");
1402 assert!(
1403 result.is_empty(),
1404 "globs skill should not inject at session start"
1405 );
1406 }
1407
1408 #[test]
1409 fn skill_index_shows_mode_tags() {
1410 let skills = vec![
1411 Skill {
1412 name: "always-skill".to_string(),
1413 description: "Always injected".to_string(),
1414 path: PathBuf::from("/tmp/skills/always-skill/SKILL.md"),
1415 mode: SkillMode::Always,
1416 triggers: vec![],
1417 hint: String::new(),
1418 depends_on: vec![],
1419 refs: vec![],
1420 params: vec![],
1421 scripts: vec![],
1422 sections: vec![],
1423 globs: None,
1424 },
1425 Skill {
1426 name: "trigger-skill".to_string(),
1427 description: "Trigger based".to_string(),
1428 path: PathBuf::from("/tmp/skills/trigger-skill/SKILL.md"),
1429 mode: SkillMode::Trigger,
1430 triggers: vec!["rust".to_string()],
1431 hint: "trigger-skill: Trigger based".to_string(),
1432 depends_on: vec![],
1433 refs: vec![],
1434 params: vec![],
1435 scripts: vec![],
1436 sections: vec![],
1437 globs: None,
1438 },
1439 Skill {
1440 name: "manual-skill".to_string(),
1441 description: "Manual only".to_string(),
1442 path: PathBuf::from("/tmp/skills/manual-skill/SKILL.md"),
1443 mode: SkillMode::Manual,
1444 triggers: vec![],
1445 hint: String::new(),
1446 depends_on: vec![],
1447 refs: vec![],
1448 params: vec![],
1449 scripts: vec![],
1450 sections: vec![],
1451 globs: None,
1452 },
1453 ];
1454
1455 let result = skill_index(&skills);
1456 assert!(result.contains("[always] always-skill"));
1457 assert!(result.contains("[trigger] trigger-skill"));
1458 assert!(result.contains("- manual-skill: Manual only"));
1459 }
1460
1461 #[test]
1464 fn parse_sections_extracts_named_sections() {
1465 let content = "---\nname: test\ndescription: Test\n---\n\n## Overview\n\nGeneral info here.\n\n## Details\n\nMore specific content.\n\n## Examples\n\nExample 1\nExample 2";
1466 let sections = parse_sections(content);
1467 assert_eq!(sections.len(), 3);
1468 assert_eq!(sections[0].name, "Overview");
1469 assert_eq!(sections[0].content, "General info here.");
1470 assert_eq!(sections[1].name, "Details");
1471 assert_eq!(sections[1].content, "More specific content.");
1472 assert_eq!(sections[2].name, "Examples");
1473 assert_eq!(sections[2].content, "Example 1\nExample 2");
1474 }
1475
1476 #[test]
1477 fn parse_sections_returns_empty_for_no_headings() {
1478 let content = "---\nname: test\ndescription: Test\n---\n\nJust a body with no sections.";
1479 let sections = parse_sections(content);
1480 assert!(sections.is_empty());
1481 }
1482
1483 #[test]
1484 fn parse_sections_handles_no_frontmatter() {
1485 let content = "## Intro\n\nHello world\n\n## Conclusion\n\nBye";
1486 let sections = parse_sections(content);
1487 assert_eq!(sections.len(), 2);
1488 assert_eq!(sections[0].name, "Intro");
1489 assert_eq!(sections[0].content, "Hello world");
1490 assert_eq!(sections[1].name, "Conclusion");
1491 assert_eq!(sections[1].content, "Bye");
1492 }
1493
1494 #[test]
1495 fn skill_index_shows_sections() {
1496 let skills = vec![
1497 Skill {
1498 name: "with-sections".to_string(),
1499 description: "Has sections".to_string(),
1500 path: PathBuf::from("/tmp/skills/with-sections/SKILL.md"),
1501 mode: SkillMode::Manual,
1502 triggers: vec![],
1503 hint: String::new(),
1504 depends_on: vec![],
1505 refs: vec![],
1506 params: vec![],
1507 scripts: vec![],
1508 sections: vec![
1509 SkillSection {
1510 name: "Overview".to_string(),
1511 content: "General info".to_string(),
1512 },
1513 SkillSection {
1514 name: "Details".to_string(),
1515 content: "Specific info".to_string(),
1516 },
1517 ],
1518 globs: None,
1519 },
1520 Skill {
1521 name: "no-sections".to_string(),
1522 description: "No sections".to_string(),
1523 path: PathBuf::from("/tmp/skills/no-sections/SKILL.md"),
1524 mode: SkillMode::Manual,
1525 triggers: vec![],
1526 hint: String::new(),
1527 depends_on: vec![],
1528 refs: vec![],
1529 params: vec![],
1530 scripts: vec![],
1531 sections: vec![],
1532 globs: None,
1533 },
1534 ];
1535
1536 let result = skill_index(&skills);
1537 assert!(result.contains("sections: Overview, Details"));
1538 assert!(result.contains("- no-sections: No sections"));
1539 }
1540
1541 #[test]
1542 fn discover_skills_populates_sections() {
1543 let tmp = tempfile::TempDir::new().unwrap();
1544 let base = tmp.path();
1545
1546 let skill_dir = base.join("sectioned-skill");
1547 fs::create_dir(&skill_dir).unwrap();
1548 fs::write(
1549 skill_dir.join("SKILL.md"),
1550 "---\nname: sectioned-skill\ndescription: A skill with sections\n---\n\n## Setup\n\nInstallation steps.\n\n## Usage\n\nHow to use it.\n\n## API\n\nReference docs.",
1551 )
1552 .unwrap();
1553
1554 let skills = discover_skills(&[base.to_path_buf()]);
1555 assert_eq!(skills.len(), 1);
1556 let skill = &skills[0];
1557 assert_eq!(skill.sections.len(), 3);
1558 assert_eq!(skill.sections[0].name, "Setup");
1559 assert_eq!(skill.sections[1].name, "Usage");
1560 assert_eq!(skill.sections[2].name, "API");
1561 }
1562
1563 fn make_desc_skill(i: usize, desc: String) -> Skill {
1566 Skill {
1567 name: format!("skill-{i}"),
1568 description: desc,
1569 path: PathBuf::from(format!("/tmp/skills/skill-{i}/SKILL.md")),
1570 mode: SkillMode::Manual,
1571 triggers: vec![],
1572 hint: String::new(),
1573 depends_on: vec![],
1574 refs: vec![],
1575 params: vec![],
1576 scripts: vec![],
1577 sections: vec![],
1578 globs: None,
1579 }
1580 }
1581
1582 #[test]
1585 fn skill_index_under_budget_unchanged() {
1586 let skills = vec![
1587 make_desc_skill(0, "First skill".to_string()),
1588 make_desc_skill(1, "Second skill".to_string()),
1589 ];
1590 let rendered = skill_index_with_budget(&skills, 5000);
1592 assert!(rendered.contains("Available skills"));
1595 assert!(rendered.contains("- skill-0: First skill"));
1596 assert!(rendered.contains("- skill-1: Second skill"));
1597 assert!(
1598 !rendered.contains('…'),
1599 "under-budget render must not truncate: {rendered}"
1600 );
1601 }
1602
1603 #[test]
1607 fn skill_index_over_budget_truncates_descriptions() {
1608 let long_desc = "x".repeat(1000);
1611 let skills: Vec<Skill> = (0..5)
1612 .map(|i| make_desc_skill(i, long_desc.clone()))
1613 .collect();
1614
1615 let rendered = skill_index_with_budget(&skills, 2000);
1616 assert!(
1617 rendered.len() <= 2000,
1618 "truncated render must fit budget (got {} bytes)",
1619 rendered.len()
1620 );
1621 for line in rendered.lines() {
1623 if line.starts_with("- skill-") {
1624 assert!(
1625 line.contains('…'),
1626 "expected ellipsis on truncated line: {line}"
1627 );
1628 }
1629 }
1630 for line in rendered.lines() {
1633 if let Some(rest) = line.split('…').next() {
1634 let desc_start = rest.rfind(": ").map(|i| i + 2).unwrap_or(0);
1635 let desc_bytes = rest[desc_start..].len();
1636 assert!(
1637 desc_bytes <= SKILL_INDEX_PER_ENTRY_DESC_BYTES,
1638 "truncated desc must be ≤ {} bytes (was {desc_bytes}): {line}",
1639 SKILL_INDEX_PER_ENTRY_DESC_BYTES
1640 );
1641 }
1642 }
1643 }
1644
1645 #[test]
1649 fn skill_index_severely_over_budget_falls_back_to_names_only() {
1650 let desc = "y".repeat(1000);
1655 let skills: Vec<Skill> = (0..10).map(|i| make_desc_skill(i, desc.clone())).collect();
1656
1657 let rendered = skill_index_with_budget(&skills, 200);
1658 assert!(
1659 rendered.len() <= 200,
1660 "names-only fallback must fit budget (got {} bytes)",
1661 rendered.len()
1662 );
1663 assert!(rendered.contains("Available skills"));
1664 assert!(rendered.contains("- skill-0"));
1665 assert!(rendered.contains("- skill-9"));
1666 assert!(
1668 !rendered.contains('…'),
1669 "names-only fallback must not contain ellipsis: {rendered}"
1670 );
1671 assert!(
1672 !rendered.contains(": y"),
1673 "names-only fallback must not include description text: {rendered}"
1674 );
1675 }
1676
1677 #[test]
1682 fn skill_index_multibyte_description_truncation_no_panic() {
1683 let mut desc = "多字节描述 ".repeat(40);
1686 desc.push_str(" 文案结束");
1687 assert!(
1688 desc.len() > SKILL_INDEX_PER_ENTRY_DESC_BYTES,
1689 "fixture must exceed the per-entry desc cap"
1690 );
1691 let skills = vec![make_desc_skill(0, desc.clone())];
1692
1693 let header_overhead = 80;
1697 let rendered =
1698 skill_index_with_budget(&skills, SKILL_INDEX_PER_ENTRY_DESC_BYTES + header_overhead);
1699 assert!(rendered.contains("skill-0"), "{rendered}");
1701 assert!(
1702 rendered.contains('…'),
1703 "expected truncation ellipsis: {rendered}"
1704 );
1705 assert!(std::str::from_utf8(rendered.as_bytes()).is_ok());
1708 }
1709
1710 #[test]
1714 fn skill_index_env_budget_override() {
1715 let _env_lock = crate::test_util::env_lock();
1716 let original = std::env::var("RECURSIVE_SKILL_INDEX_BUDGET").ok();
1717
1718 std::env::remove_var("RECURSIVE_SKILL_INDEX_BUDGET");
1720 assert_eq!(default_skill_index_budget(), 8000);
1721
1722 std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "1234");
1724 assert_eq!(default_skill_index_budget(), 1234);
1725
1726 std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "not-a-number");
1728 assert_eq!(default_skill_index_budget(), 8000);
1729
1730 std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "0");
1732 assert_eq!(default_skill_index_budget(), 8000);
1733
1734 if let Some(v) = original {
1736 std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", v);
1737 } else {
1738 std::env::remove_var("RECURSIVE_SKILL_INDEX_BUDGET");
1739 }
1740 }
1741
1742 fn fake_dir() -> PathBuf {
1745 PathBuf::from("/fake/my-skill")
1746 }
1747
1748 #[test]
1749 fn parse_skill_meta_no_frontmatter_uses_dir_name_and_first_line() {
1750 let content = "First line description\n\nMore body.";
1751 let (name, desc, mode, triggers, hint, depends_on, params, globs) =
1752 parse_skill_meta(content, &fake_dir());
1753 assert_eq!(name, "my-skill");
1754 assert_eq!(desc, "First line description");
1755 assert_eq!(mode, SkillMode::Manual);
1756 assert!(triggers.is_empty());
1757 assert!(hint.is_empty());
1758 assert!(depends_on.is_empty());
1759 assert!(params.is_empty());
1760 assert!(globs.is_empty());
1761 }
1762
1763 #[test]
1764 fn parse_skill_meta_frontmatter_name_and_description() {
1765 let content = "---\nname: my-name\ndescription: My desc\n---\nbody";
1766 let (name, desc, mode, triggers, hint, depends_on, params, globs) =
1767 parse_skill_meta(content, &fake_dir());
1768 assert_eq!(name, "my-name", "frontmatter name must win");
1769 assert_eq!(desc, "My desc", "frontmatter description must win");
1770 assert_eq!(mode, SkillMode::Manual);
1771 assert!(triggers.is_empty());
1772 assert!(hint.is_empty());
1773 assert!(depends_on.is_empty());
1774 assert!(params.is_empty());
1775 assert!(globs.is_empty());
1776 }
1777
1778 #[test]
1779 fn parse_skill_meta_mode_always() {
1780 let content = "---\nname: x\ndescription: d\nmode: always\n---\nbody";
1781 let (_n, _d, mode, ..) = parse_skill_meta(content, &fake_dir());
1782 assert_eq!(mode, SkillMode::Always);
1783 }
1784
1785 #[test]
1786 fn parse_skill_meta_mode_trigger_auto_hint() {
1787 let content = "---\nname: my-trigger\ndescription: Trigger desc\nmode: trigger\n---\nbody";
1788 let (name, desc, mode, _triggers, hint, ..) = parse_skill_meta(content, &fake_dir());
1789 assert_eq!(mode, SkillMode::Trigger);
1790 assert_eq!(hint, format!("{name}: {desc}"));
1792 }
1793
1794 #[test]
1795 fn parse_skill_meta_mode_trigger_explicit_hint_preserved() {
1796 let content = "---\nname: x\ndescription: d\nmode: trigger\nhint: custom hint\n---\nbody";
1797 let (_, _, _, _, hint, _, _, _) = parse_skill_meta(content, &fake_dir());
1798 assert_eq!(hint, "custom hint");
1799 }
1800
1801 #[test]
1802 fn parse_skill_meta_triggers_parsed() {
1803 let content = "---\nname: x\ndescription: d\ntriggers: foo, bar, baz\n---\nbody";
1804 let (_n, _d, _m, triggers, ..) = parse_skill_meta(content, &fake_dir());
1805 assert_eq!(triggers, vec!["foo", "bar", "baz"]);
1806 }
1807
1808 #[test]
1809 fn parse_skill_meta_depends_on_parsed() {
1810 let content = "---\nname: x\ndescription: d\ndepends_on: base, extra\n---\nbody";
1811 let (_n, _d, _m, _t, _h, depends_on, ..) = parse_skill_meta(content, &fake_dir());
1812 assert_eq!(depends_on, vec!["base", "extra"]);
1813 }
1814
1815 #[test]
1816 fn parse_skill_meta_globs_parsed() {
1817 let content =
1818 "---\nname: x\ndescription: d\nmode: globs\nglobs:\n- \"**/*.rs\"\n- src/**\n---\nbody";
1819 let (_n, _d, mode, _t, _h, _do, _p, globs) = parse_skill_meta(content, &fake_dir());
1820 assert_eq!(mode, SkillMode::Globs);
1821 assert!(globs.contains(&"**/*.rs".to_string()));
1822 assert!(globs.contains(&"src/**".to_string()));
1823 }
1824
1825 #[test]
1826 fn parse_skill_meta_globs_mode_no_patterns_becomes_manual() {
1827 let content = "---\nname: x\ndescription: d\nmode: globs\n---\nbody";
1828 let (_n, _d, mode, ..) = parse_skill_meta(content, &fake_dir());
1829 assert_eq!(mode, SkillMode::Manual);
1831 }
1832
1833 #[test]
1834 fn parse_skill_meta_params_parsed() {
1835 let content = "---\nname: x\ndescription: d\nparams:\n - name: myarg\n description: What it does\n default: fallback\n---\nbody";
1836 let (_n, _d, _m, _t, _h, _do, params, _g) = parse_skill_meta(content, &fake_dir());
1837 assert_eq!(params.len(), 1);
1838 assert_eq!(params[0].name, "myarg");
1839 assert_eq!(params[0].description, "What it does");
1840 assert_eq!(params[0].default, Some("fallback".to_string()));
1841 }
1842
1843 #[test]
1846 fn discover_refs_no_refs_dir_returns_empty() {
1847 let tmp = tempfile::TempDir::new().unwrap();
1848 let refs = discover_refs(tmp.path());
1849 assert!(refs.is_empty(), "no refs/ dir must return empty vec");
1850 }
1851
1852 #[test]
1853 fn discover_refs_finds_md_and_txt_files() {
1854 let tmp = tempfile::TempDir::new().unwrap();
1855 let refs_dir = tmp.path().join("refs");
1856 fs::create_dir(&refs_dir).unwrap();
1857 fs::write(refs_dir.join("guide.md"), "# guide").unwrap();
1858 fs::write(refs_dir.join("notes.txt"), "notes").unwrap();
1859 fs::write(refs_dir.join("ignore.rs"), "fn main() {}").unwrap();
1860
1861 let refs = discover_refs(tmp.path());
1862 assert_eq!(refs.len(), 2, "must find .md and .txt but not .rs");
1863 let names: Vec<&str> = refs.iter().map(|r| r.name.as_str()).collect();
1864 assert!(names.contains(&"guide"));
1865 assert!(names.contains(&"notes"));
1866 }
1867
1868 #[test]
1869 fn discover_refs_extension_filter_excludes_non_md_txt() {
1870 let tmp = tempfile::TempDir::new().unwrap();
1871 let refs_dir = tmp.path().join("refs");
1872 fs::create_dir(&refs_dir).unwrap();
1873 fs::write(refs_dir.join("readme.html"), "<h1>").unwrap();
1874
1875 let refs = discover_refs(tmp.path());
1876 assert!(refs.is_empty(), ".html file must not appear in refs");
1877 }
1878
1879 #[test]
1882 fn discover_scripts_no_scripts_dir_returns_empty() {
1883 let tmp = tempfile::TempDir::new().unwrap();
1884 let scripts = discover_scripts(tmp.path());
1885 assert!(scripts.is_empty());
1886 }
1887
1888 #[test]
1889 fn discover_scripts_finds_sh_and_py_by_extension() {
1890 let tmp = tempfile::TempDir::new().unwrap();
1891 let scripts_dir = tmp.path().join("scripts");
1892 fs::create_dir(&scripts_dir).unwrap();
1893 fs::write(scripts_dir.join("run.sh"), "#!/bin/sh\n# Run the thing").unwrap();
1894 fs::write(scripts_dir.join("helper.py"), "# Helper script").unwrap();
1895 fs::write(scripts_dir.join("data.bin"), "binary").unwrap();
1897
1898 let scripts = discover_scripts(tmp.path());
1899 assert_eq!(scripts.len(), 2, "must find .sh and .py, not .bin");
1900 let names: Vec<&str> = scripts.iter().map(|s| s.name.as_str()).collect();
1901 assert!(names.contains(&"run"), "run.sh → name 'run'");
1902 assert!(names.contains(&"helper"), "helper.py → name 'helper'");
1903 }
1904
1905 #[test]
1908 fn extract_script_description_hash_comment() {
1909 let tmp = tempfile::NamedTempFile::new().unwrap();
1910 fs::write(tmp.path(), "# My description\nsome code").unwrap();
1911 let desc = extract_script_description(tmp.path());
1912 assert_eq!(desc, "My description");
1913 }
1914
1915 #[test]
1916 fn extract_script_description_skips_shebang() {
1917 let tmp = tempfile::NamedTempFile::new().unwrap();
1918 fs::write(
1919 tmp.path(),
1920 "#!/usr/bin/env python3\n# Script description\ncode",
1921 )
1922 .unwrap();
1923 let desc = extract_script_description(tmp.path());
1924 assert_eq!(desc, "Script description");
1925 }
1926
1927 #[test]
1928 fn extract_script_description_slash_comment() {
1929 let tmp = tempfile::NamedTempFile::new().unwrap();
1930 fs::write(tmp.path(), "// JS description\nconsole.log('hi')").unwrap();
1931 let desc = extract_script_description(tmp.path());
1932 assert_eq!(desc, "JS description");
1933 }
1934
1935 #[test]
1936 fn extract_script_description_no_comment_returns_empty() {
1937 let tmp = tempfile::NamedTempFile::new().unwrap();
1938 fs::write(tmp.path(), "no comment here").unwrap();
1939 let desc = extract_script_description(tmp.path());
1940 assert_eq!(desc, "");
1941 }
1942
1943 #[test]
1946 #[cfg(unix)]
1947 fn is_executable_non_executable_file_returns_false() {
1948 let tmp = tempfile::NamedTempFile::new().unwrap();
1949 use std::os::unix::fs::PermissionsExt;
1951 let mut perms = fs::metadata(tmp.path()).unwrap().permissions();
1952 perms.set_mode(0o644);
1953 fs::set_permissions(tmp.path(), perms).unwrap();
1954 assert!(!is_executable(tmp.path()));
1955 }
1956
1957 #[test]
1958 #[cfg(unix)]
1959 fn is_executable_executable_file_returns_true() {
1960 let tmp = tempfile::NamedTempFile::new().unwrap();
1961 use std::os::unix::fs::PermissionsExt;
1962 let mut perms = fs::metadata(tmp.path()).unwrap().permissions();
1963 perms.set_mode(0o755);
1964 fs::set_permissions(tmp.path(), perms).unwrap();
1965 assert!(is_executable(tmp.path()));
1966 }
1967
1968 #[test]
1971 fn discover_skills_skips_invalid_entries() {
1972 let tmp = tempfile::TempDir::new().unwrap();
1973 fs::write(tmp.path().join("README.md"), "noise").unwrap();
1975 fs::create_dir(tmp.path().join("empty-dir")).unwrap();
1977 let good = tmp.path().join("good-skill");
1979 fs::create_dir(&good).unwrap();
1980 fs::write(
1981 good.join("SKILL.md"),
1982 "---\nname: good-skill\ndescription: ok\n---\n\nBody",
1983 )
1984 .unwrap();
1985 let not_a_dir = tmp.path().join("not-a-dir.txt");
1987 fs::write(¬_a_dir, "x").unwrap();
1988
1989 let skills = discover_skills(&[tmp.path().to_path_buf(), not_a_dir]);
1990 assert_eq!(skills.len(), 1, "only the valid skill must be discovered");
1991 assert_eq!(skills[0].name, "good-skill");
1992 }
1993
1994 #[test]
1995 fn extract_body_strips_frontmatter_and_trims() {
1996 assert_eq!(
1997 extract_skill_body("---\nname: x\n---\n body text "),
1998 "body text"
1999 );
2000 assert_eq!(
2001 extract_body("no frontmatter\n"),
2002 "no frontmatter",
2003 "content without --- must still be trimmed"
2004 );
2005 assert_eq!(extract_body("---\nname: x\nbody"), "---\nname: x\nbody");
2007 }
2008
2009 #[test]
2010 fn skills_for_injection_trigger_case_insensitive() {
2011 let tmp = tempfile::TempDir::new().unwrap();
2012 let dir = tmp.path().join("rust-skill");
2013 fs::create_dir(&dir).unwrap();
2014 fs::write(
2015 dir.join("SKILL.md"),
2016 "---\nname: rust-skill\ndescription: Rust helper\nmode: trigger\ntriggers: Rust\n---\n\nBody",
2017 )
2018 .unwrap();
2019 let skills = discover_skills(&[tmp.path().to_path_buf()]);
2020 let hit = skills_for_injection(&skills, "need RUST help");
2021 assert_eq!(hit.len(), 1, "trigger match must be case-insensitive");
2022 let miss = skills_for_injection(&skills, "need python help");
2023 assert!(miss.is_empty());
2024 }
2025
2026 #[test]
2027 fn parse_skill_meta_unknown_mode_and_empty_triggers() {
2028 let content =
2029 "---\nname: x\ndescription: d\nmode: bogus\ntriggers: rust, , trait\n---\nbody";
2030 let (_n, _d, mode, triggers, ..) = parse_skill_meta(content, &fake_dir());
2031 assert_eq!(
2032 mode,
2033 SkillMode::Manual,
2034 "unknown mode must fall back to Manual"
2035 );
2036 assert_eq!(
2037 triggers,
2038 vec!["rust", "trait"],
2039 "empty trigger tokens must be filtered"
2040 );
2041 }
2042
2043 #[test]
2044 fn parse_skill_meta_globs_strips_quotes_skips_empty() {
2045 let content =
2046 "---\nname: x\ndescription: d\nmode: globs\nglobs:\n - \"\"\n - 'src/**'\n - \"lib/**\"\n---\nbody";
2047 let (_n, _d, mode, _t, _h, _dep, _p, globs) = parse_skill_meta(content, &fake_dir());
2048 assert_eq!(mode, SkillMode::Globs);
2049 assert_eq!(globs, vec!["src/**", "lib/**"]);
2050 }
2051
2052 #[test]
2053 fn skill_index_shows_depends_on_and_globs_tag() {
2054 let skills = vec![Skill {
2055 name: "arch-sync".into(),
2056 description: "sync docs".into(),
2057 path: PathBuf::from("/fake/arch-sync/SKILL.md"),
2058 mode: SkillMode::Globs,
2059 triggers: vec![],
2060 hint: String::new(),
2061 depends_on: vec!["base".into()],
2062 refs: vec![],
2063 params: vec![],
2064 scripts: vec![],
2065 sections: vec![],
2066 globs: Some(vec!["src/**".into()]),
2067 }];
2068 let idx = skill_index(&skills);
2069 assert!(idx.contains("[globs]"), "globs mode tag missing: {idx}");
2070 assert!(
2071 idx.contains("depends_on: base"),
2072 "depends_on suffix missing: {idx}"
2073 );
2074 }
2075
2076 #[test]
2077 fn discover_scripts_rb_js_extensions() {
2078 let tmp = tempfile::TempDir::new().unwrap();
2079 let scripts = tmp.path().join("scripts");
2080 fs::create_dir(&scripts).unwrap();
2081 fs::write(scripts.join("lint.rb"), "# ruby lint\nputs 1").unwrap();
2082 fs::write(scripts.join("build.js"), "// js build\nconsole.log(1)").unwrap();
2083 fs::write(scripts.join("noise.bin"), "binary").unwrap();
2084 let found = discover_scripts(tmp.path());
2085 let names: Vec<&str> = found.iter().map(|s| s.name.as_str()).collect();
2086 assert!(
2087 names.contains(&"lint"),
2088 "lint.rb must be discovered: {names:?}"
2089 );
2090 assert!(
2091 names.contains(&"build"),
2092 "build.js must be discovered: {names:?}"
2093 );
2094 assert!(
2095 !names.contains(&"noise"),
2096 ".bin without exec bit must be skipped: {names:?}"
2097 );
2098 }
2099
2100 #[test]
2101 #[cfg(unix)]
2102 fn discover_scripts_includes_executable_without_ext() {
2103 use std::os::unix::fs::PermissionsExt;
2104 let tmp = tempfile::TempDir::new().unwrap();
2105 let scripts = tmp.path().join("scripts");
2106 fs::create_dir(&scripts).unwrap();
2107 let bin = scripts.join("runme");
2108 fs::write(&bin, "#!/bin/sh\necho hi").unwrap();
2109 let mut perms = fs::metadata(&bin).unwrap().permissions();
2110 perms.set_mode(0o755);
2111 fs::set_permissions(&bin, perms).unwrap();
2112 let found = discover_scripts(tmp.path());
2113 assert!(
2114 found.iter().any(|s| s.name == "runme"),
2115 "chmod+x file without script ext must be included: {found:?}"
2116 );
2117 }
2118}