1use regex::Regex;
23use std::path::Path;
24use std::sync::LazyLock;
25use thiserror::Error;
26
27pub const MAX_TOOL_FILES: usize = 500;
29
30pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
32
33static 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"));
48static 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
66static 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
74fn 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#[derive(Debug, Error)]
90pub enum ParseError {
91 #[error("JSDoc block not found in file")]
93 MissingJsDoc,
94
95 #[error("required tag '@{tag}' not found")]
97 MissingTag { tag: &'static str },
98
99 #[error("failed to parse file: {message}")]
101 ParseFailed { message: String },
102}
103
104#[derive(Debug, Error)]
106pub enum ScanError {
107 #[error("I/O error: {0}")]
109 Io(#[from] std::io::Error),
110
111 #[error("failed to parse {path}: {source}")]
113 ParseFailed {
114 path: String,
115 #[source]
116 source: ParseError,
117 },
118
119 #[error("directory does not exist: {path}")]
121 DirectoryNotFound { path: String },
122
123 #[error("too many files: {count} exceeds limit of {limit}")]
125 TooManyFiles { count: usize, limit: usize },
126
127 #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
129 FileTooLarge { path: String, size: u64, limit: u64 },
130}
131
132#[derive(Debug, Clone)]
134pub struct ParsedToolFile {
135 pub name: String,
137
138 pub typescript_name: String,
140
141 pub server_id: String,
143
144 pub category: Option<String>,
146
147 pub keywords: Vec<String>,
149
150 pub description: Option<String>,
152
153 pub parameters: Vec<ParsedParameter>,
155}
156
157#[derive(Debug, Clone)]
159pub struct ParsedParameter {
160 pub name: String,
162
163 pub typescript_type: String,
165
166 pub required: bool,
168
169 pub description: Option<String>,
171}
172
173pub fn parse_tool_file(content: &str, filename: &str) -> Result<ParsedToolFile, ParseError> {
211 let jsdoc = JSDOC_REGEX
213 .find(content)
214 .map(|m| m.as_str())
215 .ok_or(ParseError::MissingJsDoc)?;
216
217 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 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 let category = CATEGORY_REGEX
233 .captures(jsdoc)
234 .and_then(|c| c.get(1))
235 .map(|m| m.as_str().to_string());
236
237 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 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 let typescript_name = filename.strip_suffix(".ts").unwrap_or(filename).to_string();
258
259 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
273fn 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 if line.is_empty()
306 || line.starts_with("//")
307 || line.starts_with("/*")
308 || line.starts_with('*')
309 {
310 continue;
311 }
312
313 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 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
353pub async fn scan_tools_directory(dir: &Path) -> Result<Vec<ParsedToolFile>, ScanError> {
385 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 if path.is_dir() {
403 continue;
404 }
405
406 let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
408 continue;
409 };
410
411 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 if filename == "index.ts" || filename.starts_with('_') {
421 continue;
422 }
423
424 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 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 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 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 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 tracing::warn!("Failed to parse {}: {}", sanitize_path_for_error(&path), e);
471 }
472 }
473 }
474
475 tools.sort_by(|a, b| a.name.cmp(&b.name));
477
478 Ok(tools)
479}
480
481pub fn extract_skill_metadata(content: &str) -> Result<crate::types::SkillMetadata, String> {
520 use crate::types::SkillMetadata;
521
522 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 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 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 let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
545
546 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 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 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 #[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 let result = parse_tool_file(content, "test.ts");
724 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 let result = parse_tool_file(content, "test.ts").unwrap();
761 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 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 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 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 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 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 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 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 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 assert_eq!(result.keywords, vec!["create", "update", "delete"]);
1053 }
1054
1055 #[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}