Skip to main content

mcp_execution_skill/
parser.rs

1//! Server metadata sidecar reader.
2//!
3//! Reads the structured `_meta.json` sidecar emitted by
4//! `mcp-execution-codegen` alongside a server's generated TypeScript tool
5//! files, and maps it into this crate's [`ParsedToolFile`] / [`ParsedParameter`]
6//! types for skill generation.
7//!
8//! Prior to this module, tool metadata was recovered by re-parsing the
9//! generated `.ts` files with regexes — a lossy, fragile round-trip that,
10//! among other issues, could never recover parameter descriptions. The
11//! sidecar is a structured, serde-derived contract shared with codegen via
12//! `mcp_execution_core::metadata`, so no re-parsing of generated source is
13//! needed at all.
14
15use mcp_execution_core::metadata::{METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ServerMetadata};
16use regex::Regex;
17use std::path::Path;
18use std::sync::LazyLock;
19use thiserror::Error;
20
21/// Maximum number of tools accepted from a single sidecar (denial-of-service protection).
22pub const MAX_TOOL_FILES: usize = 500;
23
24/// Maximum sidecar file size to read in bytes (1MB).
25pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
26
27// Regexes for SKILL.md frontmatter parsing
28static FRONTMATTER_REGEX: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
30static NAME_REGEX: LazyLock<Regex> =
31    LazyLock::new(|| Regex::new(r"name:\s*(.+)").expect("valid regex"));
32static SKILL_DESC_REGEX: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"description:\s*(.+)").expect("valid regex"));
34
35/// Sanitize file path for error messages to prevent information disclosure.
36///
37/// Replaces the home directory with `~` to avoid leaking usernames and
38/// full filesystem paths in error messages.
39fn sanitize_path_for_error(path: &Path) -> String {
40    dirs::home_dir().map_or_else(
41        || path.display().to_string(),
42        |home| {
43            let path_str = path.display().to_string();
44            path_str.replace(&home.display().to_string(), "~")
45        },
46    )
47}
48
49/// Errors that can occur while scanning a server directory for its `_meta.json` sidecar.
50#[derive(Debug, Error)]
51pub enum ScanError {
52    /// I/O error reading the directory or sidecar file.
53    #[error("I/O error: {0}")]
54    Io(#[from] std::io::Error),
55
56    /// Directory does not exist.
57    #[error("directory does not exist: {path}")]
58    DirectoryNotFound { path: String },
59
60    /// The `_meta.json` sidecar is missing from the server directory.
61    #[error("metadata sidecar not found: {path} (was the server directory regenerated?)")]
62    MissingMetadata { path: String },
63
64    /// The `_meta.json` sidecar could not be parsed as valid `ServerMetadata` JSON.
65    #[error("failed to parse metadata sidecar {path}: {source}")]
66    MetadataParse {
67        path: String,
68        #[source]
69        source: serde_json::Error,
70    },
71
72    /// The sidecar's `schema_version` does not match the version this crate understands.
73    #[error("unsupported metadata schema version: found {found}, expected {expected}")]
74    UnsupportedSchema { found: u32, expected: u32 },
75
76    /// Too many tools in the sidecar (denial-of-service protection).
77    #[error("too many tools: {count} exceeds limit of {limit}")]
78    TooManyFiles { count: usize, limit: usize },
79
80    /// Sidecar file too large to process.
81    #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
82    FileTooLarge { path: String, size: u64, limit: u64 },
83
84    /// A tool listed in the `_meta.json` sidecar has no corresponding `.ts`
85    /// file on disk.
86    ///
87    /// This indicates the sidecar and the generated TypeScript files have
88    /// drifted apart — e.g. the file was deleted manually, or a `generate`
89    /// run was interrupted before writing it.
90    #[error(
91        "stale metadata: tool '{tool}' is listed in {sidecar_path} but its file '{expected_file}' \
92         is missing (re-run 'generate' to regenerate this server)"
93    )]
94    StaleMetadata {
95        tool: String,
96        expected_file: String,
97        sidecar_path: String,
98    },
99}
100
101/// Result of [`scan_tools_directory`]: the parsed tools plus any non-fatal
102/// drift warnings.
103///
104/// A warning does not fail the scan — it flags a `.ts` file on disk that was
105/// excluded from `tools` because the sidecar has no matching entry for it.
106/// Callers that only inspect `tools` would otherwise have no way to detect
107/// this drift short of tailing server-side `tracing` output.
108#[derive(Debug, Clone, Default)]
109pub struct ScanResult {
110    /// Parsed tools, sorted by name.
111    pub tools: Vec<ParsedToolFile>,
112
113    /// Non-fatal warnings, e.g. `.ts` files excluded for lacking a sidecar entry.
114    pub warnings: Vec<String>,
115}
116
117/// Parsed metadata from a server's generated tool set.
118#[derive(Debug, Clone)]
119pub struct ParsedToolFile {
120    /// Original MCP tool name.
121    pub name: String,
122
123    /// TypeScript function name (`PascalCase` filename).
124    pub typescript_name: String,
125
126    /// Server identifier.
127    pub server_id: String,
128
129    /// Category for grouping.
130    pub category: Option<String>,
131
132    /// Keywords for discovery.
133    pub keywords: Vec<String>,
134
135    /// Tool description.
136    pub description: Option<String>,
137
138    /// Parsed parameters for the tool.
139    pub parameters: Vec<ParsedParameter>,
140}
141
142/// A parsed parameter from a tool's metadata.
143#[derive(Debug, Clone)]
144pub struct ParsedParameter {
145    /// Parameter name.
146    pub name: String,
147
148    /// TypeScript type (e.g., "string", "number", "boolean").
149    pub typescript_type: String,
150
151    /// Whether the parameter is required.
152    pub required: bool,
153
154    /// Parameter description.
155    pub description: Option<String>,
156}
157
158impl From<mcp_execution_core::metadata::ParameterMetadata> for ParsedParameter {
159    fn from(meta: mcp_execution_core::metadata::ParameterMetadata) -> Self {
160        Self {
161            name: meta.name,
162            typescript_type: meta.typescript_type,
163            required: meta.required,
164            description: meta.description,
165        }
166    }
167}
168
169impl From<mcp_execution_core::metadata::ToolMetadata> for ParsedToolFile {
170    fn from(meta: mcp_execution_core::metadata::ToolMetadata) -> Self {
171        Self {
172            name: meta.name,
173            typescript_name: meta.typescript_name,
174            server_id: String::new(),
175            category: meta.category,
176            keywords: meta.keywords,
177            description: meta.description,
178            parameters: meta.parameters.into_iter().map(Into::into).collect(),
179        }
180    }
181}
182
183/// Scan a server directory and read its `_meta.json` sidecar.
184///
185/// Reads the structured metadata sidecar written by `mcp-execution-codegen`
186/// and maps each tool entry into a [`ParsedToolFile`]. Unlike the former
187/// regex-based `.ts` scanner, tool metadata (name, category, keywords,
188/// parameters) is never re-parsed from generated TypeScript source — the
189/// sidecar remains the single source of truth for that. However, each
190/// sidecar entry's `.ts` file is cross-checked for existence on disk to
191/// detect drift between the sidecar and the generated files (see issues
192/// #154, #155): a missing file is a hard error, while an unreferenced `.ts`
193/// file on disk is logged via `tracing::warn!`, omitted from the result, and
194/// named in the returned [`ScanResult::warnings`] (see issue #161).
195///
196/// # Arguments
197///
198/// * `dir` - Path to server directory (e.g., `~/.claude/servers/github`)
199///
200/// # Returns
201///
202/// [`ScanResult`] with one `ParsedToolFile` per tool in the sidecar (sorted
203/// by name) plus any non-fatal drift warnings.
204///
205/// # Errors
206///
207/// Returns `ScanError` if the directory doesn't exist, the sidecar is
208/// missing or malformed, the sidecar's tool count exceeds
209/// [`MAX_TOOL_FILES`], or a sidecar entry's `.ts` file is missing from disk
210/// ([`ScanError::StaleMetadata`]).
211///
212/// # Examples
213///
214/// ```no_run
215/// use mcp_execution_skill::scan_tools_directory;
216/// use std::path::Path;
217///
218/// # async fn example() -> Result<(), mcp_execution_skill::ScanError> {
219/// let result = scan_tools_directory(Path::new("/home/user/.claude/servers/github")).await?;
220/// println!("Found {} tools", result.tools.len());
221/// # Ok(())
222/// # }
223/// ```
224pub async fn scan_tools_directory(dir: &Path) -> Result<ScanResult, ScanError> {
225    // Canonicalize the base directory to resolve symlinks and get absolute path
226    let canonical_base =
227        tokio::fs::canonicalize(dir)
228            .await
229            .map_err(|_| ScanError::DirectoryNotFound {
230                path: sanitize_path_for_error(dir),
231            })?;
232
233    let meta_path = canonical_base.join(METADATA_FILE_NAME);
234
235    // SECURITY: Canonicalize the sidecar path and validate it stays within the base
236    // directory, preventing path traversal via a symlinked `_meta.json`.
237    let canonical_meta = match tokio::fs::canonicalize(&meta_path).await {
238        Ok(path) if path.starts_with(&canonical_base) => path,
239        _ => {
240            return Err(ScanError::MissingMetadata {
241                path: sanitize_path_for_error(&meta_path),
242            });
243        }
244    };
245
246    let file_metadata = tokio::fs::metadata(&canonical_meta).await?;
247    if file_metadata.len() > MAX_FILE_SIZE {
248        return Err(ScanError::FileTooLarge {
249            path: sanitize_path_for_error(&meta_path),
250            size: file_metadata.len(),
251            limit: MAX_FILE_SIZE,
252        });
253    }
254
255    let content = tokio::fs::read_to_string(&canonical_meta).await?;
256
257    let meta: ServerMetadata =
258        serde_json::from_str(&content).map_err(|source| ScanError::MetadataParse {
259            path: sanitize_path_for_error(&meta_path),
260            source,
261        })?;
262
263    if meta.schema_version != METADATA_SCHEMA_VERSION {
264        return Err(ScanError::UnsupportedSchema {
265            found: meta.schema_version,
266            expected: METADATA_SCHEMA_VERSION,
267        });
268    }
269
270    if meta.tools.len() > MAX_TOOL_FILES {
271        return Err(ScanError::TooManyFiles {
272            count: meta.tools.len(),
273            limit: MAX_TOOL_FILES,
274        });
275    }
276
277    let warnings = verify_tool_files_on_disk(&canonical_base, &meta.tools, &meta_path).await?;
278
279    let server_id = meta.server_id.clone();
280    let mut tools: Vec<ParsedToolFile> = meta
281        .tools
282        .into_iter()
283        .map(|tool| {
284            let mut parsed: ParsedToolFile = tool.into();
285            parsed.server_id.clone_from(&server_id);
286            parsed
287        })
288        .collect();
289
290    // Sort by name for consistent ordering
291    tools.sort_by(|a, b| a.name.cmp(&b.name));
292
293    Ok(ScanResult { tools, warnings })
294}
295
296/// Cross-checks sidecar tool entries against the `.ts` files actually
297/// present in `dir`, guarding against drift between `_meta.json` and the
298/// generated TypeScript output (see issues #154, #155).
299///
300/// Every sidecar entry must have a matching `{typescript_name}.ts` file, or
301/// this returns [`ScanError::StaleMetadata`]. `.ts` files present on disk
302/// but not referenced by the sidecar are not fatal — regenerating tool
303/// files is a normal part of `generate` — but are logged via
304/// `tracing::warn!` and returned as human-readable warning strings so the
305/// drift isn't silently dropped from `SKILL.md` or invisible to structured
306/// callers (issue #161).
307///
308/// # Errors
309///
310/// Returns `ScanError::Io` if the directory cannot be read, or
311/// `ScanError::StaleMetadata` if a sidecar entry's `.ts` file is missing.
312async fn verify_tool_files_on_disk(
313    dir: &Path,
314    tools: &[mcp_execution_core::metadata::ToolMetadata],
315    meta_path: &Path,
316) -> Result<Vec<String>, ScanError> {
317    // Generated aggregator file, not a per-tool file — never expected in the sidecar.
318    const INDEX_FILE_NAME: &str = "index.ts";
319
320    let mut expected_files: std::collections::HashSet<String> =
321        std::collections::HashSet::with_capacity(tools.len());
322
323    for tool in tools {
324        let file_name = format!("{}.ts", tool.typescript_name);
325        if !dir.join(&file_name).is_file() {
326            return Err(ScanError::StaleMetadata {
327                tool: tool.name.clone(),
328                expected_file: file_name,
329                sidecar_path: sanitize_path_for_error(meta_path),
330            });
331        }
332        expected_files.insert(file_name);
333    }
334
335    let mut warnings = Vec::new();
336    let mut entries = tokio::fs::read_dir(dir).await?;
337    while let Some(entry) = entries.next_entry().await? {
338        let path = entry.path();
339        if path.extension().and_then(std::ffi::OsStr::to_str) != Some("ts") {
340            continue;
341        }
342        let Some(file_name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
343            continue;
344        };
345        if file_name == INDEX_FILE_NAME || expected_files.contains(file_name) {
346            continue;
347        }
348        tracing::warn!(
349            file = %file_name,
350            "found .ts tool file not referenced by _meta.json; it will be omitted from SKILL.md \
351             (re-run 'generate' to refresh the sidecar)"
352        );
353        warnings.push(format!(
354            "'{file_name}' is not referenced by _meta.json and was excluded from SKILL.md \
355             (re-run 'generate' to refresh the sidecar)"
356        ));
357    }
358
359    Ok(warnings)
360}
361
362/// Extract skill metadata from SKILL.md content.
363///
364/// Parses YAML frontmatter to extract name and description, and counts
365/// sections (H2 headers) and words.
366///
367/// # Arguments
368///
369/// * `content` - SKILL.md content with YAML frontmatter
370///
371/// # Returns
372///
373/// `SkillMetadata` with extracted information.
374///
375/// # Errors
376///
377/// Returns error if YAML frontmatter is missing or required fields not found.
378///
379/// # Examples
380///
381/// ```
382/// use mcp_execution_skill::extract_skill_metadata;
383///
384/// let content = r"---
385/// name: github-progressive
386/// description: GitHub MCP server operations
387/// ---
388///
389/// # GitHub Progressive
390///
391/// ## Quick Start
392///
393/// Content here.
394/// ";
395///
396/// let metadata = extract_skill_metadata(content).unwrap();
397/// assert_eq!(metadata.name, "github-progressive");
398/// assert_eq!(metadata.description, "GitHub MCP server operations");
399/// ```
400pub fn extract_skill_metadata(content: &str) -> Result<crate::types::SkillMetadata, String> {
401    use crate::types::SkillMetadata;
402
403    // Extract YAML frontmatter (using pre-compiled regex)
404    let frontmatter = FRONTMATTER_REGEX
405        .captures(content)
406        .and_then(|c| c.get(1))
407        .map(|m| m.as_str())
408        .ok_or("YAML frontmatter not found")?;
409
410    // Extract name (using pre-compiled regex)
411    let name = NAME_REGEX
412        .captures(frontmatter)
413        .and_then(|c| c.get(1))
414        .map(|m| m.as_str().trim().to_string())
415        .ok_or("'name' field not found in frontmatter")?;
416
417    // Extract description (using pre-compiled regex)
418    let description = SKILL_DESC_REGEX
419        .captures(frontmatter)
420        .and_then(|c| c.get(1))
421        .map(|m| m.as_str().trim().to_string())
422        .ok_or("'description' field not found in frontmatter")?;
423
424    // Count sections (H2 headers)
425    let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
426
427    // Count words (approximate)
428    let word_count = content.split_whitespace().count();
429
430    Ok(SkillMetadata {
431        name,
432        description,
433        section_count,
434        word_count,
435    })
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use mcp_execution_core::metadata::{ParameterMetadata, ToolMetadata};
442    use tempfile::TempDir;
443
444    fn sample_metadata(tool_count: usize) -> ServerMetadata {
445        ServerMetadata {
446            schema_version: METADATA_SCHEMA_VERSION,
447            server_id: "github".to_string(),
448            server_name: "GitHub".to_string(),
449            server_version: "1.0.0".to_string(),
450            tools: (0..tool_count)
451                .map(|i| ToolMetadata {
452                    name: format!("tool_{i}"),
453                    typescript_name: format!("tool{i}"),
454                    category: Some("test".to_string()),
455                    keywords: vec!["test".to_string()],
456                    description: Some(format!("Tool {i}")),
457                    parameters: vec![ParameterMetadata {
458                        name: "param".to_string(),
459                        typescript_type: "string".to_string(),
460                        required: true,
461                        description: Some("A parameter".to_string()),
462                    }],
463                })
464                .collect(),
465        }
466    }
467
468    /// Writes `_meta.json` plus a matching stub `.ts` file for each tool, since
469    /// `scan_tools_directory` cross-checks the sidecar against files on disk.
470    async fn write_metadata(dir: &Path, meta: &ServerMetadata) {
471        let content = serde_json::to_string_pretty(meta).unwrap();
472        tokio::fs::write(dir.join(METADATA_FILE_NAME), content)
473            .await
474            .unwrap();
475
476        for tool in &meta.tools {
477            tokio::fs::write(
478                dir.join(format!("{}.ts", tool.typescript_name)),
479                "export {}",
480            )
481            .await
482            .unwrap();
483        }
484    }
485
486    #[tokio::test]
487    async fn test_scan_tools_directory_round_trip_preserves_parameter_descriptions() {
488        // Issue #141 regression: the old regex-based parser hard-coded parameter
489        // descriptions to `None`. The sidecar-backed scanner must preserve them.
490        let temp_dir = TempDir::new().unwrap();
491        let meta = sample_metadata(2);
492        write_metadata(temp_dir.path(), &meta).await;
493
494        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
495        let tools = result.tools;
496
497        assert_eq!(tools.len(), 2);
498        assert_eq!(tools[0].name, "tool_0");
499        assert_eq!(tools[0].server_id, "github");
500        assert_eq!(tools[0].parameters.len(), 1);
501        assert_eq!(
502            tools[0].parameters[0].description,
503            Some("A parameter".to_string()),
504            "parameter descriptions must survive the sidecar round-trip"
505        );
506        assert!(result.warnings.is_empty());
507    }
508
509    #[tokio::test]
510    async fn test_scan_tools_directory_sorts_by_name() {
511        let temp_dir = TempDir::new().unwrap();
512        let mut meta = sample_metadata(0);
513        meta.tools = vec![
514            ToolMetadata {
515                name: "zebra".to_string(),
516                typescript_name: "zebra".to_string(),
517                category: None,
518                keywords: vec![],
519                description: None,
520                parameters: vec![],
521            },
522            ToolMetadata {
523                name: "alpha".to_string(),
524                typescript_name: "alpha".to_string(),
525                category: None,
526                keywords: vec![],
527                description: None,
528                parameters: vec![],
529            },
530        ];
531        write_metadata(temp_dir.path(), &meta).await;
532
533        let tools = scan_tools_directory(temp_dir.path()).await.unwrap().tools;
534
535        assert_eq!(tools[0].name, "alpha");
536        assert_eq!(tools[1].name, "zebra");
537    }
538
539    #[tokio::test]
540    async fn test_scan_tools_directory_stale_metadata_missing_ts_file() {
541        // Issue #154/#155 regression: a sidecar entry whose `.ts` file was
542        // deleted (or never written, e.g. an interrupted `generate`) must be
543        // reported instead of silently vanishing from `SKILL.md`.
544        let temp_dir = TempDir::new().unwrap();
545        let meta = sample_metadata(1);
546        // Write only the sidecar, not the tool's `.ts` file.
547        let content = serde_json::to_string_pretty(&meta).unwrap();
548        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
549            .await
550            .unwrap();
551
552        let result = scan_tools_directory(temp_dir.path()).await;
553
554        match result {
555            Err(ScanError::StaleMetadata {
556                tool,
557                expected_file,
558                ..
559            }) => {
560                assert_eq!(tool, "tool_0");
561                assert_eq!(expected_file, "tool0.ts");
562            }
563            other => panic!("expected StaleMetadata, got: {other:?}"),
564        }
565    }
566
567    #[tokio::test]
568    async fn test_scan_tools_directory_stale_metadata_reports_first_missing_in_sidecar_order() {
569        // With multiple tools in the sidecar, only some of which have a missing
570        // `.ts` file, the check short-circuits on the first missing entry in
571        // sidecar order rather than scanning every tool up front.
572        let temp_dir = TempDir::new().unwrap();
573        let meta = sample_metadata(3);
574        let content = serde_json::to_string_pretty(&meta).unwrap();
575        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
576            .await
577            .unwrap();
578
579        // Only write the `.ts` file for the middle tool; `tool_0` and `tool_2`
580        // are both missing, but `tool_0` is first in sidecar order.
581        tokio::fs::write(temp_dir.path().join("tool1.ts"), "export {}")
582            .await
583            .unwrap();
584
585        let result = scan_tools_directory(temp_dir.path()).await;
586
587        match result {
588            Err(ScanError::StaleMetadata {
589                tool,
590                expected_file,
591                ..
592            }) => {
593                assert_eq!(tool, "tool_0");
594                assert_eq!(expected_file, "tool0.ts");
595            }
596            other => panic!("expected StaleMetadata for tool_0, got: {other:?}"),
597        }
598    }
599
600    #[tokio::test]
601    async fn test_scan_tools_directory_extra_ts_file_excluded_from_result() {
602        // Issue #154/#155 regression: a `.ts` file on disk that the sidecar
603        // does not reference (e.g. left over from a renamed/removed tool) must
604        // not be fatal and must not leak into the scan result — it is logged
605        // via `tracing::warn!` instead.
606        //
607        // Issue #161: the drift must also be visible in the returned
608        // `ScanResult::warnings`, not just in the tracing log line.
609        let temp_dir = TempDir::new().unwrap();
610        let meta = sample_metadata(1);
611        write_metadata(temp_dir.path(), &meta).await;
612
613        tokio::fs::write(temp_dir.path().join("orphan.ts"), "export {}")
614            .await
615            .unwrap();
616
617        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
618
619        assert_eq!(
620            result.tools.len(),
621            1,
622            "the orphaned .ts file must not be reported as a tool"
623        );
624        assert_eq!(result.tools[0].name, "tool_0");
625        assert_eq!(
626            result.warnings.len(),
627            1,
628            "the orphaned .ts file must be surfaced as a warning"
629        );
630        assert!(
631            result.warnings[0].contains("orphan.ts"),
632            "warning must name the excluded file: {:?}",
633            result.warnings[0]
634        );
635    }
636
637    #[tokio::test]
638    async fn test_scan_tools_directory_index_ts_not_treated_as_extra() {
639        // `index.ts` is the generated aggregator file and is never listed in
640        // the sidecar; its presence alone must not affect the scan result.
641        let temp_dir = TempDir::new().unwrap();
642        let meta = sample_metadata(1);
643        write_metadata(temp_dir.path(), &meta).await;
644
645        tokio::fs::write(temp_dir.path().join("index.ts"), "export * from './tool0';")
646            .await
647            .unwrap();
648
649        let result = scan_tools_directory(temp_dir.path()).await.unwrap();
650
651        assert_eq!(result.tools.len(), 1);
652        assert_eq!(result.tools[0].name, "tool_0");
653        assert!(
654            result.warnings.is_empty(),
655            "index.ts must not be reported as a warning"
656        );
657    }
658
659    #[test]
660    fn test_stale_metadata_error_message_tells_user_to_regenerate() {
661        let err = ScanError::StaleMetadata {
662            tool: "create_issue".to_string(),
663            expected_file: "createIssue.ts".to_string(),
664            sidecar_path: "~/.claude/servers/github/_meta.json".to_string(),
665        };
666
667        let message = err.to_string();
668        assert!(
669            message.contains("create_issue"),
670            "message must name the affected tool"
671        );
672        assert!(
673            message.contains("createIssue.ts"),
674            "message must name the missing file"
675        );
676        assert!(
677            message.contains("re-run 'generate'"),
678            "message must tell the user how to fix it: {message}"
679        );
680    }
681
682    #[tokio::test]
683    async fn test_scan_tools_directory_missing_metadata() {
684        let temp_dir = TempDir::new().unwrap();
685
686        let result = scan_tools_directory(temp_dir.path()).await;
687
688        assert!(matches!(result, Err(ScanError::MissingMetadata { .. })));
689    }
690
691    #[tokio::test]
692    async fn test_scan_tools_directory_corrupt_json() {
693        let temp_dir = TempDir::new().unwrap();
694        tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), "{not valid json")
695            .await
696            .unwrap();
697
698        let result = scan_tools_directory(temp_dir.path()).await;
699
700        assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
701    }
702
703    #[tokio::test]
704    async fn test_scan_tools_directory_unsupported_schema() {
705        let temp_dir = TempDir::new().unwrap();
706        let mut meta = sample_metadata(1);
707        meta.schema_version = METADATA_SCHEMA_VERSION + 1;
708        write_metadata(temp_dir.path(), &meta).await;
709
710        let result = scan_tools_directory(temp_dir.path()).await;
711
712        match result {
713            Err(ScanError::UnsupportedSchema { found, expected }) => {
714                assert_eq!(found, METADATA_SCHEMA_VERSION + 1);
715                assert_eq!(expected, METADATA_SCHEMA_VERSION);
716            }
717            other => panic!("expected UnsupportedSchema, got: {other:?}"),
718        }
719    }
720
721    #[tokio::test]
722    async fn test_scan_tools_directory_too_many_tools() {
723        let temp_dir = TempDir::new().unwrap();
724        let meta = sample_metadata(MAX_TOOL_FILES + 1);
725        write_metadata(temp_dir.path(), &meta).await;
726
727        let result = scan_tools_directory(temp_dir.path()).await;
728
729        match result {
730            Err(ScanError::TooManyFiles { count, limit }) => {
731                assert_eq!(count, MAX_TOOL_FILES + 1);
732                assert_eq!(limit, MAX_TOOL_FILES);
733            }
734            other => panic!("expected TooManyFiles, got: {other:?}"),
735        }
736    }
737
738    #[tokio::test]
739    async fn test_scan_tools_directory_file_too_large() {
740        let temp_dir = TempDir::new().unwrap();
741        let mut meta = sample_metadata(1);
742        // MAX_FILE_SIZE (1MB) always fits in usize; the cast cannot truncate.
743        #[allow(clippy::cast_possible_truncation)]
744        let padding = "a".repeat((MAX_FILE_SIZE as usize) + 1);
745        meta.tools[0].description = Some(padding);
746        write_metadata(temp_dir.path(), &meta).await;
747
748        let result = scan_tools_directory(temp_dir.path()).await;
749
750        match result {
751            Err(ScanError::FileTooLarge { size, limit, .. }) => {
752                assert!(size > MAX_FILE_SIZE);
753                assert_eq!(limit, MAX_FILE_SIZE);
754            }
755            other => panic!("expected FileTooLarge, got: {other:?}"),
756        }
757    }
758
759    #[tokio::test]
760    async fn test_scan_tools_directory_nonexistent() {
761        let result = scan_tools_directory(Path::new("/nonexistent/path/for/testing")).await;
762
763        assert!(matches!(result, Err(ScanError::DirectoryNotFound { .. })));
764    }
765
766    // ========================================================================
767    // extract_skill_metadata Tests
768    // ========================================================================
769
770    #[test]
771    fn test_extract_skill_metadata_valid() {
772        let content = r"---
773name: github-progressive
774description: GitHub MCP server operations
775---
776
777# GitHub Progressive
778
779## Quick Start
780
781Content here.
782
783## Common Tasks
784
785More content.
786";
787
788        let result = extract_skill_metadata(content);
789        assert!(result.is_ok());
790
791        let metadata = result.unwrap();
792        assert_eq!(metadata.name, "github-progressive");
793        assert_eq!(metadata.description, "GitHub MCP server operations");
794        assert_eq!(metadata.section_count, 2);
795        assert!(metadata.word_count > 0);
796    }
797
798    #[test]
799    fn test_extract_skill_metadata_no_frontmatter() {
800        let content = "# Test\n\nNo frontmatter";
801
802        let result = extract_skill_metadata(content);
803        assert!(result.is_err());
804        assert!(result.unwrap_err().contains("YAML frontmatter not found"));
805    }
806
807    #[test]
808    fn test_extract_skill_metadata_missing_name() {
809        let content = "---\ndescription: test\n---\n# Test";
810
811        let result = extract_skill_metadata(content);
812        assert!(result.is_err());
813        assert!(result.unwrap_err().contains("'name' field not found"));
814    }
815
816    #[test]
817    fn test_extract_skill_metadata_missing_description() {
818        let content = "---\nname: test\n---\n# Test";
819
820        let result = extract_skill_metadata(content);
821        assert!(result.is_err());
822        assert!(
823            result
824                .unwrap_err()
825                .contains("'description' field not found")
826        );
827    }
828
829    #[test]
830    fn test_extract_skill_metadata_with_extra_fields() {
831        let content = r"---
832name: test-skill
833description: Test description
834version: 1.0.0
835author: Test Author
836---
837
838# Test
839";
840
841        let result = extract_skill_metadata(content);
842        assert!(result.is_ok());
843
844        let metadata = result.unwrap();
845        assert_eq!(metadata.name, "test-skill");
846        assert_eq!(metadata.description, "Test description");
847    }
848
849    #[test]
850    fn test_extract_skill_metadata_multiline_description() {
851        let content = r"---
852name: test
853description: This is a long description that contains multiple words
854---
855
856# Test
857";
858
859        let result = extract_skill_metadata(content);
860        assert!(result.is_ok());
861
862        let metadata = result.unwrap();
863        assert!(metadata.description.contains("multiple words"));
864    }
865}