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: "load_skill".into(),
56                message: "dependency tree too deep (max 3 levels)".to_string(),
57            });
58        }
59
60        let mut deps = Vec::new();
61
62        for dep_name in &skill.depends_on {
63            // Circular detection
64            if !visited.insert(dep_name.to_lowercase()) {
65                // Already in the chain — circular dependency
66                deps.push((
67                    dep_name.clone(),
68                    format!(
69                        "[WARNING: circular dependency detected, skipping {}]",
70                        dep_name
71                    ),
72                ));
73                continue;
74            }
75
76            // Find the dependency skill (case-insensitive)
77            let dep_skill = self
78                .skills
79                .iter()
80                .find(|s| s.name.to_lowercase() == dep_name.to_lowercase())
81                .ok_or_else(|| Error::Tool {
82                    name: "load_skill".into(),
83                    message: format!(
84                        "dependency '{}' not found (required by '{}')",
85                        dep_name, skill.name
86                    ),
87                })?;
88
89            // Recursively resolve the dependency's own dependencies first
90            let sub_deps = self.resolve_deps(dep_skill, visited, depth + 1)?;
91            deps.extend(sub_deps);
92
93            // Read the dependency's body
94            let content = fs::read_to_string(&dep_skill.path).map_err(|e| Error::Tool {
95                name: "load_skill".into(),
96                message: format!("failed to read dependency '{}': {e}", dep_skill.name),
97            })?;
98
99            let body = content
100                .strip_prefix("---")
101                .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
102                .unwrap_or(content.trim())
103                .to_string();
104
105            deps.push((dep_skill.name.clone(), body));
106
107            // Remove from visited so sibling branches can reference the same skill
108            visited.remove(&dep_name.to_lowercase());
109        }
110
111        Ok(deps)
112    }
113}
114
115#[async_trait]
116impl Tool for LoadSkill {
117    fn spec(&self) -> ToolSpec {
118        ToolSpec {
119            name: "load_skill".into(),
120            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(),
121            parameters: json!({
122                "type": "object",
123                "properties": {
124                    "name": {
125                        "type": "string",
126                        "description": "Name of the skill to load"
127                    },
128                    "ref": {
129                        "type": "string",
130                        "description": "Optional name of a reference document to load (e.g. 'api-spec'). Use `load_skill` without `ref` first to see available refs."
131                    },
132                    "section": {
133                        "type": "string",
134                        "description": "Optional name of a section to load (e.g. 'Overview'). Sections are delimited by ## headings in the skill body. Use `load_skill` without `section` first to see available sections."
135                    },
136                    "params": {
137                        "type": "object",
138                        "description": "Optional parameter values for template substitution (e.g. {\"language\": \"python\"}). See skill index for declared params.",
139                        "additionalProperties": {
140                            "type": "string"
141                        }
142                    }
143                },
144                "required": ["name"]
145            }),
146        }
147    }
148
149    fn is_readonly(&self) -> bool {
150        true
151    }
152
153    async fn execute(&self, arguments: Value) -> Result<String> {
154        let name = arguments["name"]
155            .as_str()
156            .ok_or_else(|| Error::BadToolArgs {
157                name: "load_skill".into(),
158                message: "missing required parameter: name".to_string(),
159            })?;
160
161        // Case-insensitive search
162        let skill = self
163            .skills
164            .iter()
165            .find(|s| s.name.to_lowercase() == name.to_lowercase())
166            .ok_or_else(|| Error::Tool {
167                name: "load_skill".into(),
168                message: format!("skill not found: {name}"),
169            })?;
170
171        // Check if a specific ref is requested
172        if let Some(ref_name) = arguments["ref"].as_str() {
173            let ref_name = ref_name.trim();
174            if ref_name.is_empty() {
175                return Err(Error::BadToolArgs {
176                    name: "load_skill".into(),
177                    message: "ref parameter must be a non-empty string".to_string(),
178                });
179            }
180
181            // Case-insensitive search in refs
182            let skill_ref = skill
183                .refs
184                .iter()
185                .find(|r| r.name.to_lowercase() == ref_name.to_lowercase())
186                .ok_or_else(|| {
187                    let available: Vec<&str> = skill.refs.iter().map(|r| r.name.as_str()).collect();
188                    let available_list = if available.is_empty() {
189                        "no refs available for this skill".to_string()
190                    } else {
191                        format!("available refs: {}", available.join(", "))
192                    };
193                    Error::Tool {
194                        name: "load_skill".into(),
195                        message: format!("ref not found: '{ref_name}'. {available_list}"),
196                    }
197                })?;
198
199            let content = fs::read_to_string(&skill_ref.path).map_err(|e| Error::Tool {
200                name: "load_skill".into(),
201                message: format!("failed to read ref file: {e}"),
202            })?;
203
204            return Ok(content.trim().to_string());
205        }
206
207        // Check if a specific section is requested
208        if let Some(section_name) = arguments["section"].as_str() {
209            let section_name = section_name.trim();
210            if section_name.is_empty() {
211                return Err(Error::BadToolArgs {
212                    name: "load_skill".into(),
213                    message: "section parameter must be a non-empty string".to_string(),
214                });
215            }
216
217            // Case-insensitive search in sections
218            let section = skill
219                .sections
220                .iter()
221                .find(|s| s.name.to_lowercase() == section_name.to_lowercase())
222                .ok_or_else(|| {
223                    let available: Vec<&str> =
224                        skill.sections.iter().map(|s| s.name.as_str()).collect();
225                    let available_list = if available.is_empty() {
226                        "no sections available for this skill".to_string()
227                    } else {
228                        format!("available sections: {}", available.join(", "))
229                    };
230                    Error::Tool {
231                        name: "load_skill".into(),
232                        message: format!("section not found: '{section_name}'. {available_list}"),
233                    }
234                })?;
235
236            // Apply param substitution to section content if params provided
237            let provided_params = arguments["params"].as_object();
238            let resolved = resolve_params(skill, provided_params)?;
239
240            let rendered = if resolved.is_empty() {
241                section.content.clone()
242            } else {
243                let mut result = section.content.clone();
244                for (key, value) in &resolved {
245                    result = result.replace(&format!("{{{{{key}}}}}"), value);
246                }
247                result
248            };
249
250            return Ok(rendered);
251        }
252
253        // No ref or section specified — return the main SKILL.md body
254        let content = fs::read_to_string(&skill.path).map_err(|e| Error::Tool {
255            name: "load_skill".into(),
256            message: format!("failed to read skill file: {e}"),
257        })?;
258
259        let body = content
260            .strip_prefix("---")
261            .and_then(|rest| rest.find("---").map(|end| rest[end + 3..].trim()))
262            .unwrap_or(content.trim())
263            .to_string();
264
265        // Resolve params and perform template substitution
266        let provided_params = arguments["params"].as_object();
267        let resolved = resolve_params(skill, provided_params)?;
268
269        // Perform template substitution: replace {{key}} with value
270        let rendered = if resolved.is_empty() {
271            body
272        } else {
273            let mut result = body;
274            for (key, value) in &resolved {
275                result = result.replace(&format!("{{{{{key}}}}}"), value);
276            }
277            result
278        };
279
280        // Resolve dependencies (if any)
281        let mut visited = HashSet::new();
282        visited.insert(skill.name.to_lowercase());
283        let deps = self.resolve_deps(skill, &mut visited, 1)?;
284
285        if deps.is_empty() {
286            return Ok(rendered);
287        }
288
289        // Build output: dependencies first, then the requested skill
290        let mut output = String::new();
291        for (dep_name, dep_body) in &deps {
292            output.push_str(&format!("=== Dependency: {dep_name} ===\n{dep_body}\n\n"));
293        }
294        output.push_str(&format!("=== Skill: {} ===\n{}", skill.name, rendered));
295
296        Ok(output)
297    }
298}
299
300/// Resolve parameter values: use provided values, fall back to defaults,
301/// error on missing required params.
302fn resolve_params(
303    skill: &Skill,
304    provided_params: Option<&serde_json::Map<String, Value>>,
305) -> Result<Vec<(String, String)>> {
306    let mut resolved = Vec::new();
307    let mut missing_required = Vec::new();
308
309    for param in &skill.params {
310        // Check if provided
311        if let Some(obj) = provided_params {
312            if let Some(val) = obj.get(&param.name).and_then(|v| v.as_str()) {
313                resolved.push((param.name.clone(), val.to_string()));
314                continue;
315            }
316        }
317
318        // Fall back to default
319        if let Some(ref default) = param.default {
320            resolved.push((param.name.clone(), default.clone()));
321        } else {
322            // Required param with no value provided
323            missing_required.push(param.name.clone());
324        }
325    }
326
327    if !missing_required.is_empty() {
328        return Err(Error::BadToolArgs {
329            name: "load_skill".into(),
330            message: format!(
331                "missing required params: {}. Declared params: {}",
332                missing_required.join(", "),
333                skill
334                    .params
335                    .iter()
336                    .map(|p| {
337                        if let Some(ref d) = p.default {
338                            format!("{}={}", p.name, d)
339                        } else {
340                            format!("{} (required)", p.name)
341                        }
342                    })
343                    .collect::<Vec<_>>()
344                    .join(", ")
345            ),
346        });
347    }
348
349    Ok(resolved)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::skills::SkillMode;
356    use std::io::Write;
357    use std::path::PathBuf;
358
359    #[test]
360    fn load_skill_returns_body_for_known_skill() {
361        let tmp = tempfile::TempDir::new().unwrap();
362        let base = tmp.path();
363
364        // Create a skill
365        let rust_dir = base.join("rust-traits");
366        fs::create_dir(&rust_dir).unwrap();
367        let mut file = fs::File::create(rust_dir.join("SKILL.md")).unwrap();
368        writeln!(file, "---").unwrap();
369        writeln!(file, "name: rust-traits").unwrap();
370        writeln!(file, "description: Explain traits").unwrap();
371        writeln!(file, "---").unwrap();
372        writeln!(file, "Skill body content here.").unwrap();
373
374        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
375        let tool = LoadSkill::new(skills);
376
377        let result = tokio::runtime::Runtime::new()
378            .unwrap()
379            .block_on(tool.execute(json!({"name": "rust-traits"})));
380
381        assert!(result.is_ok());
382        assert_eq!(result.unwrap(), "Skill body content here.");
383    }
384
385    #[test]
386    fn load_skill_errors_for_unknown_name() {
387        let tmp = tempfile::TempDir::new().unwrap();
388        let base = tmp.path();
389
390        // Create a skill
391        let rust_dir = base.join("rust-traits");
392        fs::create_dir(&rust_dir).unwrap();
393        fs::write(rust_dir.join("SKILL.md"), "Some content").unwrap();
394
395        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
396        let tool = LoadSkill::new(skills);
397
398        let result = tokio::runtime::Runtime::new()
399            .unwrap()
400            .block_on(tool.execute(json!({"name": "nonexistent"})));
401
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn load_skill_case_insensitive() {
407        let tmp = tempfile::TempDir::new().unwrap();
408        let base = tmp.path();
409
410        let rust_dir = base.join("rust-traits");
411        fs::create_dir(&rust_dir).unwrap();
412        fs::write(rust_dir.join("SKILL.md"), "Body content").unwrap();
413
414        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
415        let tool = LoadSkill::new(skills);
416
417        // Should find with different case
418        let result = tokio::runtime::Runtime::new()
419            .unwrap()
420            .block_on(tool.execute(json!({"name": "RUST-TRAITS"})));
421
422        assert!(result.is_ok());
423        assert_eq!(result.unwrap(), "Body content");
424    }
425
426    #[test]
427    fn load_skill_with_ref_returns_ref_content() {
428        let tmp = tempfile::TempDir::new().unwrap();
429        let base = tmp.path();
430
431        // Create a skill with refs
432        let skill_dir = base.join("my-skill");
433        fs::create_dir(&skill_dir).unwrap();
434        fs::write(
435            skill_dir.join("SKILL.md"),
436            "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
437        )
438        .unwrap();
439
440        let refs_dir = skill_dir.join("refs");
441        fs::create_dir(&refs_dir).unwrap();
442        fs::write(refs_dir.join("api-spec.md"), "# API Spec\n\nDetails here.").unwrap();
443
444        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
445        let tool = LoadSkill::new(skills);
446
447        let result = tokio::runtime::Runtime::new()
448            .unwrap()
449            .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
450
451        assert!(result.is_ok());
452        assert_eq!(result.unwrap(), "# API Spec\n\nDetails here.");
453    }
454
455    #[test]
456    fn load_skill_with_unknown_ref_returns_error_with_available() {
457        let tmp = tempfile::TempDir::new().unwrap();
458        let base = tmp.path();
459
460        // Create a skill with refs
461        let skill_dir = base.join("my-skill");
462        fs::create_dir(&skill_dir).unwrap();
463        fs::write(
464            skill_dir.join("SKILL.md"),
465            "---\nname: my-skill\ndescription: A skill with refs\n---\n\nBody",
466        )
467        .unwrap();
468
469        let refs_dir = skill_dir.join("refs");
470        fs::create_dir(&refs_dir).unwrap();
471        fs::write(refs_dir.join("api-spec.md"), "# API Spec").unwrap();
472        fs::write(refs_dir.join("examples.txt"), "Example 1").unwrap();
473
474        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
475        let tool = LoadSkill::new(skills);
476
477        let result = tokio::runtime::Runtime::new()
478            .unwrap()
479            .block_on(tool.execute(json!({"name": "my-skill", "ref": "nonexistent"})));
480
481        assert!(result.is_err());
482        let err = result.unwrap_err().to_string();
483        assert!(
484            err.contains("ref not found"),
485            "error should mention ref not found: {err}"
486        );
487        assert!(
488            err.contains("api-spec") && err.contains("examples"),
489            "error should list available refs: {err}"
490        );
491    }
492
493    #[test]
494    fn load_skill_with_ref_case_insensitive() {
495        let tmp = tempfile::TempDir::new().unwrap();
496        let base = tmp.path();
497
498        let skill_dir = base.join("my-skill");
499        fs::create_dir(&skill_dir).unwrap();
500        fs::write(
501            skill_dir.join("SKILL.md"),
502            "---\nname: my-skill\ndescription: test\n---\n\nBody",
503        )
504        .unwrap();
505
506        let refs_dir = skill_dir.join("refs");
507        fs::create_dir(&refs_dir).unwrap();
508        fs::write(refs_dir.join("API-Spec.md"), "Content").unwrap();
509
510        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
511        let tool = LoadSkill::new(skills);
512
513        // Should find with different case
514        let result = tokio::runtime::Runtime::new()
515            .unwrap()
516            .block_on(tool.execute(json!({"name": "my-skill", "ref": "api-spec"})));
517
518        assert!(result.is_ok());
519        assert_eq!(result.unwrap(), "Content");
520    }
521
522    #[test]
523    fn load_skill_with_empty_ref_returns_error() {
524        let tmp = tempfile::TempDir::new().unwrap();
525        let base = tmp.path();
526
527        let skill_dir = base.join("my-skill");
528        fs::create_dir(&skill_dir).unwrap();
529        fs::write(skill_dir.join("SKILL.md"), "Body").unwrap();
530
531        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
532        let tool = LoadSkill::new(skills);
533
534        let result = tokio::runtime::Runtime::new()
535            .unwrap()
536            .block_on(tool.execute(json!({"name": "my-skill", "ref": ""})));
537
538        assert!(result.is_err());
539    }
540
541    #[test]
542    fn load_skill_with_params_performs_substitution() {
543        let tmp = tempfile::TempDir::new().unwrap();
544        let base = tmp.path();
545
546        let skill_dir = base.join("code-review");
547        fs::create_dir(&skill_dir).unwrap();
548        fs::write(
549            skill_dir.join("SKILL.md"),
550            "---\n\
551             name: code-review\n\
552             description: Review code\n\
553             params:\n\
554             \x20\x20- name: language\n\
555             \x20\x20  description: Target language\n\
556             \x20\x20  default: rust\n\
557             ---\n\
558             \n\
559             Review {{language}} code.",
560        )
561        .unwrap();
562
563        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
564        let tool = LoadSkill::new(skills);
565
566        let result = tokio::runtime::Runtime::new().unwrap().block_on(
567            tool.execute(json!({"name": "code-review", "params": {"language": "python"}})),
568        );
569
570        assert!(result.is_ok());
571        assert_eq!(result.unwrap(), "Review python code.");
572    }
573
574    #[test]
575    fn load_skill_uses_defaults_when_params_not_provided() {
576        let tmp = tempfile::TempDir::new().unwrap();
577        let base = tmp.path();
578
579        let skill_dir = base.join("code-review");
580        fs::create_dir(&skill_dir).unwrap();
581        fs::write(
582            skill_dir.join("SKILL.md"),
583            "---\n\
584             name: code-review\n\
585             description: Review code\n\
586             params:\n\
587             \x20\x20- name: language\n\
588             \x20\x20  description: Target language\n\
589             \x20\x20  default: rust\n\
590             ---\n\
591             \n\
592             Review {{language}} code.",
593        )
594        .unwrap();
595
596        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
597        let tool = LoadSkill::new(skills);
598
599        // No params provided — should use default "rust"
600        let result = tokio::runtime::Runtime::new()
601            .unwrap()
602            .block_on(tool.execute(json!({"name": "code-review"})));
603
604        assert!(result.is_ok());
605        assert_eq!(result.unwrap(), "Review rust code.");
606    }
607
608    #[test]
609    fn load_skill_errors_on_missing_required_param() {
610        let tmp = tempfile::TempDir::new().unwrap();
611        let base = tmp.path();
612
613        let skill_dir = base.join("greeter");
614        fs::create_dir(&skill_dir).unwrap();
615        fs::write(
616            skill_dir.join("SKILL.md"),
617            "---\n\
618             name: greeter\n\
619             description: Greet someone\n\
620             params:\n\
621             \x20\x20- name: name\n\
622             \x20\x20  description: Name to greet\n\
623             ---\n\
624             \n\
625             Hello {{name}}!",
626        )
627        .unwrap();
628
629        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
630        let tool = LoadSkill::new(skills);
631
632        // No params provided and no default — should error
633        let result = tokio::runtime::Runtime::new()
634            .unwrap()
635            .block_on(tool.execute(json!({"name": "greeter"})));
636
637        assert!(result.is_err());
638        let err = result.unwrap_err().to_string();
639        assert!(
640            err.contains("missing required params"),
641            "error should mention missing required params: {err}"
642        );
643        assert!(
644            err.contains("name"),
645            "error should mention the missing param name: {err}"
646        );
647    }
648
649    #[test]
650    fn load_skill_with_params_substitutes_multiple() {
651        let tmp = tempfile::TempDir::new().unwrap();
652        let base = tmp.path();
653
654        let skill_dir = base.join("multi-param");
655        fs::create_dir(&skill_dir).unwrap();
656        fs::write(
657            skill_dir.join("SKILL.md"),
658            "---\n\
659             name: multi-param\n\
660             description: Multiple params\n\
661             params:\n\
662             \x20\x20- name: lang\n\
663             \x20\x20  description: Language\n\
664             \x20\x20  default: rust\n\
665             \x20\x20- name: mode\n\
666             \x20\x20  description: Mode\n\
667             \x20\x20  default: strict\n\
668             ---\n\
669             \n\
670             Lang={{lang}} Mode={{mode}}",
671        )
672        .unwrap();
673
674        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
675        let tool = LoadSkill::new(skills);
676
677        let result =
678            tokio::runtime::Runtime::new()
679                .unwrap()
680                .block_on(tool.execute(
681                    json!({"name": "multi-param", "params": {"lang": "python", "mode": "lax"}}),
682                ));
683
684        assert!(result.is_ok());
685        assert_eq!(result.unwrap(), "Lang=python Mode=lax");
686    }
687
688    // --- Section loading tests ---
689
690    #[test]
691    fn load_skill_with_section_returns_section_content() {
692        let tmp = tempfile::TempDir::new().unwrap();
693        let base = tmp.path();
694
695        let skill_dir = base.join("sectioned-skill");
696        fs::create_dir(&skill_dir).unwrap();
697        fs::write(
698            skill_dir.join("SKILL.md"),
699            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
700        )
701        .unwrap();
702
703        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
704        let tool = LoadSkill::new(skills);
705
706        let result = tokio::runtime::Runtime::new()
707            .unwrap()
708            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Overview"})));
709
710        assert!(result.is_ok());
711        assert_eq!(result.unwrap(), "General info.");
712    }
713
714    #[test]
715    fn load_skill_with_section_case_insensitive() {
716        let tmp = tempfile::TempDir::new().unwrap();
717        let base = tmp.path();
718
719        let skill_dir = base.join("sectioned-skill");
720        fs::create_dir(&skill_dir).unwrap();
721        fs::write(
722            skill_dir.join("SKILL.md"),
723            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
724        )
725        .unwrap();
726
727        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
728        let tool = LoadSkill::new(skills);
729
730        let result = tokio::runtime::Runtime::new()
731            .unwrap()
732            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "overview"})));
733
734        assert!(result.is_ok());
735        assert_eq!(result.unwrap(), "General info.");
736    }
737
738    #[test]
739    fn load_skill_with_unknown_section_returns_error_with_available() {
740        let tmp = tempfile::TempDir::new().unwrap();
741        let base = tmp.path();
742
743        let skill_dir = base.join("sectioned-skill");
744        fs::create_dir(&skill_dir).unwrap();
745        fs::write(
746            skill_dir.join("SKILL.md"),
747            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.\n\n## Details\n\nSpecific content.",
748        )
749        .unwrap();
750
751        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
752        let tool = LoadSkill::new(skills);
753
754        let result = tokio::runtime::Runtime::new()
755            .unwrap()
756            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": "Nonexistent"})));
757
758        assert!(result.is_err());
759        let err = result.unwrap_err().to_string();
760        assert!(
761            err.contains("section not found"),
762            "error should mention section not found: {err}"
763        );
764        assert!(
765            err.contains("Overview") && err.contains("Details"),
766            "error should list available sections: {err}"
767        );
768    }
769
770    #[test]
771    fn load_skill_with_empty_section_returns_error() {
772        let tmp = tempfile::TempDir::new().unwrap();
773        let base = tmp.path();
774
775        let skill_dir = base.join("sectioned-skill");
776        fs::create_dir(&skill_dir).unwrap();
777        fs::write(
778            skill_dir.join("SKILL.md"),
779            "---\nname: sectioned-skill\ndescription: Has sections\n---\n\n## Overview\n\nGeneral info.",
780        )
781        .unwrap();
782
783        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
784        let tool = LoadSkill::new(skills);
785
786        let result = tokio::runtime::Runtime::new()
787            .unwrap()
788            .block_on(tool.execute(json!({"name": "sectioned-skill", "section": ""})));
789
790        assert!(result.is_err());
791    }
792
793    #[test]
794    fn load_skill_with_section_and_params_performs_substitution() {
795        let tmp = tempfile::TempDir::new().unwrap();
796        let base = tmp.path();
797
798        let skill_dir = base.join("template-skill");
799        fs::create_dir(&skill_dir).unwrap();
800        fs::write(
801            skill_dir.join("SKILL.md"),
802            "---\n\
803             name: template-skill\n\
804             description: Has templates\n\
805             params:\n\
806             \x20\x20- name: lang\n\
807             \x20\x20  description: Language\n\
808             \x20\x20  default: rust\n\
809             ---\n\
810             \n\
811             ## Overview\n\
812             \n\
813             Review {{lang}} code.\n\
814             \n\
815             ## Details\n\
816             \n\
817             More about {{lang}}.",
818        )
819        .unwrap();
820
821        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
822        let tool = LoadSkill::new(skills);
823
824        let result = tokio::runtime::Runtime::new()
825            .unwrap()
826            .block_on(tool.execute(
827            json!({"name": "template-skill", "section": "Overview", "params": {"lang": "python"}}),
828        ));
829
830        assert!(result.is_ok());
831        assert_eq!(result.unwrap(), "Review python code.");
832    }
833
834    // --- Dependency resolution tests ---
835
836    /// Helper to create a skill directory with frontmatter and body.
837    fn create_skill(base: &std::path::Path, name: &str, depends_on: &[&str], body: &str) {
838        let dir = base.join(name);
839        fs::create_dir(&dir).unwrap();
840        let mut frontmatter = format!("---\nname: {name}\ndescription: {name} skill\n");
841        if !depends_on.is_empty() {
842            frontmatter.push_str(&format!("depends_on: {}\n", depends_on.join(", ")));
843        }
844        frontmatter.push_str("---\n\n");
845        fs::write(dir.join("SKILL.md"), format!("{frontmatter}{body}")).unwrap();
846    }
847
848    #[test]
849    fn load_skill_resolves_single_dependency() {
850        let tmp = tempfile::TempDir::new().unwrap();
851        let base = tmp.path();
852
853        // Skill B (no deps)
854        create_skill(base, "skill-b", &[], "Body of B");
855        // Skill A depends on B
856        create_skill(base, "skill-a", &["skill-b"], "Body of A");
857
858        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
859        let tool = LoadSkill::new(skills);
860
861        let result = tokio::runtime::Runtime::new()
862            .unwrap()
863            .block_on(tool.execute(json!({"name": "skill-a"})));
864
865        assert!(result.is_ok());
866        let output = result.unwrap();
867        assert_eq!(
868            output,
869            "=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
870        );
871    }
872
873    #[test]
874    fn load_skill_resolves_multi_level_dependencies() {
875        let tmp = tempfile::TempDir::new().unwrap();
876        let base = tmp.path();
877
878        // Skill C (no deps)
879        create_skill(base, "skill-c", &[], "Body of C");
880        // Skill B depends on C
881        create_skill(base, "skill-b", &["skill-c"], "Body of B");
882        // Skill A depends on B
883        create_skill(base, "skill-a", &["skill-b"], "Body of A");
884
885        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
886        let tool = LoadSkill::new(skills);
887
888        let result = tokio::runtime::Runtime::new()
889            .unwrap()
890            .block_on(tool.execute(json!({"name": "skill-a"})));
891
892        assert!(result.is_ok());
893        let output = result.unwrap();
894        assert_eq!(
895            output,
896            "=== Dependency: skill-c ===\nBody of C\n\n=== Dependency: skill-b ===\nBody of B\n\n=== Skill: skill-a ===\nBody of A"
897        );
898    }
899
900    #[test]
901    fn load_skill_detects_circular_dependency() {
902        let tmp = tempfile::TempDir::new().unwrap();
903        let base = tmp.path();
904
905        // Skill A depends on B
906        create_skill(base, "skill-a", &["skill-b"], "Body of A");
907        // Skill B depends on A (circular!)
908        create_skill(base, "skill-b", &["skill-a"], "Body of B");
909
910        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
911        let tool = LoadSkill::new(skills);
912
913        let result = tokio::runtime::Runtime::new()
914            .unwrap()
915            .block_on(tool.execute(json!({"name": "skill-a"})));
916
917        assert!(result.is_ok());
918        let output = result.unwrap();
919        // Should contain the warning for the circular dependency
920        assert!(
921            output.contains("circular dependency detected"),
922            "output should mention circular dependency: {output}"
923        );
924        // Should still contain the skill body
925        assert!(output.contains("=== Skill: skill-a ==="));
926        assert!(output.contains("Body of A"));
927    }
928
929    #[test]
930    fn load_skill_respects_depth_limit() {
931        let tmp = tempfile::TempDir::new().unwrap();
932        let base = tmp.path();
933
934        // Create a chain A -> B -> C -> D -> E (4 levels deep, exceeds max 3)
935        create_skill(base, "skill-e", &[], "Body of E");
936        create_skill(base, "skill-d", &["skill-e"], "Body of D");
937        create_skill(base, "skill-c", &["skill-d"], "Body of C");
938        create_skill(base, "skill-b", &["skill-c"], "Body of B");
939        create_skill(base, "skill-a", &["skill-b"], "Body of A");
940
941        let skills = crate::skills::discover_skills(&[base.to_path_buf()]);
942        let tool = LoadSkill::new(skills);
943
944        let result = tokio::runtime::Runtime::new()
945            .unwrap()
946            .block_on(tool.execute(json!({"name": "skill-a"})));
947
948        assert!(result.is_err());
949        let err = result.unwrap_err().to_string();
950        assert!(
951            err.contains("dependency tree too deep"),
952            "error should mention depth limit: {err}"
953        );
954    }
955
956    #[test]
957    fn parse_skill_meta_single_depends_on() {
958        let tmp = tempfile::TempDir::new().unwrap();
959        let dir = tmp.path().join("test-skill");
960        fs::create_dir(&dir).unwrap();
961        let content =
962            "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill\n---\n\nBody text";
963        let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
964        assert_eq!(depends_on, vec!["base-skill"]);
965    }
966
967    #[test]
968    fn parse_skill_meta_multiple_depends_on() {
969        let tmp = tempfile::TempDir::new().unwrap();
970        let dir = tmp.path().join("test-skill");
971        fs::create_dir(&dir).unwrap();
972        let content =
973            "---\nname: test-skill\ndescription: A test\ndepends_on: base-skill, utils, logging\n---\n\nBody text";
974        let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
975        assert_eq!(depends_on, vec!["base-skill", "utils", "logging"]);
976    }
977
978    #[test]
979    fn parse_skill_meta_no_depends_on() {
980        let tmp = tempfile::TempDir::new().unwrap();
981        let dir = tmp.path().join("test-skill");
982        fs::create_dir(&dir).unwrap();
983        let content = "---\nname: test-skill\ndescription: A test\n---\n\nBody text";
984        let (_, _, _, _, _, depends_on, _) = crate::skills::parse_skill_meta(content, &dir);
985        assert!(depends_on.is_empty());
986    }
987
988    #[test]
989    fn skill_index_shows_depends_on() {
990        let skills = vec![
991            Skill {
992                name: "with-deps".to_string(),
993                description: "Has dependencies".to_string(),
994                path: PathBuf::from("/tmp/skills/with-deps/SKILL.md"),
995                mode: SkillMode::Manual,
996                triggers: vec![],
997                hint: String::new(),
998                depends_on: vec!["base".to_string(), "utils".to_string()],
999                refs: vec![],
1000                params: vec![],
1001                scripts: vec![],
1002                sections: vec![],
1003            },
1004            Skill {
1005                name: "no-deps".to_string(),
1006                description: "No dependencies".to_string(),
1007                path: PathBuf::from("/tmp/skills/no-deps/SKILL.md"),
1008                mode: SkillMode::Manual,
1009                triggers: vec![],
1010                hint: String::new(),
1011                depends_on: vec![],
1012                refs: vec![],
1013                params: vec![],
1014                scripts: vec![],
1015                sections: vec![],
1016            },
1017        ];
1018
1019        let result = crate::skills::skill_index(&skills);
1020        assert!(
1021            result.contains("depends_on: base, utils"),
1022            "should show depends_on: {result}"
1023        );
1024        assert!(
1025            result.contains("- no-deps: No dependencies"),
1026            "should show skill without deps normally"
1027        );
1028    }
1029}