skill_runtime/
skill_md.rs

1//! SKILL.md parser for extracting rich skill documentation.
2//!
3//! This module parses SKILL.md files following Anthropic's skills format:
4//! - YAML frontmatter with name, description, allowed-tools
5//! - Markdown content with tool documentation, usage examples
6//! - Parameter tables and code blocks
7//!
8//! # Example SKILL.md format
9//!
10//! ```markdown
11//! ---
12//! name: kubernetes-skill
13//! description: Kubernetes cluster management with kubectl
14//! allowed-tools: Read, Bash, skill-run
15//! ---
16//!
17//! # Kubernetes Skill
18//!
19//! ## Tools Provided
20//!
21//! ### get
22//! Get Kubernetes resources (pods, services, deployments)
23//!
24//! **Parameters**:
25//! - `resource` (required): Resource type
26//! - `namespace` (optional): Kubernetes namespace
27//!
28//! **Example**:
29//! ```bash
30//! skill run kubernetes get resource=pods namespace=default
31//! ```
32//! ```
33
34use anyhow::{Context, Result};
35use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::Path;
39
40/// YAML frontmatter from SKILL.md
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42pub struct SkillMdFrontmatter {
43    /// Skill name
44    pub name: String,
45
46    /// Short description for discovery
47    pub description: String,
48
49    /// Comma-separated list of allowed tools
50    #[serde(default, rename = "allowed-tools")]
51    pub allowed_tools: Option<String>,
52
53    /// Additional metadata
54    #[serde(flatten)]
55    pub extra: HashMap<String, serde_yaml::Value>,
56}
57
58/// Parsed SKILL.md content
59#[derive(Debug, Clone, Default)]
60pub struct SkillMdContent {
61    /// YAML frontmatter
62    pub frontmatter: SkillMdFrontmatter,
63
64    /// Full markdown body (after frontmatter)
65    pub body: String,
66
67    /// Tool-specific documentation extracted from markdown
68    pub tool_docs: HashMap<String, ToolDocumentation>,
69
70    /// Code examples extracted from the document
71    pub examples: Vec<CodeExample>,
72
73    /// When to use this skill (extracted from ## When to Use section)
74    pub when_to_use: Option<String>,
75
76    /// Configuration documentation
77    pub configuration: Option<String>,
78}
79
80/// Documentation for a specific tool
81#[derive(Debug, Clone, Default)]
82pub struct ToolDocumentation {
83    /// Tool name
84    pub name: String,
85
86    /// Tool description
87    pub description: String,
88
89    /// Usage instructions
90    pub usage: Option<String>,
91
92    /// Parameter documentation
93    pub parameters: Vec<ParameterDoc>,
94
95    /// Code examples for this tool
96    pub examples: Vec<CodeExample>,
97}
98
99/// Parameter type enumeration
100#[derive(Debug, Clone, PartialEq, Default)]
101pub enum ParameterType {
102    #[default]
103    String,
104    Integer,
105    Number,
106    Boolean,
107    Array,
108    Object,
109}
110
111impl std::fmt::Display for ParameterType {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            ParameterType::String => write!(f, "string"),
115            ParameterType::Integer => write!(f, "integer"),
116            ParameterType::Number => write!(f, "number"),
117            ParameterType::Boolean => write!(f, "boolean"),
118            ParameterType::Array => write!(f, "array"),
119            ParameterType::Object => write!(f, "object"),
120        }
121    }
122}
123
124/// Parameter documentation from markdown
125#[derive(Debug, Clone)]
126pub struct ParameterDoc {
127    /// Parameter name
128    pub name: String,
129
130    /// Whether the parameter is required
131    pub required: bool,
132
133    /// Parameter type (string, integer, boolean, etc.)
134    pub param_type: ParameterType,
135
136    /// Parameter description
137    pub description: String,
138
139    /// Default value if any
140    pub default: Option<String>,
141
142    /// Allowed values (enum)
143    pub allowed_values: Vec<String>,
144}
145
146/// Code example extracted from markdown
147#[derive(Debug, Clone)]
148pub struct CodeExample {
149    /// Language hint (bash, json, etc.)
150    pub language: Option<String>,
151
152    /// The code content
153    pub code: String,
154
155    /// Optional description/title
156    pub description: Option<String>,
157}
158
159/// Parse a SKILL.md file
160pub fn parse_skill_md(path: &Path) -> Result<SkillMdContent> {
161    let content = std::fs::read_to_string(path)
162        .with_context(|| format!("Failed to read SKILL.md: {}", path.display()))?;
163
164    parse_skill_md_content(&content)
165}
166
167/// Parse SKILL.md content from a string
168pub fn parse_skill_md_content(content: &str) -> Result<SkillMdContent> {
169    // Split frontmatter and body
170    let (frontmatter, body) = extract_frontmatter(content)?;
171
172    // Parse the markdown body
173    let tool_docs = extract_tool_sections(&body);
174    let examples = extract_code_examples(&body);
175    let when_to_use = extract_section(&body, "When to Use");
176    let configuration = extract_section(&body, "Configuration");
177
178    Ok(SkillMdContent {
179        frontmatter,
180        body,
181        tool_docs,
182        examples,
183        when_to_use,
184        configuration,
185    })
186}
187
188/// Extract YAML frontmatter from markdown content
189fn extract_frontmatter(content: &str) -> Result<(SkillMdFrontmatter, String)> {
190    let content = content.trim();
191
192    // Check for frontmatter delimiter
193    if !content.starts_with("---") {
194        // No frontmatter, return empty frontmatter and full content as body
195        return Ok((SkillMdFrontmatter::default(), content.to_string()));
196    }
197
198    // Find the closing delimiter
199    let after_first = &content[3..];
200    let end_pos = after_first
201        .find("\n---")
202        .or_else(|| after_first.find("\r\n---"))
203        .context("SKILL.md has opening --- but no closing ---")?;
204
205    let yaml_content = &after_first[..end_pos].trim();
206    let body_start = 3 + end_pos + 4; // Skip past closing ---
207    let body = if body_start < content.len() {
208        content[body_start..].trim().to_string()
209    } else {
210        String::new()
211    };
212
213    // Parse YAML frontmatter
214    let frontmatter: SkillMdFrontmatter = serde_yaml::from_str(yaml_content)
215        .with_context(|| format!("Failed to parse SKILL.md frontmatter: {}", yaml_content))?;
216
217    Ok((frontmatter, body))
218}
219
220/// Extract tool documentation sections from markdown
221fn extract_tool_sections(markdown: &str) -> HashMap<String, ToolDocumentation> {
222    let mut tools = HashMap::new();
223    let parser = Parser::new(markdown);
224
225    let mut _current_h2: Option<String> = None;
226    let mut _current_h3: Option<String> = None;
227    let mut current_tool: Option<ToolDocumentation> = None;
228    let mut in_tools_section = false;
229    let mut collecting_text = false;
230    let mut current_text = String::new();
231    let mut in_code_block = false;
232    let mut code_lang: Option<String> = None;
233    let mut code_content = String::new();
234    let mut h3_tool_candidate: Option<ToolDocumentation> = None;
235
236    for event in parser {
237        match event {
238            Event::Start(Tag::Heading { level, .. }) => {
239                // Save previous tool if we have one
240                if let Some(tool) = current_tool.take() {
241                    if !tool.name.is_empty() {
242                        tools.insert(tool.name.clone(), tool);
243                    }
244                }
245
246                collecting_text = true;
247                current_text.clear();
248
249                match level {
250                    HeadingLevel::H2 => {
251                        _current_h3 = None;
252                    }
253                    HeadingLevel::H3 => {}
254                    _ => {}
255                }
256            }
257            Event::End(TagEnd::Heading(level)) => {
258                collecting_text = false;
259                let heading = current_text.trim().to_string();
260
261                match level {
262                    HeadingLevel::H2 => {
263                        // Commit H3 candidate if we had one (no H4 followed it)
264                        if let Some(h3_tool) = h3_tool_candidate.take() {
265                            if !h3_tool.name.is_empty() {
266                                tools.insert(h3_tool.name.clone(), h3_tool);
267                            }
268                        }
269                        _current_h2 = Some(heading.clone());
270                        in_tools_section = heading.to_lowercase().contains("tools");
271                    }
272                    HeadingLevel::H3 if in_tools_section => {
273                        // Commit previous H3 candidate if we had one (no H4 followed it)
274                        if let Some(h3_tool) = h3_tool_candidate.take() {
275                            if !h3_tool.name.is_empty() {
276                                tools.insert(h3_tool.name.clone(), h3_tool);
277                            }
278                        }
279                        // Save new H3 as candidate
280                        _current_h3 = Some(heading.clone());
281                        h3_tool_candidate = Some(ToolDocumentation {
282                            name: heading,
283                            ..Default::default()
284                        });
285                    }
286                    HeadingLevel::H4 if in_tools_section => {
287                        // H4 found - discard H3 candidate (it was a category)
288                        h3_tool_candidate = None;
289                        // H4 is the actual tool/command
290                        current_tool = Some(ToolDocumentation {
291                            name: heading,
292                            ..Default::default()
293                        });
294                    }
295                    _ => {}
296                }
297            }
298            Event::Start(Tag::CodeBlock(kind)) => {
299                in_code_block = true;
300                code_lang = match kind {
301                    pulldown_cmark::CodeBlockKind::Fenced(lang) => {
302                        let lang_str = lang.to_string();
303                        if lang_str.is_empty() {
304                            None
305                        } else {
306                            Some(lang_str)
307                        }
308                    }
309                    _ => None,
310                };
311                code_content.clear();
312            }
313            Event::End(TagEnd::CodeBlock) => {
314                in_code_block = false;
315                if let Some(ref mut tool) = current_tool {
316                    tool.examples.push(CodeExample {
317                        language: code_lang.take(),
318                        code: code_content.clone(),
319                        description: None,
320                    });
321                }
322            }
323            Event::Text(text) => {
324                if collecting_text {
325                    current_text.push_str(&text);
326                } else if in_code_block {
327                    code_content.push_str(&text);
328                } else if let Some(ref mut tool) = current_tool {
329                    // Add to tool description if we're in a tool section
330                    if tool.description.is_empty() && !text.trim().is_empty() {
331                        tool.description = text.trim().to_string();
332                    }
333                }
334            }
335            Event::Code(code) => {
336                if collecting_text {
337                    current_text.push_str(&code);
338                }
339            }
340            _ => {}
341        }
342    }
343
344    // Save last tool (either H4-based or H3 candidate)
345    if let Some(tool) = current_tool {
346        if !tool.name.is_empty() {
347            tools.insert(tool.name.clone(), tool);
348        }
349    } else if let Some(h3_tool) = h3_tool_candidate {
350        // No H4 followed the last H3, so it was a tool
351        if !h3_tool.name.is_empty() {
352            tools.insert(h3_tool.name.clone(), h3_tool);
353        }
354    }
355
356    // CRITICAL FIX: Extract parameters for each tool by parsing their sections
357    extract_tool_parameters(markdown, &mut tools);
358
359    tools
360}
361
362/// Extract and parse parameters for each tool from the markdown content
363/// This function finds the **Parameters**: section under each tool heading
364fn extract_tool_parameters(markdown: &str, tools: &mut HashMap<String, ToolDocumentation>) {
365    for (tool_name, tool_doc) in tools.iter_mut() {
366        // Find the tool section in markdown
367        if let Some(tool_section) = extract_tool_section_content(markdown, tool_name) {
368            // Look for **Parameters**: section
369            if let Some(params_text) = extract_parameters_section(&tool_section) {
370                tool_doc.parameters = parse_parameters(&params_text);
371            }
372        }
373    }
374}
375
376/// Extract the content of a specific tool section (from heading to next same-level heading)
377fn extract_tool_section_content(markdown: &str, tool_name: &str) -> Option<String> {
378    let lines: Vec<&str> = markdown.lines().collect();
379    let mut start_idx: Option<usize> = None;
380    let mut section_level: Option<usize> = None;
381
382    // Find the tool heading
383    for (idx, line) in lines.iter().enumerate() {
384        let trimmed = line.trim();
385        // Check for H3 (###) or H4 (####) headings matching tool name
386        if (trimmed.starts_with("### ") || trimmed.starts_with("#### "))
387            && trimmed.trim_start_matches('#').trim() == tool_name {
388            start_idx = Some(idx);
389            section_level = Some(trimmed.chars().take_while(|c| *c == '#').count());
390            break;
391        }
392    }
393
394    let start_idx = start_idx?;
395    let section_level = section_level?;
396
397    // Find the end of this section (next heading at same or higher level)
398    let mut end_idx = lines.len();
399    for (idx, line) in lines.iter().enumerate().skip(start_idx + 1) {
400        let trimmed = line.trim();
401        if trimmed.starts_with('#') {
402            let level = trimmed.chars().take_while(|c| *c == '#').count();
403            if level <= section_level {
404                end_idx = idx;
405                break;
406            }
407        }
408    }
409
410    // Extract the section content
411    let section_lines = &lines[start_idx..end_idx];
412    Some(section_lines.join("\n"))
413}
414
415/// Extract the **Parameters**: section content from a tool section
416fn extract_parameters_section(tool_section: &str) -> Option<String> {
417    let lines: Vec<&str> = tool_section.lines().collect();
418    let mut params_start: Option<usize> = None;
419
420    // Find **Parameters**: line
421    for (idx, line) in lines.iter().enumerate() {
422        let trimmed = line.trim();
423        if trimmed.starts_with("**Parameters") && trimmed.contains(':') {
424            params_start = Some(idx);
425            break;
426        }
427    }
428
429    let params_start = params_start?;
430
431    // Find the end of parameters section (next **Section** or empty line followed by heading)
432    let mut params_end = lines.len();
433    for (idx, line) in lines.iter().enumerate().skip(params_start + 1) {
434        let trimmed = line.trim();
435        // Stop at next bold section or example section
436        if trimmed.starts_with("**") && !trimmed.starts_with("**Parameters") {
437            params_end = idx;
438            break;
439        }
440        // Stop at code block
441        if trimmed.starts_with("```") {
442            params_end = idx;
443            break;
444        }
445    }
446
447    let params_lines = &lines[params_start..params_end];
448    Some(params_lines.join("\n"))
449}
450
451/// Extract all code examples from markdown
452fn extract_code_examples(markdown: &str) -> Vec<CodeExample> {
453    let parser = Parser::new(markdown);
454    let mut examples = Vec::new();
455    let mut in_code_block = false;
456    let mut code_lang: Option<String> = None;
457    let mut code_content = String::new();
458
459    for event in parser {
460        match event {
461            Event::Start(Tag::CodeBlock(kind)) => {
462                in_code_block = true;
463                code_lang = match kind {
464                    pulldown_cmark::CodeBlockKind::Fenced(lang) => {
465                        let lang_str = lang.to_string();
466                        if lang_str.is_empty() {
467                            None
468                        } else {
469                            Some(lang_str)
470                        }
471                    }
472                    _ => None,
473                };
474                code_content.clear();
475            }
476            Event::End(TagEnd::CodeBlock) => {
477                in_code_block = false;
478                examples.push(CodeExample {
479                    language: code_lang.take(),
480                    code: code_content.clone(),
481                    description: None,
482                });
483            }
484            Event::Text(text) if in_code_block => {
485                code_content.push_str(&text);
486            }
487            _ => {}
488        }
489    }
490
491    examples
492}
493
494/// Extract a specific section by heading name
495fn extract_section(markdown: &str, section_name: &str) -> Option<String> {
496    let parser = Parser::new(markdown);
497    let mut in_target_section = false;
498    let mut content = String::new();
499    let mut collecting_heading = false;
500    let mut heading_text = String::new();
501    let mut target_level: Option<HeadingLevel> = None;
502
503    for event in parser {
504        match event {
505            Event::Start(Tag::Heading { level, .. }) => {
506                if in_target_section {
507                    // Check if this heading ends our section
508                    if let Some(target) = target_level {
509                        if level <= target {
510                            break;
511                        }
512                    }
513                }
514                collecting_heading = true;
515                heading_text.clear();
516            }
517            Event::End(TagEnd::Heading(level)) => {
518                collecting_heading = false;
519                if heading_text.to_lowercase().contains(&section_name.to_lowercase()) {
520                    in_target_section = true;
521                    target_level = Some(level);
522                }
523            }
524            Event::Text(text) => {
525                if collecting_heading {
526                    heading_text.push_str(&text);
527                } else if in_target_section {
528                    content.push_str(&text);
529                }
530            }
531            Event::SoftBreak | Event::HardBreak if in_target_section => {
532                content.push('\n');
533            }
534            Event::Start(Tag::Paragraph) if in_target_section => {}
535            Event::End(TagEnd::Paragraph) if in_target_section => {
536                content.push('\n');
537            }
538            Event::Start(Tag::Item) if in_target_section => {
539                content.push_str("- ");
540            }
541            Event::End(TagEnd::Item) if in_target_section => {
542                content.push('\n');
543            }
544            _ => {}
545        }
546    }
547
548    if content.trim().is_empty() {
549        None
550    } else {
551        Some(content.trim().to_string())
552    }
553}
554
555/// Parse parameter documentation from a tool section
556///
557/// Supports formats:
558/// - `name` (required): description
559/// - `name` (optional, string): description
560/// - `name` (required, integer): description
561/// - `name` (optional, boolean, default: true): description
562/// - `name` (required, enum: value1|value2|value3): description
563pub fn parse_parameters(text: &str) -> Vec<ParameterDoc> {
564    let mut params = Vec::new();
565
566    for line in text.lines() {
567        let line = line.trim();
568        if !line.starts_with('-') && !line.starts_with('*') {
569            continue;
570        }
571
572        // Remove leading bullet
573        let line = line.trim_start_matches('-').trim_start_matches('*').trim();
574
575        // Try to extract parameter name (in backticks or bold)
576        let (name, rest) = if line.starts_with('`') {
577            if let Some(end) = line[1..].find('`') {
578                let name = &line[1..=end];
579                let rest = &line[end + 2..];
580                (name.to_string(), rest.trim())
581            } else {
582                continue;
583            }
584        } else if line.starts_with("**") {
585            if let Some(end) = line[2..].find("**") {
586                let name = &line[2..end + 2];
587                let rest = &line[end + 4..];
588                (name.to_string(), rest.trim())
589            } else {
590                continue;
591            }
592        } else {
593            continue;
594        };
595
596        let rest_lower = rest.to_lowercase();
597
598        // Check for required/optional
599        let required = rest_lower.contains("required");
600
601        // Extract type from parentheses content
602        // Patterns: (required), (optional, string), (required, integer), etc.
603        let param_type = if rest_lower.contains("integer") || rest_lower.contains("int)") {
604            ParameterType::Integer
605        } else if rest_lower.contains("number") || rest_lower.contains("float") {
606            ParameterType::Number
607        } else if rest_lower.contains("boolean") || rest_lower.contains("bool") {
608            ParameterType::Boolean
609        } else if rest_lower.contains("array") || rest_lower.contains("list") {
610            ParameterType::Array
611        } else if rest_lower.contains("object") || rest_lower.contains("json") {
612            ParameterType::Object
613        } else {
614            ParameterType::String
615        };
616
617        // Extract default value if present
618        // Pattern: default: value or default=value
619        let default = if let Some(pos) = rest_lower.find("default:") {
620            let after = &rest[pos + 8..];
621            // Find the end (comma, paren, or end of parentheses block)
622            let end = after.find(|c: char| c == ',' || c == ')').unwrap_or(after.len());
623            Some(after[..end].trim().to_string())
624        } else if let Some(pos) = rest_lower.find("default=") {
625            let after = &rest[pos + 8..];
626            let end = after.find(|c: char| c == ',' || c == ')').unwrap_or(after.len());
627            Some(after[..end].trim().to_string())
628        } else {
629            None
630        };
631
632        // Extract allowed values (enum)
633        // Pattern: enum: value1|value2|value3 or values: [a, b, c]
634        let allowed_values = if let Some(pos) = rest_lower.find("enum:") {
635            let after = &rest[pos + 5..];
636            let end = after.find(')').unwrap_or(after.len());
637            after[..end]
638                .split('|')
639                .map(|s| s.trim().to_string())
640                .filter(|s| !s.is_empty())
641                .collect()
642        } else {
643            Vec::new()
644        };
645
646        // Extract description (after the colon following parentheses)
647        let description = if let Some(colon_pos) = rest.find(':') {
648            // Skip if the colon is inside parentheses (part of default: or enum:)
649            let before_colon = &rest[..colon_pos];
650            let open_parens = before_colon.matches('(').count();
651            let close_parens = before_colon.matches(')').count();
652
653            if open_parens > close_parens {
654                // Colon is inside parentheses, look for next colon after closing paren
655                if let Some(paren_end) = rest.find(')') {
656                    if let Some(next_colon) = rest[paren_end..].find(':') {
657                        rest[paren_end + next_colon + 1..].trim().to_string()
658                    } else {
659                        rest[paren_end + 1..].trim().to_string()
660                    }
661                } else {
662                    rest.to_string()
663                }
664            } else {
665                rest[colon_pos + 1..].trim().to_string()
666            }
667        } else {
668            rest.to_string()
669        };
670
671        params.push(ParameterDoc {
672            name,
673            required,
674            param_type,
675            description,
676            default,
677            allowed_values,
678        });
679    }
680
681    params
682}
683
684/// Find SKILL.md file in a skill directory
685pub fn find_skill_md(skill_dir: &Path) -> Option<std::path::PathBuf> {
686    let skill_md = skill_dir.join("SKILL.md");
687    if skill_md.exists() {
688        return Some(skill_md);
689    }
690
691    // Try lowercase
692    let skill_md_lower = skill_dir.join("skill.md");
693    if skill_md_lower.exists() {
694        return Some(skill_md_lower);
695    }
696
697    None
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn test_parse_frontmatter() {
706        let content = r#"---
707name: test-skill
708description: A test skill for unit testing
709allowed-tools: Read, Bash
710---
711
712# Test Skill
713
714This is the body content.
715"#;
716
717        let result = parse_skill_md_content(content).unwrap();
718        assert_eq!(result.frontmatter.name, "test-skill");
719        assert_eq!(result.frontmatter.description, "A test skill for unit testing");
720        assert_eq!(result.frontmatter.allowed_tools, Some("Read, Bash".to_string()));
721        assert!(result.body.contains("# Test Skill"));
722    }
723
724    #[test]
725    fn test_parse_no_frontmatter() {
726        let content = r#"# Just a Markdown File
727
728No frontmatter here.
729"#;
730
731        let result = parse_skill_md_content(content).unwrap();
732        assert!(result.frontmatter.name.is_empty());
733        assert!(result.body.contains("# Just a Markdown File"));
734    }
735
736    #[test]
737    fn test_extract_tool_sections() {
738        let markdown = r#"
739# Skill
740
741## Tools Provided
742
743### get
744Get resources from the cluster.
745
746### delete
747Delete resources from the cluster.
748
749## Configuration
750
751Some config info.
752"#;
753
754        let tools = extract_tool_sections(markdown);
755        assert!(tools.contains_key("get"));
756        assert!(tools.contains_key("delete"));
757        assert_eq!(tools.get("get").unwrap().description, "Get resources from the cluster.");
758    }
759
760    #[test]
761    fn test_extract_code_examples() {
762        let markdown = r#"
763# Example
764
765```bash
766skill run kubernetes get resource=pods
767```
768
769Some text.
770
771```json
772{"key": "value"}
773```
774"#;
775
776        let examples = extract_code_examples(markdown);
777        assert_eq!(examples.len(), 2);
778        assert_eq!(examples[0].language, Some("bash".to_string()));
779        assert!(examples[0].code.contains("skill run"));
780        assert_eq!(examples[1].language, Some("json".to_string()));
781    }
782
783    #[test]
784    fn test_extract_section() {
785        let markdown = r#"
786# Skill
787
788## When to Use
789
790Use this skill when you need to:
791- Manage Kubernetes resources
792- Deploy applications
793
794## Configuration
795
796Set up credentials first.
797"#;
798
799        let when_to_use = extract_section(markdown, "When to Use").unwrap();
800        assert!(when_to_use.contains("Manage Kubernetes"));
801        assert!(when_to_use.contains("Deploy applications"));
802    }
803
804    #[test]
805    fn test_parse_parameters() {
806        let text = r#"
807**Parameters**:
808- `resource` (required): The resource type to get
809- `namespace` (optional): Kubernetes namespace
810- `output` (optional): Output format
811"#;
812
813        let params = parse_parameters(text);
814        assert_eq!(params.len(), 3);
815        assert_eq!(params[0].name, "resource");
816        assert!(params[0].required);
817        assert_eq!(params[0].param_type, ParameterType::String);
818        assert_eq!(params[1].name, "namespace");
819        assert!(!params[1].required);
820    }
821
822    #[test]
823    fn test_parse_parameters_with_types() {
824        let text = r#"
825**Parameters**:
826- `count` (required, integer): Number of items to return
827- `enabled` (optional, boolean, default: true): Enable feature
828- `replicas` (required, integer): Desired replica count
829- `format` (optional, enum: json|yaml|table): Output format
830"#;
831
832        let params = parse_parameters(text);
833        assert_eq!(params.len(), 4);
834
835        assert_eq!(params[0].name, "count");
836        assert!(params[0].required);
837        assert_eq!(params[0].param_type, ParameterType::Integer);
838
839        assert_eq!(params[1].name, "enabled");
840        assert!(!params[1].required);
841        assert_eq!(params[1].param_type, ParameterType::Boolean);
842        assert_eq!(params[1].default, Some("true".to_string()));
843
844        assert_eq!(params[2].name, "replicas");
845        assert_eq!(params[2].param_type, ParameterType::Integer);
846
847        assert_eq!(params[3].name, "format");
848        assert_eq!(params[3].allowed_values, vec!["json", "yaml", "table"]);
849    }
850}