Skip to main content

recursive/tools/
load_skill.rs

1//! Tool to load a skill's content by name.
2//!
3//! Supports loading the main SKILL.md body, or an individual reference
4//! document from the skill's `refs/` directory via the optional `ref`
5//! parameter, or a named section from the skill body via the optional
6//! `section` parameter. Also supports parameter substitution via the
7//! `params` object.
8//!
9//! When loading a skill (without `ref` or `section`), any skills listed in its
10//! `depends_on` frontmatter field are resolved recursively (up to 3
11//! levels deep) and prepended to the output. Circular dependencies are
12//! detected and skipped with a warning.
13
14use std::collections::HashSet;
15use std::fs;
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use serde_json::{json, Value};
20
21use crate::error::{Error, Result};
22use crate::llm::ToolSpec;
23use crate::skills::Skill;
24use crate::tools::Tool;
25
26/// Maximum depth for dependency resolution.
27const MAX_DEPTH: usize = 3;
28
29/// Tool to load a skill's SKILL.md body content, or a specific ref document,
30/// or a named section.
31pub struct LoadSkill {
32    skills: Arc<Vec<Skill>>,
33}
34
35impl LoadSkill {
36    pub fn new(skills: Vec<Skill>) -> Self {
37        Self {
38            skills: Arc::new(skills),
39        }
40    }
41
42    /// Resolve dependencies for a skill, returning a list of (name, body) pairs
43    /// in dependency-first order (breadth-first from leaves).
44    ///
45    /// `visited` tracks names already seen in the current resolution chain
46    /// (for circular detection). `depth` tracks how deep we are.
47    fn resolve_deps(
48        &self,
49        skill: &Skill,
50        visited: &mut HashSet<String>,
51        depth: usize,
52    ) -> Result<Vec<(String, String)>> {
53        if depth > MAX_DEPTH {
54            return Err(Error::Tool {
55                name: "Skill".into(),
56                call_id: None,
57                message: "dependency tree too deep (max 3 levels)".to_string(),
58            });
59        }
60
61        let mut deps = Vec::new();
62
63        for dep_name in &skill.depends_on {
64            // Circular detection
65            if !visited.insert(dep_name.to_lowercase()) {
66                // Already in the chain — circular dependency
67                deps.push((
68                    dep_name.clone(),
69                    format!(
70                        "[WARNING: circular dependency detected, skipping {}]",
71                        dep_name
72                    ),
73                ));
74                continue;
75            }
76
77            // Find the dependency skill (case-insensitive)
78            let dep_skill = self
79                .skills
80                .iter()
81                .find(|s| s.name.to_lowercase() == dep_name.to_lowercase())
82                .ok_or_else(|| Error::Tool {
83                    name: "Skill".into(),
84                    call_id: None,
85                    message: format!(
86                        "dependency '{}' not found (required by '{}')",
87                        dep_name, skill.name
88                    ),
89                })?;
90
91            // Recursively resolve the dependency's own dependencies first
92            let sub_deps = self.resolve_deps(dep_skill, visited, depth + 1)?;
93            deps.extend(sub_deps);
94
95            // Read the dependency's body
96            let content = fs::read_to_string(&dep_skill.path).map_err(|e| Error::Tool {
97                name: "Skill".into(),
98                call_id: None,
99                message: format!("failed to read dependency '{}': {e}", dep_skill.name),
100            })?;
101
102            let body = content
103                .strip_prefix("---")
104                .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
105                .unwrap_or(content.trim())
106                .to_string();
107
108            deps.push((dep_skill.name.clone(), body));
109
110            // Remove from visited so sibling branches can reference the same skill
111            visited.remove(&dep_name.to_lowercase());
112        }
113
114        Ok(deps)
115    }
116}
117
118#[async_trait]
119impl Tool for LoadSkill {
120    fn spec(&self) -> ToolSpec {
121        ToolSpec {
122            name: "Skill".into(),
123            description: "Load a skill's content by name (case-insensitive). Optionally load a reference document from the skill's refs/ directory, or a named section from the skill body. Pass params for template substitution.".into(),
124            parameters: json!({
125                "type": "object",
126                "properties": {
127                    "name": {
128                        "type": "string",
129                        "description": "Name of the skill to load"
130                    },
131                    "ref": {
132                        "type": "string",
133                        "description": "Optional name of a reference document to load (e.g. 'api-spec'). Use `Skill` without `ref` first to see available refs."
134                    },
135                    "section": {
136                        "type": "string",
137                        "description": "Optional name of a section to load (e.g. 'Overview'). Sections are delimited by ## headings in the skill body. Use `Skill` without `section` first to see available sections."
138                    },
139                    "params": {
140                        "type": "object",
141                        "description": "Optional parameter values for template substitution (e.g. {\"language\": \"python\"}). See skill index for declared params.",
142                        "additionalProperties": {
143                            "type": "string"
144                        }
145                    }
146                },
147                "required": ["name"]
148            }),
149        }
150    }
151
152    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
153        crate::tools::ToolSideEffect::ReadOnly
154    }
155
156    async fn execute(&self, arguments: Value) -> Result<String> {
157        let name = arguments["name"]
158            .as_str()
159            .ok_or_else(|| Error::BadToolArgs {
160                name: "Skill".into(),
161                message: "missing required parameter: name".to_string(),
162            })?;
163
164        // Case-insensitive search
165        let skill = self
166            .skills
167            .iter()
168            .find(|s| s.name.to_lowercase() == name.to_lowercase())
169            .ok_or_else(|| Error::Tool {
170                name: "Skill".into(),
171                call_id: None,
172                message: format!("skill not found: {name}"),
173            })?;
174
175        // Check if a specific ref is requested
176        if let Some(ref_name) = arguments["ref"].as_str() {
177            let ref_name = ref_name.trim();
178            if ref_name.is_empty() {
179                return Err(Error::BadToolArgs {
180                    name: "Skill".into(),
181                    message: "ref parameter must be a non-empty string".to_string(),
182                });
183            }
184
185            // Case-insensitive search in refs
186            let skill_ref = skill
187                .refs
188                .iter()
189                .find(|r| r.name.to_lowercase() == ref_name.to_lowercase())
190                .ok_or_else(|| {
191                    let available: Vec<&str> = skill.refs.iter().map(|r| r.name.as_str()).collect();
192                    let available_list = if available.is_empty() {
193                        "no refs available for this skill".to_string()
194                    } else {
195                        format!("available refs: {}", available.join(", "))
196                    };
197                    Error::Tool {
198                        name: "Skill".into(),
199                        call_id: None,
200                        message: format!("ref not found: '{ref_name}'. {available_list}"),
201                    }
202                })?;
203
204            let content = fs::read_to_string(&skill_ref.path).map_err(|e| Error::Tool {
205                name: "Skill".into(),
206                call_id: None,
207                message: format!("failed to read ref file: {e}"),
208            })?;
209
210            return Ok(content.trim().to_string());
211        }
212
213        // Check if a specific section is requested
214        if let Some(section_name) = arguments["section"].as_str() {
215            let section_name = section_name.trim();
216            if section_name.is_empty() {
217                return Err(Error::BadToolArgs {
218                    name: "Skill".into(),
219                    message: "section parameter must be a non-empty string".to_string(),
220                });
221            }
222
223            // Case-insensitive search in sections
224            let section = skill
225                .sections
226                .iter()
227                .find(|s| s.name.to_lowercase() == section_name.to_lowercase())
228                .ok_or_else(|| {
229                    let available: Vec<&str> =
230                        skill.sections.iter().map(|s| s.name.as_str()).collect();
231                    let available_list = if available.is_empty() {
232                        "no sections available for this skill".to_string()
233                    } else {
234                        format!("available sections: {}", available.join(", "))
235                    };
236                    Error::Tool {
237                        name: "Skill".into(),
238                        call_id: None,
239                        message: format!("section not found: '{section_name}'. {available_list}"),
240                    }
241                })?;
242
243            // Apply param substitution to section content if params provided
244            let provided_params = arguments["params"].as_object();
245            let resolved = resolve_params(skill, provided_params)?;
246
247            let rendered = if resolved.is_empty() {
248                section.content.clone()
249            } else {
250                let mut result = section.content.clone();
251                for (key, value) in &resolved {
252                    result = result.replace(&format!("{{{{{key}}}}}"), value);
253                }
254                result
255            };
256
257            // Substitute ${SKILL_DIR} / ${RECURSIVE_SKILL_DIR} placeholders
258            // so skill authors can reference bundled scripts and refs
259            // (e.g. `bash ${SKILL_DIR}/scripts/lint.sh`). Ref documents
260            // are returned as-is and never receive this substitution —
261            // they may legitimately contain literal `${...}` text.
262            let rendered = substitute_skill_dir(&rendered, skill);
263
264            return Ok(rendered);
265        }
266
267        // No ref or section specified — return the main SKILL.md body
268        let content = fs::read_to_string(&skill.path).map_err(|e| Error::Tool {
269            name: "Skill".into(),
270            call_id: None,
271            message: format!("failed to read skill file: {e}"),
272        })?;
273
274        let body = content
275            .strip_prefix("---")
276            .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
277            .unwrap_or(content.trim())
278            .to_string();
279
280        // Resolve params and perform template substitution
281        let provided_params = arguments["params"].as_object();
282        let resolved = resolve_params(skill, provided_params)?;
283
284        // Perform template substitution: replace {{key}} with value
285        let rendered = if resolved.is_empty() {
286            body
287        } else {
288            let mut result = body;
289            for (key, value) in &resolved {
290                result = result.replace(&format!("{{{{{key}}}}}"), value);
291            }
292            result
293        };
294
295        // Substitute ${SKILL_DIR} / ${RECURSIVE_SKILL_DIR} placeholders
296        // for the requested skill's body only. Dependency bodies are not
297        // recursed into here — they will get their own substitution when
298        // they are loaded by a future `Skill` call (do not recurse).
299        let rendered = substitute_skill_dir(&rendered, skill);
300
301        // Resolve dependencies (if any)
302        let mut visited = HashSet::new();
303        visited.insert(skill.name.to_lowercase());
304        let deps = self.resolve_deps(skill, &mut visited, 1)?;
305
306        if deps.is_empty() {
307            return Ok(rendered);
308        }
309
310        // Build output: dependencies first, then the requested skill
311        let mut output = String::new();
312        for (dep_name, dep_body) in &deps {
313            output.push_str(&format!("=== Dependency: {dep_name} ===\n{dep_body}\n\n"));
314        }
315        output.push_str(&format!("=== Skill: {} ===\n{}", skill.name, rendered));
316
317        Ok(output)
318    }
319}
320
321/// Substitute `${SKILL_DIR}` and `${RECURSIVE_SKILL_DIR}` with the
322/// absolute path of the directory containing the skill's SKILL.md.
323/// Trailing slashes are not added — authors write the slash after the
324/// placeholder (e.g. `${SKILL_DIR}/scripts/lint.sh`) so the resulting
325/// path is well-formed. If `skill.path` has no parent (degenerate case),
326/// the placeholders are left as-is (no panic).
327///
328/// To include a literal `${SKILL_DIR}` in the body (e.g. when a skill
329/// documents the feature itself), prefix it with a backslash:
330/// `\${SKILL_DIR}` renders as the literal text `${SKILL_DIR}`. A lone
331/// backslash that is not followed by one of the two placeholders is
332/// preserved unchanged.
333fn substitute_skill_dir(content: &str, skill: &Skill) -> String {
334    const SKILL_DIR: &str = "${SKILL_DIR}";
335    const RECURSIVE_SKILL_DIR: &str = "${RECURSIVE_SKILL_DIR}";
336
337    let dir: Option<String> = skill.path.parent().map(|p| p.to_string_lossy().to_string());
338
339    let mut out = String::with_capacity(content.len());
340    let mut i = 0;
341    while i < content.len() {
342        let rest = &content[i..];
343        // Escaped placeholder: `\${SKILL_DIR}` / `\${RECURSIVE_SKILL_DIR}`
344        // renders as the literal placeholder, regardless of whether a dir
345        // is available.
346        if let Some(after) = rest.strip_prefix('\\') {
347            if after.starts_with(SKILL_DIR) {
348                out.push_str(SKILL_DIR);
349                i += 1 + SKILL_DIR.len();
350                continue;
351            }
352            if after.starts_with(RECURSIVE_SKILL_DIR) {
353                out.push_str(RECURSIVE_SKILL_DIR);
354                i += 1 + RECURSIVE_SKILL_DIR.len();
355                continue;
356            }
357            // Lone backslash — keep it and let the normal char copy below
358            // handle it, so we don't mis-handle `\` at EOF or before a
359            // multi-byte char.
360        }
361        if let Some(d) = &dir {
362            if rest.starts_with(SKILL_DIR) {
363                out.push_str(d);
364                i += SKILL_DIR.len();
365                continue;
366            }
367            if rest.starts_with(RECURSIVE_SKILL_DIR) {
368                out.push_str(d);
369                i += RECURSIVE_SKILL_DIR.len();
370                continue;
371            }
372        }
373        // Copy one char (advancing to the next char boundary so multi-byte
374        // sequences are preserved intact).
375        let next = i + 1;
376        let mut end = next;
377        while end < content.len() && !content.is_char_boundary(end) {
378            end += 1;
379        }
380        out.push_str(&content[i..end]);
381        i = end;
382    }
383    out
384}
385
386/// Resolve parameter values: use provided values, fall back to defaults,
387/// error on missing required params.
388fn resolve_params(
389    skill: &Skill,
390    provided_params: Option<&serde_json::Map<String, Value>>,
391) -> Result<Vec<(String, String)>> {
392    let mut resolved = Vec::new();
393    let mut missing_required = Vec::new();
394
395    for param in &skill.params {
396        // Check if provided
397        if let Some(obj) = provided_params {
398            if let Some(val) = obj.get(&param.name).and_then(|v| v.as_str()) {
399                resolved.push((param.name.clone(), val.to_string()));
400                continue;
401            }
402        }
403
404        // Fall back to default
405        if let Some(ref default) = param.default {
406            resolved.push((param.name.clone(), default.clone()));
407        } else {
408            // Required param with no value provided
409            missing_required.push(param.name.clone());
410        }
411    }
412
413    if !missing_required.is_empty() {
414        return Err(Error::BadToolArgs {
415            name: "Skill".into(),
416            message: format!(
417                "missing required params: {}. Declared params: {}",
418                missing_required.join(", "),
419                skill
420                    .params
421                    .iter()
422                    .map(|p| {
423                        if let Some(ref d) = p.default {
424                            format!("{}={}", p.name, d)
425                        } else {
426                            format!("{} (required)", p.name)
427                        }
428                    })
429                    .collect::<Vec<_>>()
430                    .join(", ")
431            ),
432        });
433    }
434
435    Ok(resolved)
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::skills::{SkillMode, SkillSection};
442    use std::io::Write;
443    use std::path::PathBuf;
444
445    #[test]
446    fn load_skill_returns_body_for_known_skill() {
447        let tmp = tempfile::TempDir::new().unwrap();
448        let base = tmp.path();
449
450        // Create a skill
451        let rust_dir = base.join("rust-traits");
452        fs::create_dir(&rust_dir).unwrap();
453        let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
454        writeln!(file, "---").unwrap();
455        writeln!(file, "name: rust-traits").unwrap();
456        writeln!(file, "description: Explain traits").unwrap();
457        writeln!(file, "---").unwrap();
458        writeln!(file, "Skill body content here.").unwrap();
459
460        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
461        let tool = LoadSkill::new(skills);
462
463        let result = tokio::runtime::Runtime::new()
464            .unwrap()
465            .block_on(tool.execute(json!({"name": "rust-traits"})));
466
467        assert!(result.is_ok());
468        assert_eq!(result.unwrap(), "Skill body content here.");
469    }
470
471    #[test]
472    fn load_skill_errors_for_unknown_name() {
473        let tmp = tempfile::TempDir::new().unwrap();
474        let base = tmp.path();
475
476        // Create a skill
477        let rust_dir = base.join("rust-traits");
478        fs::create_dir(&rust_dir).unwrap();
479        fs::write(rust_dir.join("SKILL.md"), "Some content").unwrap();
480
481        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
482        let tool = LoadSkill::new(skills);
483
484        let result = tokio::runtime::Runtime::new()
485            .unwrap()
486            .block_on(tool.execute(json!({"name": "nonexistent"})));
487
488        assert!(result.is_err());
489    }
490
491    #[test]
492    fn load_skill_case_insensitive() {
493        let tmp = tempfile::TempDir::new().unwrap();
494        let base = tmp.path();
495
496        let rust_dir = base.join("rust-traits");
497        fs::create_dir(&rust_dir).unwrap();
498        fs::write(rust_dir.join("SKILL.md"), "Body content").unwrap();
499
500        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
501        let tool = LoadSkill::new(skills);
502
503        // Should find with different case
504        let result = tokio::runtime::Runtime::new()
505            .unwrap()
506            .block_on(tool.execute(json!({"name": "RUST-TRAITS"})));
507
508        assert!(result.is_ok());
509        assert_eq!(result.unwrap(), "Body content");
510    }
511
512    #[test]
513    fn load_skill_with_ref_returns_ref_content() {
514        let tmp = tempfile::TempDir::new().unwrap();
515        let base = tmp.path();
516
517        // Create a skill with refs
518        let skill_dir = base.join("my-skill");
519        fs::create_dir(&skill_dir).unwrap();
520        fs::write(
521            skill_dir.join("SKILL.md"),
522            "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
523        )
524        .unwrap();
525
526        let refs_dir = skill_dir.join("refs");
527        fs::create_dir(&refs_dir).unwrap();
528        fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
529
530        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
531        let tool = LoadSkill::new(skills);
532
533        let result = tokio::runtime::Runtime::new()
534            .unwrap()
535            .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
536
537        assert!(result.is_ok());
538        assert_eq!(result.unwrap(), "# API Spec\n\nDetails here.");
539    }
540
541    #[test]
542    fn load_skill_with_unknown_ref_returns_error_with_available() {
543        let tmp = tempfile::TempDir::new().unwrap();
544        let base = tmp.path();
545
546        // Create a skill with refs
547        let skill_dir = base.join("my-skill");
548        fs::create_dir(&skill_dir).unwrap();
549        fs::write(
550            skill_dir.join("SKILL.md"),
551            "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
552        )
553        .unwrap();
554
555        let refs_dir = skill_dir.join("refs");
556        fs::create_dir(&refs_dir).unwrap();
557        fs::write(refs_dir.join("api-spec.md"), "# API Spec").unwrap();
558        fs::write(refs_dir.join("examples.txt"), "Example 1").unwrap();
559
560        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
561        let tool = LoadSkill::new(skills);
562
563        let result = tokio::runtime::Runtime::new()
564            .unwrap()
565            .block_on(tool.execute(json!({"name": "my-skill", "ref": "nonexistent"})));
566
567        assert!(result.is_err());
568        let err = result.unwrap_err().to_string();
569        assert!(
570            err.contains("ref not found"),
571            "error should mention ref not found: {err}"
572        );
573        assert!(
574            err.contains("api-spec") && err.contains("examples"),
575            "error should list available refs: {err}"
576        );
577    }
578
579    #[test]
580    fn load_skill_with_ref_case_insensitive() {
581        let tmp = tempfile::TempDir::new().unwrap();
582        let base = tmp.path();
583
584        let skill_dir = base.join("my-skill");
585        fs::create_dir(&skill_dir).unwrap();
586        fs::write(
587            skill_dir.join("SKILL.md"),
588            "---\nname: my-skill\ndescription: test\n---\n\nBody",
589        )
590        .unwrap();
591
592        let refs_dir = skill_dir.join("refs");
593        fs::create_dir(&refs_dir).unwrap();
594        fs::write(refs_dir.join("API-Spec.md"), "Content").unwrap();
595
596        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
597        let tool = LoadSkill::new(skills);
598
599        // Should find with different case
600        let result = tokio::runtime::Runtime::new()
601            .unwrap()
602            .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
603
604        assert!(result.is_ok());
605        assert_eq!(result.unwrap(), "Content");
606    }
607
608    #[test]
609    fn load_skill_with_empty_ref_returns_error() {
610        let tmp = tempfile::TempDir::new().unwrap();
611        let base = tmp.path();
612
613        let skill_dir = base.join("my-skill");
614        fs::create_dir(&skill_dir).unwrap();
615        fs::write(skill_dir.join("SKILL.md"), "Body").unwrap();
616
617        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
618        let tool = LoadSkill::new(skills);
619
620        let result = tokio::runtime::Runtime::new()
621            .unwrap()
622            .block_on(tool.execute(json!({"name": "my-skill", "ref": ""})));
623
624        assert!(result.is_err());
625    }
626
627    #[test]
628    fn load_skill_with_params_performs_substitution() {
629        let tmp = tempfile::TempDir::new().unwrap();
630        let base = tmp.path();
631
632        let skill_dir = base.join("code-review");
633        fs::create_dir(&skill_dir).unwrap();
634        fs::write(
635            skill_dir.join("SKILL.md"),
636            "---\n\
637             name: code-review\n\
638             description: Review code\n\
639             params:\n\
640             \x20\x20- name: language\n\
641             \x20\x20  description: Target language\n\
642             \x20\x20  default: rust\n\
643             ---\n\
644             \n\
645             Review {{language}} code.",
646        )
647        .unwrap();
648
649        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
650        let tool = LoadSkill::new(skills);
651
652        let result = tokio::runtime::Runtime::new().unwrap().block_on(
653            tool.execute(json!({"name": "code-review", "params": {"language": "python"}})),
654        );
655
656        assert!(result.is_ok());
657        assert_eq!(result.unwrap(), "Review python code.");
658    }
659
660    #[test]
661    fn load_skill_uses_defaults_when_params_not_provided() {
662        let tmp = tempfile::TempDir::new().unwrap();
663        let base = tmp.path();
664
665        let skill_dir = base.join("code-review");
666        fs::create_dir(&skill_dir).unwrap();
667        fs::write(
668            skill_dir.join("SKILL.md"),
669            "---\n\
670             name: code-review\n\
671             description: Review code\n\
672             params:\n\
673             \x20\x20- name: language\n\
674             \x20\x20  description: Target language\n\
675             \x20\x20  default: rust\n\
676             ---\n\
677             \n\
678             Review {{language}} code.",
679        )
680        .unwrap();
681
682        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
683        let tool = LoadSkill::new(skills);
684
685        // No params provided — should use default "rust"
686        let result = tokio::runtime::Runtime::new()
687            .unwrap()
688            .block_on(tool.execute(json!({"name": "code-review"})));
689
690        assert!(result.is_ok());
691        assert_eq!(result.unwrap(), "Review rust code.");
692    }
693
694    #[test]
695    fn load_skill_errors_on_missing_required_param() {
696        let tmp = tempfile::TempDir::new().unwrap();
697        let base = tmp.path();
698
699        let skill_dir = base.join("greeter");
700        fs::create_dir(&skill_dir).unwrap();
701        fs::write(
702            skill_dir.join("SKILL.md"),
703            "---\n\
704             name: greeter\n\
705             description: Greet someone\n\
706             params:\n\
707             \x20\x20- name: name\n\
708             \x20\x20  description: Name to greet\n\
709             ---\n\
710             \n\
711             Hello {{name}}!",
712        )
713        .unwrap();
714
715        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
716        let tool = LoadSkill::new(skills);
717
718        // No params provided and no default — should error
719        let result = tokio::runtime::Runtime::new()
720            .unwrap()
721            .block_on(tool.execute(json!({"name": "greeter"})));
722
723        assert!(result.is_err());
724        let err = result.unwrap_err().to_string();
725        assert!(
726            err.contains("missing required params"),
727            "error should mention missing required params: {err}"
728        );
729        assert!(
730            err.contains("name"),
731            "error should mention the missing param name: {err}"
732        );
733    }
734
735    #[test]
736    fn load_skill_with_params_substitutes_multiple() {
737        let tmp = tempfile::TempDir::new().unwrap();
738        let base = tmp.path();
739
740        let skill_dir = base.join("multi-param");
741        fs::create_dir(&skill_dir).unwrap();
742        fs::write(
743            skill_dir.join("SKILL.md"),
744            "---\n\
745             name: multi-param\n\
746             description: Multiple params\n\
747             params:\n\
748             \x20\x20- name: lang\n\
749             \x20\x20  description: Language\n\
750             \x20\x20  default: rust\n\
751             \x20\x20- name: mode\n\
752             \x20\x20  description: Mode\n\
753             \x20\x20  default: strict\n\
754             ---\n\
755             \n\
756             Lang={{lang}} Mode={{mode}}",
757        )
758        .unwrap();
759
760        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
761        let tool = LoadSkill::new(skills);
762
763        let result =
764            tokio::runtime::Runtime::new()
765                .unwrap()
766                .block_on(tool.execute(
767                    json!({"name": "multi-param", "params": {"lang": "python", "mode": "lax"}}),
768                ));
769
770        assert!(result.is_ok());
771        assert_eq!(result.unwrap(), "Lang=python Mode=lax");
772    }
773
774    // --- Section loading tests ---
775
776    #[test]
777    fn load_skill_with_section_returns_section_content() {
778        let tmp = tempfile::TempDir::new().unwrap();
779        let base = tmp.path();
780
781        let skill_dir = base.join("sectioned-skill");
782        fs::create_dir(&skill_dir).unwrap();
783        fs::write(
784            skill_dir.join("SKILL.md"),
785            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
786        )
787        .unwrap();
788
789        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
790        let tool = LoadSkill::new(skills);
791
792        let result = tokio::runtime::Runtime::new()
793            .unwrap()
794            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Overview"})));
795
796        assert!(result.is_ok());
797        assert_eq!(result.unwrap(), "General info.");
798    }
799
800    #[test]
801    fn load_skill_with_section_case_insensitive() {
802        let tmp = tempfile::TempDir::new().unwrap();
803        let base = tmp.path();
804
805        let skill_dir = base.join("sectioned-skill");
806        fs::create_dir(&skill_dir).unwrap();
807        fs::write(
808            skill_dir.join("SKILL.md"),
809            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
810        )
811        .unwrap();
812
813        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
814        let tool = LoadSkill::new(skills);
815
816        let result = tokio::runtime::Runtime::new()
817            .unwrap()
818            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "overview"})));
819
820        assert!(result.is_ok());
821        assert_eq!(result.unwrap(), "General info.");
822    }
823
824    #[test]
825    fn load_skill_with_unknown_section_returns_error_with_available() {
826        let tmp = tempfile::TempDir::new().unwrap();
827        let base = tmp.path();
828
829        let skill_dir = base.join("sectioned-skill");
830        fs::create_dir(&skill_dir).unwrap();
831        fs::write(
832            skill_dir.join("SKILL.md"),
833            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
834        )
835        .unwrap();
836
837        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
838        let tool = LoadSkill::new(skills);
839
840        let result = tokio::runtime::Runtime::new()
841            .unwrap()
842            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Nonexistent"})));
843
844        assert!(result.is_err());
845        let err = result.unwrap_err().to_string();
846        assert!(
847            err.contains("section not found"),
848            "error should mention section not found: {err}"
849        );
850        assert!(
851            err.contains("Overview") && err.contains("Details"),
852            "error should list available sections: {err}"
853        );
854    }
855
856    #[test]
857    fn load_skill_with_empty_section_returns_error() {
858        let tmp = tempfile::TempDir::new().unwrap();
859        let base = tmp.path();
860
861        let skill_dir = base.join("sectioned-skill");
862        fs::create_dir(&skill_dir).unwrap();
863        fs::write(
864            skill_dir.join("SKILL.md"),
865            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
866        )
867        .unwrap();
868
869        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
870        let tool = LoadSkill::new(skills);
871
872        let result = tokio::runtime::Runtime::new()
873            .unwrap()
874            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": ""})));
875
876        assert!(result.is_err());
877    }
878
879    #[test]
880    fn load_skill_with_section_and_params_performs_substitution() {
881        let tmp = tempfile::TempDir::new().unwrap();
882        let base = tmp.path();
883
884        let skill_dir = base.join("template-skill");
885        fs::create_dir(&skill_dir).unwrap();
886        fs::write(
887            skill_dir.join("SKILL.md"),
888            "---\n\
889             name: template-skill\n\
890             description: Has templates\n\
891             params:\n\
892             \x20\x20- name: lang\n\
893             \x20\x20  description: Language\n\
894             \x20\x20  default: rust\n\
895             ---\n\
896             \n\
897             ## Overview\n\
898             \n\
899             Review {{lang}} code.\n\
900             \n\
901             ## Details\n\
902             \n\
903             More about {{lang}}.",
904        )
905        .unwrap();
906
907        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
908        let tool = LoadSkill::new(skills);
909
910        let result = tokio::runtime::Runtime::new()
911            .unwrap()
912            .block_on(tool.execute(
913            json!({"name": "template-skill", "section": "Overview", "params": {"lang": "python"}}),
914        ));
915
916        assert!(result.is_ok());
917        assert_eq!(result.unwrap(), "Review python code.");
918    }
919
920    // --- ${SKILL_DIR} substitution tests (Goal 320) ---
921
922    #[test]
923    fn load_skill_substitutes_skill_dir_in_body() {
924        let tmp = tempfile::TempDir::new().unwrap();
925        let base = tmp.path();
926
927        let skill_dir = base.join("my-skill");
928        fs::create_dir(&skill_dir).unwrap();
929        fs::write(
930            skill_dir.join("SKILL.md"),
931            "---\nname: my-skill\ndescription: A skill that runs a script\n---\n\n\
932             Run the linter with: `bash ${SKILL_DIR}/scripts/lint.sh`",
933        )
934        .unwrap();
935
936        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
937        let expected_dir = skills[0]
938            .path
939            .parent()
940            .unwrap()
941            .to_string_lossy()
942            .to_string();
943        let tool = LoadSkill::new(skills);
944
945        let result = tokio::runtime::Runtime::new()
946            .unwrap()
947            .block_on(tool.execute(json!({"name": "my-skill"})));
948
949        assert!(result.is_ok());
950        let expected = format!("Run the linter with: `bash {expected_dir}/scripts/lint.sh`");
951        assert_eq!(result.unwrap(), expected);
952    }
953
954    #[test]
955    fn load_skill_substitutes_recursive_skill_dir_alias() {
956        let tmp = tempfile::TempDir::new().unwrap();
957        let base = tmp.path();
958
959        let skill_dir = base.join("my-skill");
960        fs::create_dir(&skill_dir).unwrap();
961        fs::write(
962            skill_dir.join("SKILL.md"),
963            "---\nname: my-skill\ndescription: A skill that runs a script\n---\n\n\
964             Run with `${RECURSIVE_SKILL_DIR}/scripts/lint.sh`.",
965        )
966        .unwrap();
967
968        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
969        let expected_dir = skills[0]
970            .path
971            .parent()
972            .unwrap()
973            .to_string_lossy()
974            .to_string();
975        let tool = LoadSkill::new(skills);
976
977        let result = tokio::runtime::Runtime::new()
978            .unwrap()
979            .block_on(tool.execute(json!({"name": "my-skill"})));
980
981        assert!(result.is_ok());
982        let expected = format!("Run with `{expected_dir}/scripts/lint.sh`.");
983        assert_eq!(result.unwrap(), expected);
984    }
985
986    #[test]
987    fn load_skill_substitutes_skill_dir_in_section() {
988        let tmp = tempfile::TempDir::new().unwrap();
989        let base = tmp.path();
990
991        let skill_dir = base.join("my-skill");
992        fs::create_dir(&skill_dir).unwrap();
993        fs::write(
994            skill_dir.join("SKILL.md"),
995            "---\nname: my-skill\ndescription: Has a script-running section\n---\n\n\
996             ## Overview\n\n\
997             Intro without placeholders.\n\n\
998             ## Run\n\n\
999             Execute `${SKILL_DIR}/scripts/run.sh` to start.",
1000        )
1001        .unwrap();
1002
1003        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1004        let expected_dir = skills[0]
1005            .path
1006            .parent()
1007            .unwrap()
1008            .to_string_lossy()
1009            .to_string();
1010        let tool = LoadSkill::new(skills);
1011
1012        let result = tokio::runtime::Runtime::new()
1013            .unwrap()
1014            .block_on(tool.execute(json!({"name": "my-skill", "section": "Run"})));
1015
1016        assert!(result.is_ok());
1017        let expected = format!("Execute `{expected_dir}/scripts/run.sh` to start.");
1018        assert_eq!(result.unwrap(), expected);
1019    }
1020
1021    #[test]
1022    fn load_skill_does_not_substitute_skill_dir_in_ref() {
1023        let tmp = tempfile::TempDir::new().unwrap();
1024        let base = tmp.path();
1025
1026        // Create a skill with a ref that contains literal ${SKILL_DIR} text.
1027        // Refs are arbitrary documents and must NOT have ${SKILL_DIR}
1028        // substituted on them.
1029        let skill_dir = base.join("my-skill");
1030        fs::create_dir(&skill_dir).unwrap();
1031        fs::write(
1032            skill_dir.join("SKILL.md"),
1033            "---\nname: my-skill\ndescription: A skill with a literal-ref\n---\n\nBody",
1034        )
1035        .unwrap();
1036
1037        let refs_dir = skill_dir.join("refs");
1038        fs::create_dir(&refs_dir).unwrap();
1039        fs::write(
1040            refs_dir.join("literal.md"),
1041            "The literal text `${SKILL_DIR}` and `${RECURSIVE_SKILL_DIR}` must pass through.",
1042        )
1043        .unwrap();
1044
1045        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1046        let tool = LoadSkill::new(skills);
1047
1048        let result = tokio::runtime::Runtime::new()
1049            .unwrap()
1050            .block_on(tool.execute(json!({"name": "my-skill", "ref": "literal"})));
1051
1052        assert!(result.is_ok());
1053        let body = result.unwrap();
1054        assert!(
1055            body.contains("${SKILL_DIR}"),
1056            "ref should preserve literal ${{SKILL_DIR}} text: {body}"
1057        );
1058        assert!(
1059            body.contains("${RECURSIVE_SKILL_DIR}"),
1060            "ref should preserve literal ${{RECURSIVE_SKILL_DIR}} text: {body}"
1061        );
1062    }
1063
1064    #[test]
1065    fn load_skill_no_skill_dir_when_path_has_no_parent() {
1066        // Degenerate case: a Skill whose `path` has no parent (e.g. the
1067        // filesystem root "/"). The substitution helper must return content
1068        // unchanged rather than panic. We exercise the section-return path
1069        // because it doesn't read `skill.path` from disk (so we can use a
1070        // non-existent root path safely).
1071        let skill = Skill {
1072            name: "weird-skill".to_string(),
1073            description: "Skill with no parent path".to_string(),
1074            path: PathBuf::from("/"),
1075            mode: SkillMode::Manual,
1076            triggers: vec![],
1077            hint: String::new(),
1078            depends_on: vec![],
1079            refs: vec![],
1080            params: vec![],
1081            scripts: vec![],
1082            sections: vec![SkillSection {
1083                name: "Overview".to_string(),
1084                content: "Run bash ${SKILL_DIR}/scripts/lint.sh".to_string(),
1085            }],
1086            globs: None,
1087        };
1088
1089        let tool = LoadSkill::new(vec![skill]);
1090
1091        let result = tokio::runtime::Runtime::new()
1092            .unwrap()
1093            .block_on(tool.execute(json!({"name": "weird-skill", "section": "Overview"})));
1094
1095        assert!(result.is_ok(), "should not panic: {result:?}");
1096        // No parent → no substitution; content unchanged
1097        assert_eq!(result.unwrap(), "Run bash ${SKILL_DIR}/scripts/lint.sh");
1098    }
1099
1100    #[test]
1101    fn load_skill_escape_preserves_literal_skill_dir() {
1102        // A backslash before the placeholder escapes substitution, so a
1103        // skill that documents the feature can include literal `${SKILL_DIR}`
1104        // text in its body without it being replaced by the path.
1105        let tmp = tempfile::TempDir::new().unwrap();
1106        let base = tmp.path();
1107
1108        let skill_dir = base.join("my-skill");
1109        fs::create_dir(&skill_dir).unwrap();
1110        fs::write(
1111            skill_dir.join("SKILL.md"),
1112            "---\nname: my-skill\ndescription: documents the placeholder\n---\n\n\
1113             Use \\${SKILL_DIR} to reference this skill's directory.\n\
1114             Run `bash ${SKILL_DIR}/scripts/lint.sh`.",
1115        )
1116        .unwrap();
1117
1118        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1119        let expected_dir = skills[0]
1120            .path
1121            .parent()
1122            .unwrap()
1123            .to_string_lossy()
1124            .to_string();
1125        let tool = LoadSkill::new(skills);
1126
1127        let result = tokio::runtime::Runtime::new()
1128            .unwrap()
1129            .block_on(tool.execute(json!({"name": "my-skill"})));
1130
1131        let expected = format!(
1132            "Use ${{SKILL_DIR}} to reference this skill's directory.\n\
1133             Run `bash {expected_dir}/scripts/lint.sh`."
1134        );
1135        assert_eq!(result.unwrap(), expected);
1136    }
1137
1138    #[test]
1139    fn load_skill_escape_preserves_literal_recursive_skill_dir() {
1140        let tmp = tempfile::TempDir::new().unwrap();
1141        let base = tmp.path();
1142
1143        let skill_dir = base.join("my-skill");
1144        fs::create_dir(&skill_dir).unwrap();
1145        fs::write(
1146            skill_dir.join("SKILL.md"),
1147            "---\nname: my-skill\ndescription: documents the alias\n---\n\n\
1148             Alias form: \\${RECURSIVE_SKILL_DIR} stays literal.",
1149        )
1150        .unwrap();
1151
1152        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1153        let tool = LoadSkill::new(skills);
1154
1155        let result = tokio::runtime::Runtime::new()
1156            .unwrap()
1157            .block_on(tool.execute(json!({"name": "my-skill"})));
1158
1159        assert_eq!(
1160            result.unwrap(),
1161            "Alias form: ${RECURSIVE_SKILL_DIR} stays literal."
1162        );
1163    }
1164
1165    #[test]
1166    fn load_skill_lone_backslash_preserved() {
1167        // A backslash not followed by a known placeholder must pass through
1168        // unchanged (no over-eager escaping of unrelated content, and no
1169        // panic on a trailing backslash).
1170        let tmp = tempfile::TempDir::new().unwrap();
1171        let base = tmp.path();
1172
1173        let skill_dir = base.join("my-skill");
1174        fs::create_dir(&skill_dir).unwrap();
1175        fs::write(
1176            skill_dir.join("SKILL.md"),
1177            "---\nname: my-skill\ndescription: backslash handling\n---\n\n\
1178             Path C:\\Users\\foo and a trailing slash \\",
1179        )
1180        .unwrap();
1181
1182        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1183        let tool = LoadSkill::new(skills);
1184
1185        let result = tokio::runtime::Runtime::new()
1186            .unwrap()
1187            .block_on(tool.execute(json!({"name": "my-skill"})));
1188
1189        assert_eq!(
1190            result.unwrap(),
1191            "Path C:\\Users\\foo and a trailing slash \\"
1192        );
1193    }
1194
1195    // --- Dependency resolution tests ---
1196
1197    /// Helper to create a skill directory with frontmatter and body.
1198    fn create_skill(base: &std::path::Path, name: &str, depends_on: &[&str], body: &str) {
1199        let dir = base.join(name);
1200        fs::create_dir(&dir).unwrap();
1201        let mut frontmatter = format!("---\nname: {name}\ndescription: {name} skill\n");
1202        if !depends_on.is_empty() {
1203            frontmatter.push_str(&format!("depends_on: {}\n", depends_on.join(", ")));
1204        }
1205        frontmatter.push_str("---\n\n");
1206        fs::write(dir.join("SKILL.md"), format!("{frontmatter}{body}")).unwrap();
1207    }
1208
1209    #[test]
1210    fn load_skill_resolves_single_dependency() {
1211        let tmp = tempfile::TempDir::new().unwrap();
1212        let base = tmp.path();
1213
1214        // Skill B (no deps)
1215        create_skill(base, "skill-b", &[], "Body of B");
1216        // Skill A depends on B
1217        create_skill(base, "skill-a", &["skill-b"], "Body of A");
1218
1219        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1220        let tool = LoadSkill::new(skills);
1221
1222        let result = tokio::runtime::Runtime::new()
1223            .unwrap()
1224            .block_on(tool.execute(json!({"name": "skill-a"})));
1225
1226        assert!(result.is_ok());
1227        let output = result.unwrap();
1228        assert_eq!(
1229            output,
1230            "=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
1231        );
1232    }
1233
1234    #[test]
1235    fn load_skill_resolves_multi_level_dependencies() {
1236        let tmp = tempfile::TempDir::new().unwrap();
1237        let base = tmp.path();
1238
1239        // Skill C (no deps)
1240        create_skill(base, "skill-c", &[], "Body of C");
1241        // Skill B depends on C
1242        create_skill(base, "skill-b", &["skill-c"], "Body of B");
1243        // Skill A depends on B
1244        create_skill(base, "skill-a", &["skill-b"], "Body of A");
1245
1246        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1247        let tool = LoadSkill::new(skills);
1248
1249        let result = tokio::runtime::Runtime::new()
1250            .unwrap()
1251            .block_on(tool.execute(json!({"name": "skill-a"})));
1252
1253        assert!(result.is_ok());
1254        let output = result.unwrap();
1255        assert_eq!(
1256            output,
1257            "=== Dependency: skill-c ===\nBody of C\n\n=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
1258        );
1259    }
1260
1261    #[test]
1262    fn load_skill_detects_circular_dependency() {
1263        let tmp = tempfile::TempDir::new().unwrap();
1264        let base = tmp.path();
1265
1266        // Skill A depends on B
1267        create_skill(base, "skill-a", &["skill-b"], "Body of A");
1268        // Skill B depends on A (circular!)
1269        create_skill(base, "skill-b", &["skill-a"], "Body of B");
1270
1271        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1272        let tool = LoadSkill::new(skills);
1273
1274        let result = tokio::runtime::Runtime::new()
1275            .unwrap()
1276            .block_on(tool.execute(json!({"name": "skill-a"})));
1277
1278        assert!(result.is_ok());
1279        let output = result.unwrap();
1280        // Should contain the warning for the circular dependency
1281        assert!(
1282            output.contains("circular dependency detected"),
1283            "output should mention circular dependency: {output}"
1284        );
1285        // Should still contain the skill body
1286        assert!(output.contains("=== Skill: skill-a ==="));
1287        assert!(output.contains("Body of A"));
1288    }
1289
1290    #[test]
1291    fn load_skill_respects_depth_limit() {
1292        let tmp = tempfile::TempDir::new().unwrap();
1293        let base = tmp.path();
1294
1295        // Create a chain A -> B -> C -> D -> E (4 levels deep, exceeds max 3)
1296        create_skill(base, "skill-e", &[], "Body of E");
1297        create_skill(base, "skill-d", &["skill-e"], "Body of D");
1298        create_skill(base, "skill-c", &["skill-d"], "Body of C");
1299        create_skill(base, "skill-b", &["skill-c"], "Body of B");
1300        create_skill(base, "skill-a", &["skill-b"], "Body of A");
1301
1302        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1303        let tool = LoadSkill::new(skills);
1304
1305        let result = tokio::runtime::Runtime::new()
1306            .unwrap()
1307            .block_on(tool.execute(json!({"name": "skill-a"})));
1308
1309        assert!(result.is_err());
1310        let err = result.unwrap_err().to_string();
1311        assert!(
1312            err.contains("dependency tree too deep"),
1313            "error should mention depth limit: {err}"
1314        );
1315    }
1316
1317    #[test]
1318    fn load_skill_depth_exactly_at_max_succeeds() {
1319        // resolve_deps is called with initial depth=1.
1320        // Chain A(1) → B(2) → C(3, no further deps) — C is processed at depth 3 = MAX_DEPTH.
1321        // `depth > MAX_DEPTH` (3 > 3 = false) → succeeds.
1322        // Mutant `>= MAX_DEPTH` (3 >= 3 = true) → error.
1323        let tmp = tempfile::TempDir::new().unwrap();
1324        let base = tmp.path();
1325
1326        create_skill(base, "skill-c", &[], "Body of C");
1327        create_skill(base, "skill-b", &["skill-c"], "Body of B");
1328        create_skill(base, "skill-a", &["skill-b"], "Body of A");
1329
1330        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
1331        let tool = LoadSkill::new(skills);
1332
1333        let result = tokio::runtime::Runtime::new()
1334            .unwrap()
1335            .block_on(tool.execute(json!({"name": "skill-a"})));
1336
1337        assert!(
1338            result.is_ok(),
1339            "chain A→B→C (resolved at depth 1/2/3 = MAX_DEPTH) must succeed, got: {:?}",
1340            result
1341        );
1342        let output = result.unwrap();
1343        assert!(
1344            output.contains("Body of C"),
1345            "must include deepest dep body"
1346        );
1347    }
1348
1349    #[test]
1350    fn parse_skill_meta_single_depends_on() {
1351        let tmp = tempfile::TempDir::new().unwrap();
1352        let dir = tmp.path().join("test-skill");
1353        fs::create_dir(&dir).unwrap();
1354        let content =
1355            "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill\n---\n\nBody text";
1356        let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1357        assert_eq!(depends_on, vec!["base-skill"]);
1358    }
1359
1360    #[test]
1361    fn parse_skill_meta_multiple_depends_on() {
1362        let tmp = tempfile::TempDir::new().unwrap();
1363        let dir = tmp.path().join("test-skill");
1364        fs::create_dir(&dir).unwrap();
1365        let content =
1366            "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill, utils, logging\n---\n\nBody text";
1367        let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1368        assert_eq!(depends_on, vec!["base-skill", "utils", "logging"]);
1369    }
1370
1371    #[test]
1372    fn parse_skill_meta_no_depends_on() {
1373        let tmp = tempfile::TempDir::new().unwrap();
1374        let dir = tmp.path().join("test-skill");
1375        fs::create_dir(&dir).unwrap();
1376        let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
1377        let (_, _, _, _, _, depends_on, _, _) = crate::skills::parse_skill_meta(content, &dir);
1378        assert!(depends_on.is_empty());
1379    }
1380
1381    #[test]
1382    fn skill_index_shows_depends_on() {
1383        let skills = vec![
1384            Skill {
1385                name: "with-deps".to_string(),
1386                description: "Has dependencies".to_string(),
1387                path: PathBuf::from("/tmp/skills/with-deps/SKILL.md"),
1388                mode: SkillMode::Manual,
1389                triggers: vec![],
1390                hint: String::new(),
1391                depends_on: vec!["base".to_string(), "utils".to_string()],
1392                refs: vec![],
1393                params: vec![],
1394                scripts: vec![],
1395                sections: vec![],
1396                globs: None,
1397            },
1398            Skill {
1399                name: "no-deps".to_string(),
1400                description: "No dependencies".to_string(),
1401                path: PathBuf::from("/tmp/skills/no-deps/SKILL.md"),
1402                mode: SkillMode::Manual,
1403                triggers: vec![],
1404                hint: String::new(),
1405                depends_on: vec![],
1406                refs: vec![],
1407                params: vec![],
1408                scripts: vec![],
1409                sections: vec![],
1410                globs: None,
1411            },
1412        ];
1413
1414        let result = crate::skills::skill_index(&skills);
1415        assert!(
1416            result.contains("depends_on: base, utils"),
1417            "should show depends_on: {result}"
1418        );
1419        assert!(
1420            result.contains("- no-deps: No dependencies"),
1421            "should show skill without deps normally"
1422        );
1423    }
1424}