Skip to main content

mcp_execution_skill/
parser.rs

1//! TypeScript tool file parser.
2//!
3//! Extracts `JSDoc` metadata from generated TypeScript files:
4//! - `@tool` - Original MCP tool name
5//! - `@server` - Server identifier
6//! - `@category` - Tool category
7//! - `@keywords` - Comma-separated keywords
8//! - `@description` - Tool description
9//!
10//! # `JSDoc` Format
11//!
12//! ```typescript
13//! /**
14//!  * @tool create_issue
15//!  * @server github
16//!  * @category issues
17//!  * @keywords create,issue,new,bug,feature
18//!  * @description Create a new issue in a repository
19//!  */
20//! ```
21
22use regex::Regex;
23use std::path::Path;
24use std::sync::LazyLock;
25use thiserror::Error;
26
27/// Maximum number of tool files to scan (denial-of-service protection).
28pub const MAX_TOOL_FILES: usize = 500;
29
30/// Maximum file size to read in bytes (1MB).
31pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
32
33// Pre-compiled regexes for performance (compiled once, reused)
34static JSDOC_REGEX: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r"/\*\*[\s\S]*?\*/").expect("valid regex"));
36static TOOL_REGEX: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"@tool\s+(\S+)").expect("valid regex"));
38static SERVER_REGEX: LazyLock<Regex> =
39    LazyLock::new(|| Regex::new(r"@server\s+(\S+)").expect("valid regex"));
40static CATEGORY_REGEX: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"@category\s+(\S+)").expect("valid regex"));
42static KEYWORDS_REGEX: LazyLock<Regex> =
43    LazyLock::new(|| Regex::new(r"@keywords[ \t]+(.+)").expect("valid regex"));
44static DESC_REGEX: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r"@description[ \t]+(.+)").expect("valid regex"));
46static INTERFACE_REGEX: LazyLock<Regex> =
47    LazyLock::new(|| Regex::new(r"interface\s+\w+Params\s*\{([^}]*)\}").expect("valid regex"));
48// TODO(review): INTERFACE_REGEX body truncation — nested-type bodies containing `}` are cut off
49// (e.g., `{ foo: string }` as a property type truncates the interface match early).
50// Suggested action: switch to a balanced-brace parser or accept multi-pass scanning.
51
52/// Anchored single-line property pattern.
53///
54/// Matches TypeScript interface properties like:
55/// - `name: string;`
56/// - `count?: number;`
57/// - `readonly id: string;`
58///
59/// The `^...$` anchors, combined with line-by-line iteration, prevent false
60/// positives from `JSDoc` comment lines such as `* Tags to include: foo, bar`.
61static PROP_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
62    Regex::new(r"^(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)(\?)?\s*:\s*([^;]+?)\s*[;,]?\s*$")
63        .expect("valid regex")
64});
65
66// Regexes for SKILL.md frontmatter parsing
67static FRONTMATTER_REGEX: LazyLock<Regex> =
68    LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
69static NAME_REGEX: LazyLock<Regex> =
70    LazyLock::new(|| Regex::new(r"name:\s*(.+)").expect("valid regex"));
71static SKILL_DESC_REGEX: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new(r"description:\s*(.+)").expect("valid regex"));
73
74/// Sanitize file path for error messages to prevent information disclosure.
75///
76/// Replaces the home directory with `~` to avoid leaking usernames and
77/// full filesystem paths in error messages.
78fn sanitize_path_for_error(path: &Path) -> String {
79    dirs::home_dir().map_or_else(
80        || path.display().to_string(),
81        |home| {
82            let path_str = path.display().to_string();
83            path_str.replace(&home.display().to_string(), "~")
84        },
85    )
86}
87
88/// Errors that can occur during TypeScript file parsing.
89#[derive(Debug, Error)]
90pub enum ParseError {
91    /// `JSDoc` block not found in file.
92    #[error("JSDoc block not found in file")]
93    MissingJsDoc,
94
95    /// Required tag not found in `JSDoc`.
96    #[error("required tag '@{tag}' not found")]
97    MissingTag { tag: &'static str },
98
99    /// Failed to parse file content.
100    #[error("failed to parse file: {message}")]
101    ParseFailed { message: String },
102}
103
104/// Errors that can occur during directory scanning.
105#[derive(Debug, Error)]
106pub enum ScanError {
107    /// I/O error reading directory or files.
108    #[error("I/O error: {0}")]
109    Io(#[from] std::io::Error),
110
111    /// Failed to parse a tool file.
112    #[error("failed to parse {path}: {source}")]
113    ParseFailed {
114        path: String,
115        #[source]
116        source: ParseError,
117    },
118
119    /// Directory does not exist.
120    #[error("directory does not exist: {path}")]
121    DirectoryNotFound { path: String },
122
123    /// Too many files in directory (denial-of-service protection).
124    #[error("too many files: {count} exceeds limit of {limit}")]
125    TooManyFiles { count: usize, limit: usize },
126
127    /// File too large to process.
128    #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
129    FileTooLarge { path: String, size: u64, limit: u64 },
130}
131
132/// Parsed metadata from a TypeScript tool file.
133#[derive(Debug, Clone)]
134pub struct ParsedToolFile {
135    /// Original MCP tool name (from @tool tag).
136    pub name: String,
137
138    /// TypeScript function name (`PascalCase` filename).
139    pub typescript_name: String,
140
141    /// Server identifier (from @server tag).
142    pub server_id: String,
143
144    /// Category for grouping (from @category tag).
145    pub category: Option<String>,
146
147    /// Keywords for discovery (from @keywords tag).
148    pub keywords: Vec<String>,
149
150    /// Tool description (from @description tag).
151    pub description: Option<String>,
152
153    /// Parsed parameters from TypeScript interface.
154    pub parameters: Vec<ParsedParameter>,
155}
156
157/// A parsed parameter from TypeScript interface.
158#[derive(Debug, Clone)]
159pub struct ParsedParameter {
160    /// Parameter name.
161    pub name: String,
162
163    /// TypeScript type (e.g., "string", "number", "boolean").
164    pub typescript_type: String,
165
166    /// Whether the parameter is required.
167    pub required: bool,
168
169    /// Parameter description from `JSDoc`.
170    pub description: Option<String>,
171}
172
173/// Parse `JSDoc` metadata from TypeScript file content.
174///
175/// # Arguments
176///
177/// * `content` - TypeScript file content as string
178/// * `filename` - Filename for deriving TypeScript function name
179///
180/// # Returns
181///
182/// `ParsedToolFile` with extracted metadata.
183///
184/// # Errors
185///
186/// Returns `ParseError` if `JSDoc` block or required tags are missing.
187///
188/// # Panics
189///
190/// Panics if regex compilation fails (should never happen with hardcoded patterns).
191///
192/// # Examples
193///
194/// ```
195/// use mcp_execution_skill::parse_tool_file;
196///
197/// let content = r"
198/// /**
199///  * @tool create_issue
200///  * @server github
201///  * @category issues
202///  * @keywords create,issue,new
203///  * @description Create a new issue
204///  */
205/// ";
206///
207/// let result = parse_tool_file(content, "createIssue.ts");
208/// assert!(result.is_ok());
209/// ```
210pub fn parse_tool_file(content: &str, filename: &str) -> Result<ParsedToolFile, ParseError> {
211    // Extract JSDoc block (using pre-compiled regex)
212    let jsdoc = JSDOC_REGEX
213        .find(content)
214        .map(|m| m.as_str())
215        .ok_or(ParseError::MissingJsDoc)?;
216
217    // Extract @tool tag (required)
218    let name = TOOL_REGEX
219        .captures(jsdoc)
220        .and_then(|c| c.get(1))
221        .map(|m| m.as_str().to_string())
222        .ok_or(ParseError::MissingTag { tag: "tool" })?;
223
224    // Extract @server tag (required)
225    let server_id = SERVER_REGEX
226        .captures(jsdoc)
227        .and_then(|c| c.get(1))
228        .map(|m| m.as_str().to_string())
229        .ok_or(ParseError::MissingTag { tag: "server" })?;
230
231    // Extract @category tag (optional)
232    let category = CATEGORY_REGEX
233        .captures(jsdoc)
234        .and_then(|c| c.get(1))
235        .map(|m| m.as_str().to_string());
236
237    // Extract @keywords tag (optional)
238    let keywords = KEYWORDS_REGEX
239        .captures(jsdoc)
240        .and_then(|c| c.get(1))
241        .map(|m| {
242            m.as_str()
243                .split(',')
244                .map(|s| s.trim().to_string())
245                .filter(|s| !s.is_empty())
246                .collect()
247        })
248        .unwrap_or_default();
249
250    // Extract @description tag (optional)
251    let description = DESC_REGEX
252        .captures(jsdoc)
253        .and_then(|c| c.get(1))
254        .map(|m| m.as_str().trim().to_string());
255
256    // Derive TypeScript name from filename
257    let typescript_name = filename.strip_suffix(".ts").unwrap_or(filename).to_string();
258
259    // Parse parameters from TypeScript interface
260    let parameters = parse_parameters(content);
261
262    Ok(ParsedToolFile {
263        name,
264        typescript_name,
265        server_id,
266        category,
267        keywords,
268        description,
269        parameters,
270    })
271}
272
273/// Parse parameters from TypeScript interface definition.
274///
275/// Extracts parameter names, types, and optionality from:
276/// ```typescript
277/// interface CreateIssueParams {
278///   owner: string;
279///   repo: string;
280///   title: string;
281///   body?: string;  // optional
282/// }
283/// ```
284///
285/// Each line is processed individually so that `JSDoc` comment lines (e.g.
286/// `* Tags to include: foo, bar`) are rejected before the regex runs.
287/// Trailing `// ...` inline comments are stripped from each property line
288/// before matching.
289///
290/// # Limitations
291///
292/// Multi-line property declarations (type wraps to the next line) are not
293/// supported — each property must fit on a single line.
294/// TODO(review): Add multi-line property support if generated TS ever wraps types.
295fn parse_parameters(content: &str) -> Vec<ParsedParameter> {
296    let mut parameters = Vec::new();
297
298    if let Some(captures) = INTERFACE_REGEX.captures(content)
299        && let Some(body) = captures.get(1)
300    {
301        for raw in body.as_str().lines() {
302            let line = raw.trim_start();
303
304            // Skip blank lines and comment lines before applying the regex.
305            if line.is_empty()
306                || line.starts_with("//")
307                || line.starts_with("/*")
308                || line.starts_with('*')
309            {
310                continue;
311            }
312
313            // Bound comment search before the first string delimiter so that
314            // `//` or `/*` inside a string-literal type (e.g. `"https://x"`)
315            // does not falsely truncate the property line (S2).
316            let comment_search_end = line
317                .find('"')
318                .or_else(|| line.find('\''))
319                .unwrap_or(line.len());
320            let search_region = &line[..comment_search_end];
321
322            // Find the earliest `//` or `/*` comment marker within the safe region,
323            // then strip everything from that position to end-of-line (S1 + C1).
324            let comment_start = [search_region.find("//"), search_region.find("/*")]
325                .into_iter()
326                .flatten()
327                .min();
328            let line = comment_start.map_or(line, |i| line[..i].trim_end());
329
330            if let Some(cap) = PROP_LINE_REGEX.captures(line) {
331                let name = cap
332                    .get(1)
333                    .map(|m| m.as_str().to_string())
334                    .unwrap_or_default();
335                let optional = cap.get(2).is_some();
336                let typescript_type = cap
337                    .get(3)
338                    .map_or_else(|| "unknown".to_string(), |m| m.as_str().trim().to_string());
339
340                parameters.push(ParsedParameter {
341                    name,
342                    typescript_type,
343                    required: !optional,
344                    description: None,
345                });
346            }
347        }
348    }
349
350    parameters
351}
352
353/// Scan directory and parse all tool files.
354///
355/// Reads all `.ts` files in the directory, excluding:
356/// - `index.ts` (barrel export)
357/// - Files in `_runtime/` subdirectory
358/// - Files starting with `_`
359///
360/// # Arguments
361///
362/// * `dir` - Path to server directory (e.g., `~/.claude/servers/github`)
363///
364/// # Returns
365///
366/// Vector of `ParsedToolFile` for each successfully parsed file.
367///
368/// # Errors
369///
370/// Returns `ScanError` if directory doesn't exist or files can't be read.
371///
372/// # Examples
373///
374/// ```no_run
375/// use mcp_execution_skill::scan_tools_directory;
376/// use std::path::Path;
377///
378/// # async fn example() -> Result<(), mcp_execution_skill::ScanError> {
379/// let tools = scan_tools_directory(Path::new("/home/user/.claude/servers/github")).await?;
380/// println!("Found {} tools", tools.len());
381/// # Ok(())
382/// # }
383/// ```
384pub async fn scan_tools_directory(dir: &Path) -> Result<Vec<ParsedToolFile>, ScanError> {
385    // Canonicalize the base directory to resolve symlinks and get absolute path
386    let canonical_base =
387        tokio::fs::canonicalize(dir)
388            .await
389            .map_err(|_| ScanError::DirectoryNotFound {
390                path: sanitize_path_for_error(dir),
391            })?;
392
393    let mut tools = Vec::new();
394    let mut file_count = 0usize;
395
396    let mut entries = tokio::fs::read_dir(&canonical_base).await?;
397
398    while let Some(entry) = entries.next_entry().await? {
399        let path = entry.path();
400
401        // Skip directories (like _runtime)
402        if path.is_dir() {
403            continue;
404        }
405
406        // Get filename
407        let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
408            continue;
409        };
410
411        // Skip non-TypeScript files
412        if !std::path::Path::new(filename)
413            .extension()
414            .is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
415        {
416            continue;
417        }
418
419        // Skip index.ts and files starting with _
420        if filename == "index.ts" || filename.starts_with('_') {
421            continue;
422        }
423
424        // SECURITY: Canonicalize file path and validate it stays within base directory
425        // This prevents path traversal via symlinks
426        let Ok(canonical_file) = tokio::fs::canonicalize(&path).await else {
427            tracing::warn!(
428                "Skipping file with invalid path: {}",
429                sanitize_path_for_error(&path)
430            );
431            continue;
432        };
433
434        // Prevent path traversal via symlinks
435        if !canonical_file.starts_with(&canonical_base) {
436            tracing::warn!(
437                "Skipping file outside base directory: {} (symlink to {})",
438                sanitize_path_for_error(&path),
439                sanitize_path_for_error(&canonical_file)
440            );
441            continue;
442        }
443
444        // Check file count limit (DoS protection)
445        file_count += 1;
446        if file_count > MAX_TOOL_FILES {
447            return Err(ScanError::TooManyFiles {
448                count: file_count,
449                limit: MAX_TOOL_FILES,
450            });
451        }
452
453        // Check file size before reading (DoS protection)
454        let metadata = tokio::fs::metadata(&canonical_file).await?;
455        if metadata.len() > MAX_FILE_SIZE {
456            return Err(ScanError::FileTooLarge {
457                path: sanitize_path_for_error(&path),
458                size: metadata.len(),
459                limit: MAX_FILE_SIZE,
460            });
461        }
462
463        // Read and parse file (use canonical path)
464        let content = tokio::fs::read_to_string(&canonical_file).await?;
465
466        match parse_tool_file(&content, filename) {
467            Ok(tool) => tools.push(tool),
468            Err(e) => {
469                // Log warning but continue with other files
470                tracing::warn!("Failed to parse {}: {}", sanitize_path_for_error(&path), e);
471            }
472        }
473    }
474
475    // Sort by name for consistent ordering
476    tools.sort_by(|a, b| a.name.cmp(&b.name));
477
478    Ok(tools)
479}
480
481/// Extract skill metadata from SKILL.md content.
482///
483/// Parses YAML frontmatter to extract name and description, and counts
484/// sections (H2 headers) and words.
485///
486/// # Arguments
487///
488/// * `content` - SKILL.md content with YAML frontmatter
489///
490/// # Returns
491///
492/// `SkillMetadata` with extracted information.
493///
494/// # Errors
495///
496/// Returns error if YAML frontmatter is missing or required fields not found.
497///
498/// # Examples
499///
500/// ```
501/// use mcp_execution_skill::extract_skill_metadata;
502///
503/// let content = r"---
504/// name: github-progressive
505/// description: GitHub MCP server operations
506/// ---
507///
508/// # GitHub Progressive
509///
510/// ## Quick Start
511///
512/// Content here.
513/// ";
514///
515/// let metadata = extract_skill_metadata(content).unwrap();
516/// assert_eq!(metadata.name, "github-progressive");
517/// assert_eq!(metadata.description, "GitHub MCP server operations");
518/// ```
519pub fn extract_skill_metadata(content: &str) -> Result<crate::types::SkillMetadata, String> {
520    use crate::types::SkillMetadata;
521
522    // Extract YAML frontmatter (using pre-compiled regex)
523    let frontmatter = FRONTMATTER_REGEX
524        .captures(content)
525        .and_then(|c| c.get(1))
526        .map(|m| m.as_str())
527        .ok_or("YAML frontmatter not found")?;
528
529    // Extract name (using pre-compiled regex)
530    let name = NAME_REGEX
531        .captures(frontmatter)
532        .and_then(|c| c.get(1))
533        .map(|m| m.as_str().trim().to_string())
534        .ok_or("'name' field not found in frontmatter")?;
535
536    // Extract description (using pre-compiled regex)
537    let description = SKILL_DESC_REGEX
538        .captures(frontmatter)
539        .and_then(|c| c.get(1))
540        .map(|m| m.as_str().trim().to_string())
541        .ok_or("'description' field not found in frontmatter")?;
542
543    // Count sections (H2 headers)
544    let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
545
546    // Count words (approximate)
547    let word_count = content.split_whitespace().count();
548
549    Ok(SkillMetadata {
550        name,
551        description,
552        section_count,
553        word_count,
554    })
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_parse_tool_file_complete() {
563        let content = r"
564/**
565 * @tool create_issue
566 * @server github
567 * @category issues
568 * @keywords create,issue,new,bug,feature
569 * @description Create a new issue in a repository
570 */
571
572interface CreateIssueParams {
573  owner: string;
574  repo: string;
575  title: string;
576  body?: string;
577  labels?: string[];
578}
579";
580
581        let result = parse_tool_file(content, "createIssue.ts").unwrap();
582
583        assert_eq!(result.name, "create_issue");
584        assert_eq!(result.typescript_name, "createIssue");
585        assert_eq!(result.server_id, "github");
586        assert_eq!(result.category, Some("issues".to_string()));
587        assert_eq!(
588            result.keywords,
589            vec!["create", "issue", "new", "bug", "feature"]
590        );
591        assert_eq!(
592            result.description,
593            Some("Create a new issue in a repository".to_string())
594        );
595        assert_eq!(result.parameters.len(), 5);
596
597        // Check required params
598        let owner = result
599            .parameters
600            .iter()
601            .find(|p| p.name == "owner")
602            .unwrap();
603        assert!(owner.required);
604        assert_eq!(owner.typescript_type, "string");
605
606        // Check optional params
607        let body = result.parameters.iter().find(|p| p.name == "body").unwrap();
608        assert!(!body.required);
609    }
610
611    #[test]
612    fn test_parse_tool_file_minimal() {
613        let content = r"
614/**
615 * @tool get_user
616 * @server github
617 */
618";
619
620        let result = parse_tool_file(content, "getUser.ts").unwrap();
621
622        assert_eq!(result.name, "get_user");
623        assert_eq!(result.server_id, "github");
624        assert!(result.category.is_none());
625        assert!(result.keywords.is_empty());
626        assert!(result.description.is_none());
627    }
628
629    #[test]
630    fn test_parse_tool_file_missing_jsdoc() {
631        let content = r"
632// No JSDoc block
633function test() {}
634";
635
636        let result = parse_tool_file(content, "test.ts");
637        assert!(matches!(result, Err(ParseError::MissingJsDoc)));
638    }
639
640    #[test]
641    fn test_parse_tool_file_missing_tool_tag() {
642        let content = r"
643/**
644 * @server github
645 */
646";
647
648        let result = parse_tool_file(content, "test.ts");
649        assert!(matches!(
650            result,
651            Err(ParseError::MissingTag { tag: "tool" })
652        ));
653    }
654
655    #[test]
656    fn test_parse_parameters() {
657        let content = r"
658interface TestParams {
659  required: string;
660  optional?: number;
661  array: string[];
662  complex?: Record<string, unknown>;
663}
664";
665
666        let params = parse_parameters(content);
667
668        assert_eq!(params.len(), 4);
669
670        let required = params.iter().find(|p| p.name == "required").unwrap();
671        assert!(required.required);
672        assert_eq!(required.typescript_type, "string");
673
674        let optional = params.iter().find(|p| p.name == "optional").unwrap();
675        assert!(!optional.required);
676        assert_eq!(optional.typescript_type, "number");
677    }
678
679    #[test]
680    fn test_parse_keywords_with_spaces() {
681        let content = r"
682/**
683 * @tool test
684 * @server test
685 * @keywords  create , update,  delete
686 */
687";
688
689        let result = parse_tool_file(content, "test.ts").unwrap();
690        assert_eq!(result.keywords, vec!["create", "update", "delete"]);
691    }
692
693    // ========================================================================
694    // Edge Cases
695    // ========================================================================
696
697    #[test]
698    fn test_parse_tool_file_missing_server_tag() {
699        let content = r"
700/**
701 * @tool test_tool
702 */
703";
704
705        let result = parse_tool_file(content, "test.ts");
706        assert!(matches!(
707            result,
708            Err(ParseError::MissingTag { tag: "server" })
709        ));
710    }
711
712    #[test]
713    fn test_parse_tool_file_malformed_jsdoc() {
714        let content = r"
715/**
716 * @tool
717 * @server github
718 */
719";
720
721        // @tool with no value - regex requires @tool\s+(\S+) which would capture
722        // the `*` from the next line as the tool name. Parser is lenient.
723        let result = parse_tool_file(content, "test.ts");
724        // Parsing succeeds but tool_name may be unexpected (e.g., "*")
725        // Validation of proper tool names happens at a higher level
726        assert!(result.is_ok());
727    }
728
729    #[test]
730    fn test_parse_tool_file_multiline_description() {
731        let content = r"
732/**
733 * @tool test
734 * @server github
735 * @description This is a very long description that spans
736 */
737";
738
739        let result = parse_tool_file(content, "test.ts").unwrap();
740        assert!(result.description.is_some());
741        assert!(
742            result
743                .description
744                .unwrap()
745                .contains("This is a very long description")
746        );
747    }
748
749    #[test]
750    fn test_parse_tool_file_empty_keywords() {
751        let content = r"
752/**
753 * @tool test
754 * @server github
755 * @keywords
756 */
757";
758
759        // When @keywords has no value, the regex doesn't match, so keywords will be default (empty vec)
760        let result = parse_tool_file(content, "test.ts").unwrap();
761        // This is acceptable - parsing should succeed with empty keywords
762        assert!(result.keywords.is_empty());
763    }
764
765    #[test]
766    fn test_parse_tool_file_single_keyword() {
767        let content = r"
768/**
769 * @tool test
770 * @server github
771 * @keywords single
772 */
773";
774
775        let result = parse_tool_file(content, "test.ts").unwrap();
776        assert_eq!(result.keywords, vec!["single"]);
777    }
778
779    #[test]
780    fn test_parse_tool_file_with_hyphens_in_names() {
781        let content = r"
782/**
783 * @tool create-pull-request
784 * @server git-hub
785 * @category pull-requests
786 */
787";
788
789        let result = parse_tool_file(content, "test.ts").unwrap();
790        assert_eq!(result.name, "create-pull-request");
791        assert_eq!(result.server_id, "git-hub");
792        assert_eq!(result.category, Some("pull-requests".to_string()));
793    }
794
795    #[test]
796    fn test_parse_parameters_no_interface() {
797        let content = r"
798export async function test(): Promise<void> {
799  // No interface
800}
801";
802
803        let params = parse_parameters(content);
804        assert_eq!(params.len(), 0);
805    }
806
807    #[test]
808    fn test_parse_parameters_empty_interface() {
809        let content = r"
810interface TestParams {
811}
812";
813
814        let params = parse_parameters(content);
815        assert_eq!(params.len(), 0);
816    }
817
818    #[test]
819    fn test_parse_parameters_complex_types() {
820        let content = r"
821interface TestParams {
822  callback?: (arg: string) => void;
823  union: string | number;
824  generic: Array<string>;
825  nested: { foo: string };
826}
827";
828
829        let params = parse_parameters(content);
830        // Complex types like nested objects may not parse correctly with simple regex
831        // We should get at least 3 params (callback, union, generic)
832        assert!(params.len() >= 3);
833
834        if let Some(callback) = params.iter().find(|p| p.name == "callback") {
835            assert!(!callback.required);
836        }
837
838        if let Some(union) = params.iter().find(|p| p.name == "union") {
839            assert!(union.required);
840        }
841    }
842
843    #[test]
844    fn test_parse_parameters_with_comments() {
845        let content = r"
846interface TestParams {
847  // This is a comment
848  param1: string;
849  /* Another comment */
850  param2: number;
851}
852";
853
854        let params = parse_parameters(content);
855        assert_eq!(params.len(), 2);
856    }
857
858    #[test]
859    fn test_parse_tool_file_special_chars_in_description() {
860        // Need r#""# because content contains embedded double quotes
861        let content = r#"
862/**
863 * @tool test
864 * @server github
865 * @description Create & update <items> with "quotes" and 'apostrophes'
866 */
867"#;
868
869        let result = parse_tool_file(content, "test.ts").unwrap();
870        assert!(result.description.is_some());
871        let description = result.description.unwrap();
872        assert!(description.contains('&'));
873        assert!(description.contains('"'));
874    }
875
876    #[test]
877    fn test_parse_tool_file_numeric_category() {
878        let content = r"
879/**
880 * @tool test
881 * @server github
882 * @category v2-api
883 */
884";
885
886        let result = parse_tool_file(content, "test.ts").unwrap();
887        assert_eq!(result.category, Some("v2-api".to_string()));
888    }
889
890    #[test]
891    fn test_parse_tool_file_unicode_in_description() {
892        let content = r"
893/**
894 * @tool test
895 * @server github
896 * @description Create issue with emoji 🚀 and unicode ™
897 */
898";
899
900        let result = parse_tool_file(content, "test.ts").unwrap();
901        assert!(result.description.is_some());
902        let description = result.description.unwrap();
903        assert!(description.contains("🚀"));
904    }
905
906    #[test]
907    fn test_parse_tool_file_duplicate_tags() {
908        let content = r"
909/**
910 * @tool first_tool
911 * @tool second_tool
912 * @server github
913 */
914";
915
916        // Should use the first match
917        let result = parse_tool_file(content, "test.ts").unwrap();
918        assert_eq!(result.name, "first_tool");
919    }
920
921    #[test]
922    fn test_parse_parameters_readonly_modifier() {
923        let content = r"
924interface TestParams {
925  readonly id: string;
926  readonly count?: number;
927}
928";
929
930        let params = parse_parameters(content);
931        // PROP_LINE_REGEX handles `readonly` — both fields must be extracted.
932        assert_eq!(params.len(), 2);
933
934        let id = params.iter().find(|p| p.name == "id").unwrap();
935        assert!(id.required);
936        assert_eq!(id.typescript_type, "string");
937
938        let count = params.iter().find(|p| p.name == "count").unwrap();
939        assert!(!count.required);
940        assert_eq!(count.typescript_type, "number");
941    }
942
943    #[test]
944    fn test_parse_parameters_inline_block_comment_stripped() {
945        // S1 regression: inline `/* */` block comments must not drop the parameter.
946        let content = r"
947interface TestParams {
948  name: string; /* required */
949  count?: number; /* default: 5 */
950}
951";
952
953        let params = parse_parameters(content);
954        assert_eq!(params.len(), 2);
955        assert!(params.iter().any(|p| p.name == "name" && p.required));
956        assert!(params.iter().any(|p| p.name == "count" && !p.required));
957    }
958
959    #[test]
960    fn test_parse_parameters_url_type_not_stripped() {
961        // S2 regression: `//` inside a string-literal type must not truncate the line.
962        let content = r"
963interface TestParams {
964  url: string; // endpoint URL
965  mode: string;
966}
967";
968
969        let params = parse_parameters(content);
970        assert_eq!(params.len(), 2);
971        assert!(
972            params.iter().any(|p| p.name == "url"),
973            "url must not be dropped"
974        );
975        assert!(params.iter().any(|p| p.name == "mode"));
976    }
977
978    #[test]
979    fn test_parse_parameters_jsdoc_body_comments_not_extracted() {
980        // Regression: comment lines inside JSDoc must NOT produce parameters.
981        let content = r"
982interface FooParams {
983  /**
984   * @default 4
985   * Tags to include: foo, bar
986   */
987  count?: number;
988  name: string;
989}
990";
991
992        let params = parse_parameters(content);
993        assert_eq!(
994            params.len(),
995            2,
996            "expected exactly count and name, got: {params:?}"
997        );
998
999        let names: Vec<&str> = params.iter().map(|p| p.name.as_str()).collect();
1000        assert!(names.contains(&"count"), "missing 'count'");
1001        assert!(names.contains(&"name"), "missing 'name'");
1002        assert!(!names.contains(&"default"), "false positive: 'default'");
1003        assert!(!names.contains(&"include"), "false positive: 'include'");
1004    }
1005
1006    #[test]
1007    fn test_parse_parameters_inline_comment_stripped() {
1008        // C1: trailing `// ...` inline comment must be stripped before matching.
1009        let content = r"
1010interface TestParams {
1011  name: string; // parameter name
1012  count?: number; // how many
1013}
1014";
1015
1016        let params = parse_parameters(content);
1017        assert_eq!(params.len(), 2);
1018
1019        let name = params.iter().find(|p| p.name == "name").unwrap();
1020        assert!(name.required);
1021        assert_eq!(name.typescript_type, "string");
1022
1023        let count = params.iter().find(|p| p.name == "count").unwrap();
1024        assert!(!count.required);
1025    }
1026
1027    #[test]
1028    fn test_parse_tool_file_filename_without_extension() {
1029        let content = r"
1030/**
1031 * @tool test
1032 * @server github
1033 */
1034";
1035
1036        let result = parse_tool_file(content, "testFile").unwrap();
1037        assert_eq!(result.typescript_name, "testFile");
1038    }
1039
1040    #[test]
1041    fn test_parse_keywords_trailing_commas() {
1042        let content = r"
1043/**
1044 * @tool test
1045 * @server test
1046 * @keywords create,update,delete,
1047 */
1048";
1049
1050        let result = parse_tool_file(content, "test.ts").unwrap();
1051        // Empty strings from trailing commas should be filtered out
1052        assert_eq!(result.keywords, vec!["create", "update", "delete"]);
1053    }
1054
1055    // ========================================================================
1056    // extract_skill_metadata Tests
1057    // ========================================================================
1058
1059    #[test]
1060    fn test_extract_skill_metadata_valid() {
1061        let content = r"---
1062name: github-progressive
1063description: GitHub MCP server operations
1064---
1065
1066# GitHub Progressive
1067
1068## Quick Start
1069
1070Content here.
1071
1072## Common Tasks
1073
1074More content.
1075";
1076
1077        let result = extract_skill_metadata(content);
1078        assert!(result.is_ok());
1079
1080        let metadata = result.unwrap();
1081        assert_eq!(metadata.name, "github-progressive");
1082        assert_eq!(metadata.description, "GitHub MCP server operations");
1083        assert_eq!(metadata.section_count, 2);
1084        assert!(metadata.word_count > 0);
1085    }
1086
1087    #[test]
1088    fn test_extract_skill_metadata_no_frontmatter() {
1089        let content = "# Test\n\nNo frontmatter";
1090
1091        let result = extract_skill_metadata(content);
1092        assert!(result.is_err());
1093        assert!(result.unwrap_err().contains("YAML frontmatter not found"));
1094    }
1095
1096    #[test]
1097    fn test_extract_skill_metadata_missing_name() {
1098        let content = "---\ndescription: test\n---\n# Test";
1099
1100        let result = extract_skill_metadata(content);
1101        assert!(result.is_err());
1102        assert!(result.unwrap_err().contains("'name' field not found"));
1103    }
1104
1105    #[test]
1106    fn test_extract_skill_metadata_missing_description() {
1107        let content = "---\nname: test\n---\n# Test";
1108
1109        let result = extract_skill_metadata(content);
1110        assert!(result.is_err());
1111        assert!(
1112            result
1113                .unwrap_err()
1114                .contains("'description' field not found")
1115        );
1116    }
1117
1118    #[test]
1119    fn test_extract_skill_metadata_with_extra_fields() {
1120        let content = r"---
1121name: test-skill
1122description: Test description
1123version: 1.0.0
1124author: Test Author
1125---
1126
1127# Test
1128";
1129
1130        let result = extract_skill_metadata(content);
1131        assert!(result.is_ok());
1132
1133        let metadata = result.unwrap();
1134        assert_eq!(metadata.name, "test-skill");
1135        assert_eq!(metadata.description, "Test description");
1136    }
1137
1138    #[test]
1139    fn test_extract_skill_metadata_multiline_description() {
1140        let content = r"---
1141name: test
1142description: This is a long description that contains multiple words
1143---
1144
1145# Test
1146";
1147
1148        let result = extract_skill_metadata(content);
1149        assert!(result.is_ok());
1150
1151        let metadata = result.unwrap();
1152        assert!(metadata.description.contains("multiple words"));
1153    }
1154}