Skip to main content

recursive/
skills.rs

1//! Skill system: file-based capability extension.
2//!
3//! Skills are markdown files in specific directories that can be loaded
4//! on-demand to extend the agent's capabilities.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Injection mode for a skill.
10#[derive(Debug, Clone, PartialEq, Default)]
11pub enum SkillMode {
12    /// Inject into system prompt at session start.
13    Always,
14    /// Auto-load when trigger words appear in the user goal.
15    Trigger,
16    /// Agent must explicitly call load_skill (current behavior).
17    #[default]
18    Manual,
19    /// Auto-inject when a tool result references a file path matching one of
20    /// the skill's `globs` patterns (Goal 318). Each skill is injected at most
21    /// once per agent run, tracked by [`SkillInjector`].
22    Globs,
23}
24
25/// A discovered skill with metadata.
26#[derive(Debug, Clone)]
27pub struct Skill {
28    /// Human-readable name, e.g., "rust-traits".
29    pub name: String,
30    /// Brief description for the skill index.
31    pub description: String,
32    /// Absolute path to the SKILL.md file.
33    pub path: PathBuf,
34    /// Injection mode.
35    pub mode: SkillMode,
36    /// Trigger words (only relevant when mode == Trigger).
37    pub triggers: Vec<String>,
38    /// Hint string for trigger-mode skills (short description for injection).
39    /// Auto-generated from description if absent for trigger mode.
40    pub hint: String,
41    /// Skills that should be auto-loaded before this one.
42    pub depends_on: Vec<String>,
43    /// Reference documents found in <skill_dir>/refs/
44    pub refs: Vec<SkillRef>,
45    /// Parameters declared in frontmatter.
46    pub params: Vec<SkillParam>,
47    /// Executable scripts found in <skill_dir>/scripts/
48    pub scripts: Vec<SkillScript>,
49    /// Named sections within the skill body (parsed from ## headings).
50    pub sections: Vec<SkillSection>,
51    /// Glob patterns for `mode: globs` (Goal 318).
52    /// E.g. `["src/tools/**", "src/runtime.rs"]`.
53    /// `None` when mode is not Globs (or globs list is empty/absent).
54    pub globs: Option<Vec<String>>,
55}
56
57/// A named section within a skill's body, delimited by `## Section Name`.
58#[derive(Debug, Clone)]
59pub struct SkillSection {
60    /// Section name (the text after `## `, trimmed).
61    pub name: String,
62    /// Content of the section (everything between this heading and the next
63    /// heading of the same or higher level, trimmed).
64    pub content: String,
65}
66
67/// A reference document within a skill's `refs/` directory.
68#[derive(Debug, Clone)]
69pub struct SkillRef {
70    /// Filename without extension, e.g. "api-spec"
71    pub name: String,
72    /// Absolute path to the ref file
73    pub path: PathBuf,
74}
75
76/// A parameter declared in a skill's frontmatter.
77#[derive(Debug, Clone)]
78pub struct SkillParam {
79    /// Parameter name, e.g. "language"
80    pub name: String,
81    /// Brief description
82    pub description: String,
83    /// Default value (None if required)
84    pub default: Option<String>,
85}
86
87/// An executable script within a skill's `scripts/` directory.
88#[derive(Debug, Clone)]
89pub struct SkillScript {
90    /// Script name (filename without extension), e.g. "lint"
91    pub name: String,
92    /// Absolute path to the script file
93    pub path: PathBuf,
94    /// Brief description from the first comment line (if present)
95    pub description: String,
96}
97
98/// Discover skills in the given search paths.
99///
100/// For each `<path>/<name>/SKILL.md`, parses optional YAML frontmatter.
101/// If absent, uses the parent directory name as `name` and the first
102/// non-empty line of body as `description`.
103///
104/// Also scans `<skill_dir>/refs/` for `.md` and `.txt` files and populates
105/// `Skill::refs` with what's found. Also scans `<skill_dir>/scripts/` for
106/// executable scripts and populates `Skill::scripts`.
107pub 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
160/// Parse named sections from a skill's body content.
161///
162/// Sections are delimited by `## Section Name` headings (level-2 markdown).
163/// The content of each section runs from after the heading line until the
164/// next heading of the same or higher level, or end of body.
165/// Frontmatter is stripped before parsing.
166fn 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            // Collect content until next ## heading or end
177            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
196/// Scan `<skill_dir>/refs/` for `.md` and `.txt` files.
197fn 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
225/// Scan `<skill_dir>/scripts/` for executable files.
226///
227/// Accepts files with execute permission (Unix) or common script extensions:
228/// `.sh`, `.py`, `.rb`, `.js`. Extracts a description from the first comment
229/// line (shebang excluded).
230fn 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            // Check if it's executable (Unix) or has a known script extension
245            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/// Check if a file has execute permission (Unix-only).
271#[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
284/// Extract a description from the first comment line of a script.
285///
286/// Reads the first line. If it starts with `#!` (shebang), reads the second
287/// line. If a line starts with `#` or `//`, returns it (with the comment
288/// prefix stripped). Otherwise returns empty string.
289fn 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    // Skip shebang line
299    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/// Parse YAML frontmatter (if present) from skill content.
320///
321/// Returns (name, description, mode, triggers, hint, params). If frontmatter is
322/// absent, falls back to using the parent directory name and first non-empty
323/// Returns (name, description, mode, triggers, hint, depends_on, params). If frontmatter is
324/// absent, falls back to using the parent directory name and first non-empty
325/// line, with default mode (Manual), empty triggers, empty hint, and empty depends_on.
326/// Returns `(name, description, mode, triggers, hint, depends_on, params, globs)`.
327#[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    // Try to extract YAML frontmatter: --- ... ---
342    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            // Parse naive key: value pairs
348            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                    // Parse YAML list: each entry is "  - pattern"
377                    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                            // End of list — don't advance, let outer loop re-read
390                            break;
391                        }
392                    }
393                    continue;
394                } else if let Some(stripped) = line.strip_prefix("triggers:") {
395                    // Parse comma-separated trigger words
396                    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                    // Parse comma-separated dependency names
403                    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                    // Parse params list: each entry starts with "  - name: xxx"
410                    i += 1;
411                    while i < lines.len() {
412                        let entry_line = lines[i];
413                        // Check if this line starts a new param entry: "  - name: xxx"
414                        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                            // Look at subsequent lines for description and default
420                            i += 1;
421                            while i < lines.len() {
422                                let sub_line = lines[i];
423                                let trimmed = sub_line.trim();
424                                // Stop if we hit a new top-level key (no indent) or a new list entry
425                                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                                    // Next param entry — back up so outer loop re-processes
434                                    i -= 1;
435                                    break;
436                                } else if !trimmed.is_empty() && !trimmed.starts_with('-') {
437                                    // Unknown indented line — skip
438                                }
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                            // Not a param entry — skip to next line
449                            i += 1;
450                        }
451                    }
452                    // After params block, break out of top-level loop
453                    break;
454                }
455                i += 1;
456            }
457
458            // Use frontmatter values if present, otherwise fall back to defaults
459            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            // Auto-generate hint for trigger-mode skills if not explicitly set
475            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            // Normalise: Globs mode with no patterns → Manual
481            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    // No frontmatter - use directory name and first non-empty line
500    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
524/// Select skills whose mode is `Always`, plus any `Trigger` skills whose
525/// triggers match the given goal text.
526///
527/// Returns `Vec<(name, body_or_hint)>` of matching skills.
528/// For `Always` mode, the full body is returned.
529/// For `Trigger` mode, the hint is returned (short description).
530pub 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                // Read the body from the SKILL.md file
537                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                // Check if any trigger matches the goal (case-insensitive)
544                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                    // Inject the hint (short description) instead of full body
551                    result.push((skill.name.clone(), skill.hint.clone()));
552                }
553            }
554            SkillMode::Manual | SkillMode::Globs => {
555                // Manual: never auto-injected; agent must call load_skill.
556                // Globs: injected by SkillInjector after matching tool results, not here.
557            }
558        }
559    }
560
561    result
562}
563
564/// Extract the body of a SKILL.md file, stripping YAML frontmatter if present.
565pub 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
578/// Default byte budget for `skill_index` rendering.
579///
580/// Overridden by `RECURSIVE_SKILL_INDEX_BUDGET` env var. Reads on every
581/// call so test setups that toggle the env var take effect immediately.
582pub 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
590/// Per-entry description truncation cap used when the full rendering
591/// exceeds the budget (matches Claude Code's `MAX_LISTING_DESC_CHARS`).
592/// Operates on bytes via [`crate::truncate_str`] for char-boundary safety.
593const SKILL_INDEX_PER_ENTRY_DESC_BYTES: usize = 250;
594
595/// Render a compact "available skills" block for the system prompt.
596///
597/// Returns empty string if no skills found. Honors a byte budget —
598/// when the full rendering exceeds the budget, per-entry descriptions
599/// are truncated; if still too long, descriptions are dropped entirely
600/// and only the mode tag + name remain. The budget is read from
601/// `RECURSIVE_SKILL_INDEX_BUDGET` (default 8000).
602pub fn skill_index(skills: &[Skill]) -> String {
603    skill_index_with_budget(skills, default_skill_index_budget())
604}
605
606/// Render the available-skills catalog as a `<system-reminder>` block.
607///
608/// The skill list is volatile (skills load/unload, descriptions edit) and
609/// long, so it does NOT belong in the static `system` prompt — inlining it
610/// there breaks prefix-cache stability on every skill change. Instead we
611/// ship it per-turn as a `system-reminder` (placed in a user turn, which is
612/// how Anthropic expects it), budget-truncated and refreshed each turn. This
613/// mirrors how fake-cc delivers its skill catalog: the Skill tool's
614/// description only points at the reminder, the catalog itself lives
615/// out-of-system-prompt. Callers inject the returned string into the request
616/// (see `crate::run_core::call_llm`) without mutating the transcript.
617pub 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
625/// Render `skill_index` constrained to `byte_budget` bytes total.
626///
627/// When the total rendered length fits, the output is identical to the
628/// unconstrained format. When it overflows, descriptions are progressively
629/// truncated (char-boundary-safe via [`crate::truncate_str`]) and finally
630/// dropped, preserving the mode tag and any structural suffixes
631/// (refs/params/sections/depends_on/scripts). The budget is measured in
632/// bytes (`str::len`), matching the per-entry truncation cap
633/// [`SKILL_INDEX_PER_ENTRY_DESC_BYTES`]; this is a conservative proxy for
634/// prompt-token cost (multi-byte/CJK content counts toward the budget
635/// faster, so it is truncated sooner).
636pub fn skill_index_with_budget(skills: &[Skill], byte_budget: usize) -> String {
637    if skills.is_empty() {
638        return String::new();
639    }
640
641    // Phase 1: full render — preserves the prior behavior when under budget.
642    let full = render_skill_index(skills, |s| s.description.clone());
643    if full.len() <= byte_budget {
644        return full;
645    }
646
647    // Phase 2: truncate per-entry descriptions to SKILL_INDEX_PER_ENTRY_DESC_BYTES
648    // bytes (≈ 250 ASCII chars), char-boundary-safe.
649    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    // Phase 3: drop descriptions entirely; names-only listing with mode tag.
664    // Even if this still exceeds the budget (degenerate: thousands of skills),
665    // return it — further compression would lose useful info.
666    render_skill_index(skills, |_| String::new())
667}
668
669/// Render the skill index, calling `desc(skill)` to obtain the per-entry
670/// description. When `desc` returns empty, the description field is omitted
671/// from the rendered line (used by the names-only fallback in phase 3).
672/// The mode tag and structural suffixes are always emitted.
673fn 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        // Mode tag
686        let mode_tag = match skill.mode {
687            SkillMode::Always => "[always]",
688            SkillMode::Trigger => "[trigger]",
689            SkillMode::Globs => "[globs]",
690            SkillMode::Manual => "",
691        };
692
693        // Ref count
694        let ref_count = skill.refs.len();
695        if ref_count > 0 {
696            suffix_parts.push(format!("{ref_count} refs"));
697        }
698
699        // Params
700        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        // Sections
716        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        // Depends on
722        if !skill.depends_on.is_empty() {
723            suffix_parts.push(format!("depends_on: {}", skill.depends_on.join(", ")));
724        }
725
726        // Scripts
727        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            // Names-only fallback (phase 3): keep mode tag, drop `: description`.
732            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        // Append scripts suffix if present (after the main line)
750        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        // Create skill with YAML frontmatter
775        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        // Create skill without frontmatter (name from dir, desc from body)
789        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        // Create a skill with refs
855        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        // Create refs directory with some files
864        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        // Non-matching extension should be ignored
869        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        // Create a skill without refs directory
895        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        // Create a skill with scripts
1084        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        // Create scripts directory
1093        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        // Non-script extension should be ignored
1106        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        // Create a skill without scripts directory
1134        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    // --- Mode & trigger tests ---
1232
1233    #[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        // Hint should be auto-generated for trigger mode
1263        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        // Trigger mode should return hint, not full body
1342        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        // Globs-mode skills must NOT be injected at session start
1401        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    // --- Section parsing tests ---
1462
1463    #[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    // --- Goal-319: Budget-aware skill_index tests ------------------------
1564
1565    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    /// Phase-1 path: a small skill set renders identically to the prior
1583    /// unconstrained behavior when total length fits within the budget.
1584    #[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        // Render with a generous budget that the small set easily fits.
1591        let rendered = skill_index_with_budget(&skills, 5000);
1592        // Headers, names, and full descriptions must all be present,
1593        // no truncation.
1594        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    /// Phase-2 path: many skills with long descriptions fit under the
1604    /// budget only after per-entry truncation. Truncation uses
1605    /// [`crate::truncate_str`], so multi-byte chars at the boundary are safe.
1606    #[test]
1607    fn skill_index_over_budget_truncates_descriptions() {
1608        // Each description is 1000 ASCII chars; 5 skills → phase 1 ≈ 5111
1609        // bytes (over budget 2000), phase 2 ≈ 1378 bytes (fits 2000).
1610        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        // Every truncated description ends with the ellipsis added in phase 2.
1622        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        // Phase-2 cap is 250 bytes (ASCII), so each truncated description
1631        // substring before the `…` is at most 250 bytes.
1632        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    /// Phase-3 path: many skills with long descriptions cannot fit even
1646    /// when truncated → the listing degrades to `- <name>` lines with
1647    /// the mode tag preserved.
1648    #[test]
1649    fn skill_index_severely_over_budget_falls_back_to_names_only() {
1650        // 10 skills × 1000-byte descs; budget=200 forces phase 3 (names-only):
1651        //   phase 1 ≈ 10173 bytes
1652        //   phase 2 ≈ 2703 bytes
1653        //   phase 3 ≈ 163 bytes  → fits the 200-byte budget
1654        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        // Descriptions are gone, so no `:` after the name and no `…`.
1667        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    /// Regression: descriptions containing multi-byte (CJK) chars near the
1678    /// truncation boundary must not panic. Earlier tools in this slot
1679    /// byte-sliced `&desc[..N]` and panicked when byte `N` fell inside
1680    /// a 3-byte CJK codepoint. The new code uses [`crate::truncate_str`].
1681    #[test]
1682    fn skill_index_multibyte_description_truncation_no_panic() {
1683        // Pack CJK chars near the 250-byte boundary so naive byte-slicing
1684        // would panic; the char-boundary-safe path must not.
1685        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        // Single-skill budget large enough to trigger phase-2 truncation but
1694        // not phase-3 (names-only). Use a budget equal to the truncated size
1695        // plus a small header allowance.
1696        let header_overhead = 80;
1697        let rendered =
1698            skill_index_with_budget(&skills, SKILL_INDEX_PER_ENTRY_DESC_BYTES + header_overhead);
1699        // Must contain the skill name and an ellipsis (phase 2 marker).
1700        assert!(rendered.contains("skill-0"), "{rendered}");
1701        assert!(
1702            rendered.contains('…'),
1703            "expected truncation ellipsis: {rendered}"
1704        );
1705        // The fact that `rendered` is a valid `String` already proves no
1706        // mid-codepoint cut; this assertion documents the intent.
1707        assert!(std::str::from_utf8(rendered.as_bytes()).is_ok());
1708    }
1709
1710    /// Consolidated env-var test for the default budget. Holds the env lock
1711    /// so the test serializes against any other env-mutating test (see
1712    /// AGENTS.md "Env-var tests must be ONE test").
1713    #[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        // Unset → default 8000
1719        std::env::remove_var("RECURSIVE_SKILL_INDEX_BUDGET");
1720        assert_eq!(default_skill_index_budget(), 8000);
1721
1722        // Valid value → honored
1723        std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "1234");
1724        assert_eq!(default_skill_index_budget(), 1234);
1725
1726        // Garbage value → falls back to default (filter rejects non-parseable)
1727        std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "not-a-number");
1728        assert_eq!(default_skill_index_budget(), 8000);
1729
1730        // Zero → falls back to default (filter rejects n > 0)
1731        std::env::set_var("RECURSIVE_SKILL_INDEX_BUDGET", "0");
1732        assert_eq!(default_skill_index_budget(), 8000);
1733
1734        // Restore (or clear if it was unset originally)
1735        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    // ── parse_skill_meta unit tests ──────────────────────────────────────────
1743
1744    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        // Auto-generated hint: "name: description"
1791        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        // Globs mode with no patterns must fall back to Manual
1830        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    // ── discover_refs unit tests ─────────────────────────────────────────────
1844
1845    #[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    // ── discover_scripts unit tests ──────────────────────────────────────────
1880
1881    #[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        // A file with no script extension and no execute bit should be skipped
1896        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    // ── extract_script_description unit tests ───────────────────────────────
1906
1907    #[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    // ── is_executable unit tests ─────────────────────────────────────────────
1944
1945    #[test]
1946    #[cfg(unix)]
1947    fn is_executable_non_executable_file_returns_false() {
1948        let tmp = tempfile::NamedTempFile::new().unwrap();
1949        // Default NamedTempFile has no execute bit
1950        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    // ── mutant-debt pins (2026-07-09) ────────────────────────────────────────
1969
1970    #[test]
1971    fn discover_skills_skips_invalid_entries() {
1972        let tmp = tempfile::TempDir::new().unwrap();
1973        // File at search-path root (not a skill dir) — must be skipped.
1974        fs::write(tmp.path().join("README.md"), "noise").unwrap();
1975        // Subdir without SKILL.md — must be skipped.
1976        fs::create_dir(tmp.path().join("empty-dir")).unwrap();
1977        // Valid skill.
1978        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        // Non-directory search path — must be skipped entirely.
1986        let not_a_dir = tmp.path().join("not-a-dir.txt");
1987        fs::write(&not_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        // Unclosed frontmatter falls through to full trim.
2006        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}