Skip to main content

aster/skills/
loader.rs

1//! Skill Loader
2//!
3//! Handles parsing and loading skills from SKILL.md files.
4
5use super::types::{SkillDefinition, SkillExecutionMode, SkillFrontmatter, SkillSource};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Parse frontmatter from skill content
11///
12/// Supports two parsing modes:
13/// 1. Simple fields (name, description, etc.) - line-by-line parsing
14/// 2. Complex fields (workflow) - full YAML parsing via serde_yaml
15///
16/// ```text
17/// function NV(A) {
18///   let Q = /^---\s*\n([\s\S]*?)---\s*\n?/;
19///   let B = A.match(Q);
20///   if (!B) return { frontmatter: {}, content: A };
21///   ...
22/// }
23/// ```
24pub fn parse_frontmatter(content: &str) -> (SkillFrontmatter, String) {
25    // Match frontmatter block: ---\n...\n---
26    let regex = regex::Regex::new(r"^---\s*\n([\s\S]*?)---\s*\n?").unwrap();
27
28    if let Some(captures) = regex.captures(content) {
29        let frontmatter_text = captures.get(1).map(|m| m.as_str()).unwrap_or("");
30        let body_start = captures.get(0).map(|m| m.end()).unwrap_or(0);
31        let body = content.get(body_start..).unwrap_or("").to_string();
32
33        // Try full YAML parsing first (handles complex fields like workflow)
34        if let Ok(frontmatter) = serde_yaml::from_str::<SkillFrontmatter>(frontmatter_text) {
35            return (frontmatter, body);
36        }
37
38        // Fallback to simple line-by-line parsing for basic fields
39        let mut frontmatter = SkillFrontmatter::default();
40        let mut extra_fields: HashMap<String, String> = HashMap::new();
41
42        for line in frontmatter_text.lines() {
43            if let Some(colon_idx) = line.find(':') {
44                let key = line.get(..colon_idx).unwrap_or("").trim();
45                let value = line.get(colon_idx + 1..).unwrap_or("").trim();
46                // Remove surrounding quotes
47                let clean_value = value
48                    .trim_start_matches('"')
49                    .trim_end_matches('"')
50                    .trim_start_matches('\'')
51                    .trim_end_matches('\'')
52                    .to_string();
53
54                match key {
55                    "name" => frontmatter.name = Some(clean_value),
56                    "description" => frontmatter.description = Some(clean_value),
57                    "allowed-tools" => frontmatter.allowed_tools = Some(clean_value),
58                    "argument-hint" => frontmatter.argument_hint = Some(clean_value),
59                    "when-to-use" | "when_to_use" => frontmatter.when_to_use = Some(clean_value),
60                    "version" => frontmatter.version = Some(clean_value),
61                    "model" => frontmatter.model = Some(clean_value),
62                    "user-invocable" => frontmatter.user_invocable = Some(clean_value),
63                    "disable-model-invocation" => {
64                        frontmatter.disable_model_invocation = Some(clean_value)
65                    }
66                    // 新增字段解析
67                    "execution-mode" => frontmatter.execution_mode = Some(clean_value),
68                    "provider" => frontmatter.provider = Some(clean_value),
69                    _ => {
70                        extra_fields.insert(key.to_string(), clean_value);
71                    }
72                }
73            }
74        }
75
76        (frontmatter, body)
77    } else {
78        (SkillFrontmatter::default(), content.to_string())
79    }
80}
81
82/// Parse allowed-tools field into a list
83pub fn parse_allowed_tools(value: Option<&str>) -> Option<Vec<String>> {
84    value.and_then(|v| {
85        if v.is_empty() {
86            return None;
87        }
88        if v.contains(',') {
89            Some(
90                v.split(',')
91                    .map(|s| s.trim().to_string())
92                    .filter(|s| !s.is_empty())
93                    .collect(),
94            )
95        } else {
96            Some(vec![v.trim().to_string()])
97        }
98    })
99}
100
101/// Parse boolean field
102pub fn parse_boolean(value: Option<&str>, default: bool) -> bool {
103    value
104        .map(|v| {
105            let lower = v.to_lowercase();
106            matches!(lower.as_str(), "true" | "1" | "yes")
107        })
108        .unwrap_or(default)
109}
110
111/// Find supporting files in a skill directory
112pub fn find_supporting_files(directory: &Path, skill_file: &Path) -> Vec<PathBuf> {
113    let mut files = Vec::new();
114
115    if let Ok(entries) = fs::read_dir(directory) {
116        for entry in entries.flatten() {
117            let path = entry.path();
118            if path.is_file() && path != skill_file {
119                files.push(path);
120            } else if path.is_dir() {
121                // Recursively find files in subdirectories
122                if let Ok(sub_entries) = fs::read_dir(&path) {
123                    for sub_entry in sub_entries.flatten() {
124                        let sub_path = sub_entry.path();
125                        if sub_path.is_file() {
126                            files.push(sub_path);
127                        }
128                    }
129                }
130            }
131        }
132    }
133
134    files
135}
136
137/// Load a skill from a SKILL.md file
138pub fn load_skill_from_file(
139    skill_name: &str,
140    file_path: &Path,
141    source: SkillSource,
142) -> Result<SkillDefinition, String> {
143    let content =
144        fs::read_to_string(file_path).map_err(|e| format!("Failed to read skill file: {}", e))?;
145
146    let (frontmatter, markdown_content) = parse_frontmatter(&content);
147
148    let base_dir = file_path
149        .parent()
150        .ok_or("Skill file has no parent directory")?
151        .to_path_buf();
152
153    let supporting_files = find_supporting_files(&base_dir, file_path);
154
155    let display_name = frontmatter
156        .name
157        .clone()
158        .unwrap_or_else(|| skill_name.to_string());
159    let description = frontmatter.description.clone().unwrap_or_default();
160    let has_user_specified_description = frontmatter.description.is_some();
161
162    let allowed_tools = parse_allowed_tools(frontmatter.allowed_tools.as_deref());
163    let disable_model_invocation =
164        parse_boolean(frontmatter.disable_model_invocation.as_deref(), false);
165    let user_invocable = parse_boolean(frontmatter.user_invocable.as_deref(), true);
166
167    // 解析执行模式
168    let execution_mode = frontmatter
169        .execution_mode
170        .as_deref()
171        .map(SkillExecutionMode::parse)
172        .unwrap_or_default();
173
174    Ok(SkillDefinition {
175        skill_name: skill_name.to_string(),
176        display_name,
177        description,
178        has_user_specified_description,
179        markdown_content,
180        allowed_tools,
181        argument_hint: frontmatter.argument_hint,
182        when_to_use: frontmatter.when_to_use,
183        version: frontmatter.version,
184        model: frontmatter.model,
185        disable_model_invocation,
186        user_invocable,
187        source,
188        base_dir,
189        file_path: file_path.to_path_buf(),
190        supporting_files,
191        execution_mode,
192        provider: frontmatter.provider,
193        workflow: frontmatter.workflow,
194    })
195}
196
197/// Load skills from a directory
198///
199/// 1. Check for SKILL.md in root (single skill mode)
200/// 2. Otherwise, scan subdirectories for SKILL.md files
201pub fn load_skills_from_directory(dir_path: &Path, source: SkillSource) -> Vec<SkillDefinition> {
202    let mut results = Vec::new();
203
204    if !dir_path.exists() {
205        return results;
206    }
207
208    // 1. Check for SKILL.md in root directory (single skill mode)
209    let root_skill_file = dir_path.join("SKILL.md");
210    if root_skill_file.exists() {
211        let skill_name = format!(
212            "{}:{}",
213            source,
214            dir_path
215                .file_name()
216                .and_then(|n| n.to_str())
217                .unwrap_or("unknown")
218        );
219
220        if let Ok(skill) = load_skill_from_file(&skill_name, &root_skill_file, source) {
221            results.push(skill);
222        }
223        return results;
224    }
225
226    // 2. Scan subdirectories for SKILL.md files
227    if let Ok(entries) = fs::read_dir(dir_path) {
228        for entry in entries.flatten() {
229            let path = entry.path();
230            if !path.is_dir() {
231                continue;
232            }
233
234            let skill_file = path.join("SKILL.md");
235            if skill_file.exists() {
236                let skill_name = format!(
237                    "{}:{}",
238                    source,
239                    path.file_name()
240                        .and_then(|n| n.to_str())
241                        .unwrap_or("unknown")
242                );
243
244                if let Ok(skill) = load_skill_from_file(&skill_name, &skill_file, source) {
245                    results.push(skill);
246                }
247            }
248        }
249    }
250
251    results
252}
253
254/// Get enabled plugins from settings
255pub fn get_enabled_plugins() -> std::collections::HashSet<String> {
256    let mut enabled = std::collections::HashSet::new();
257
258    if let Some(home) = dirs::home_dir() {
259        let settings_path = home.join(".claude/settings.json");
260        if let Ok(content) = fs::read_to_string(&settings_path) {
261            if let Ok(settings) = serde_json::from_str::<serde_json::Value>(&content) {
262                if let Some(plugins) = settings.get("enabledPlugins").and_then(|v| v.as_object()) {
263                    for (plugin_id, is_enabled) in plugins {
264                        if is_enabled.as_bool().unwrap_or(false) {
265                            enabled.insert(plugin_id.clone());
266                        }
267                    }
268                }
269            }
270        }
271    }
272
273    enabled
274}
275
276/// Load skills from plugin cache
277///
278pub fn load_skills_from_plugin_cache() -> Vec<SkillDefinition> {
279    let mut results = Vec::new();
280
281    let home = match dirs::home_dir() {
282        Some(h) => h,
283        None => return results,
284    };
285
286    let plugins_cache_dir = home.join(".claude/plugins/cache");
287    if !plugins_cache_dir.exists() {
288        return results;
289    }
290
291    let enabled_plugins = get_enabled_plugins();
292
293    // Traverse marketplace directories
294    let marketplaces = match fs::read_dir(&plugins_cache_dir) {
295        Ok(entries) => entries,
296        Err(_) => return results,
297    };
298
299    for marketplace_entry in marketplaces.flatten() {
300        if !marketplace_entry.path().is_dir() {
301            continue;
302        }
303
304        let marketplace_name = marketplace_entry.file_name();
305        let marketplace_path = marketplace_entry.path();
306
307        let plugins = match fs::read_dir(&marketplace_path) {
308            Ok(entries) => entries,
309            Err(_) => continue,
310        };
311
312        for plugin_entry in plugins.flatten() {
313            if !plugin_entry.path().is_dir() {
314                continue;
315            }
316
317            let plugin_name = plugin_entry.file_name();
318            let plugin_id = format!(
319                "{}@{}",
320                plugin_name.to_string_lossy(),
321                marketplace_name.to_string_lossy()
322            );
323
324            // Check if plugin is enabled
325            if !enabled_plugins.contains(&plugin_id) {
326                continue;
327            }
328
329            let plugin_path = plugin_entry.path();
330            let versions = match fs::read_dir(&plugin_path) {
331                Ok(entries) => entries,
332                Err(_) => continue,
333            };
334
335            for version_entry in versions.flatten() {
336                if !version_entry.path().is_dir() {
337                    continue;
338                }
339
340                let skills_path = version_entry.path().join("skills");
341                if !skills_path.exists() {
342                    continue;
343                }
344
345                let skill_dirs = match fs::read_dir(&skills_path) {
346                    Ok(entries) => entries,
347                    Err(_) => continue,
348                };
349
350                for skill_dir_entry in skill_dirs.flatten() {
351                    if !skill_dir_entry.path().is_dir() {
352                        continue;
353                    }
354
355                    let skill_md_path = skill_dir_entry.path().join("SKILL.md");
356                    if !skill_md_path.exists() {
357                        continue;
358                    }
359
360                    let skill_name = format!(
361                        "{}:{}",
362                        plugin_name.to_string_lossy(),
363                        skill_dir_entry.file_name().to_string_lossy()
364                    );
365
366                    if let Ok(skill) =
367                        load_skill_from_file(&skill_name, &skill_md_path, SkillSource::Plugin)
368                    {
369                        results.push(skill);
370                    }
371                }
372            }
373        }
374    }
375
376    results
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use tempfile::TempDir;
383
384    #[test]
385    fn test_parse_frontmatter_basic() {
386        let content = r#"---
387name: test-skill
388description: A test skill
389---
390
391# Test Skill
392
393This is the body.
394"#;
395        let (fm, body) = parse_frontmatter(content);
396        assert_eq!(fm.name, Some("test-skill".to_string()));
397        assert_eq!(fm.description, Some("A test skill".to_string()));
398        assert!(body.contains("# Test Skill"));
399    }
400
401    #[test]
402    fn test_parse_frontmatter_no_frontmatter() {
403        let content = "# Just content\nNo frontmatter here.";
404        let (fm, body) = parse_frontmatter(content);
405        assert!(fm.name.is_none());
406        assert_eq!(body, content);
407    }
408
409    #[test]
410    fn test_parse_frontmatter_with_quotes() {
411        let content = r#"---
412name: "quoted-name"
413description: 'single quoted'
414---
415Body
416"#;
417        let (fm, _) = parse_frontmatter(content);
418        assert_eq!(fm.name, Some("quoted-name".to_string()));
419        assert_eq!(fm.description, Some("single quoted".to_string()));
420    }
421
422    #[test]
423    fn test_parse_allowed_tools() {
424        assert_eq!(parse_allowed_tools(None), None);
425        assert_eq!(parse_allowed_tools(Some("")), None);
426        assert_eq!(
427            parse_allowed_tools(Some("tool1")),
428            Some(vec!["tool1".to_string()])
429        );
430        assert_eq!(
431            parse_allowed_tools(Some("tool1, tool2, tool3")),
432            Some(vec![
433                "tool1".to_string(),
434                "tool2".to_string(),
435                "tool3".to_string()
436            ])
437        );
438    }
439
440    #[test]
441    fn test_parse_boolean() {
442        assert!(!parse_boolean(None, false));
443        assert!(parse_boolean(None, true));
444        assert!(parse_boolean(Some("true"), false));
445        assert!(parse_boolean(Some("TRUE"), false));
446        assert!(parse_boolean(Some("1"), false));
447        assert!(parse_boolean(Some("yes"), false));
448        assert!(!parse_boolean(Some("false"), true));
449        assert!(!parse_boolean(Some("no"), true));
450    }
451
452    #[test]
453    fn test_load_skill_from_file() {
454        let temp_dir = TempDir::new().unwrap();
455        let skill_dir = temp_dir.path().join("my-skill");
456        fs::create_dir(&skill_dir).unwrap();
457
458        let skill_file = skill_dir.join("SKILL.md");
459        fs::write(
460            &skill_file,
461            r#"---
462name: my-skill
463description: Test skill description
464allowed-tools: tool1, tool2
465version: 1.0.0
466---
467
468# My Skill
469
470Instructions here.
471"#,
472        )
473        .unwrap();
474
475        // Add supporting file
476        fs::write(skill_dir.join("helper.py"), "print('hello')").unwrap();
477
478        let skill = load_skill_from_file("user:my-skill", &skill_file, SkillSource::User).unwrap();
479
480        assert_eq!(skill.skill_name, "user:my-skill");
481        assert_eq!(skill.display_name, "my-skill");
482        assert_eq!(skill.description, "Test skill description");
483        assert!(skill.has_user_specified_description);
484        assert_eq!(
485            skill.allowed_tools,
486            Some(vec!["tool1".to_string(), "tool2".to_string()])
487        );
488        assert_eq!(skill.version, Some("1.0.0".to_string()));
489        assert!(skill.user_invocable);
490        assert!(!skill.disable_model_invocation);
491        assert_eq!(skill.supporting_files.len(), 1);
492    }
493
494    #[test]
495    fn test_load_skills_from_directory() {
496        let temp_dir = TempDir::new().unwrap();
497        let skills_dir = temp_dir.path().join("skills");
498        fs::create_dir(&skills_dir).unwrap();
499
500        // Create skill 1
501        let skill1_dir = skills_dir.join("skill-one");
502        fs::create_dir(&skill1_dir).unwrap();
503        fs::write(
504            skill1_dir.join("SKILL.md"),
505            r#"---
506name: skill-one
507description: First skill
508---
509Content 1
510"#,
511        )
512        .unwrap();
513
514        // Create skill 2
515        let skill2_dir = skills_dir.join("skill-two");
516        fs::create_dir(&skill2_dir).unwrap();
517        fs::write(
518            skill2_dir.join("SKILL.md"),
519            r#"---
520name: skill-two
521description: Second skill
522---
523Content 2
524"#,
525        )
526        .unwrap();
527
528        let skills = load_skills_from_directory(&skills_dir, SkillSource::User);
529
530        assert_eq!(skills.len(), 2);
531        let names: Vec<_> = skills.iter().map(|s| s.short_name()).collect();
532        assert!(names.contains(&"skill-one"));
533        assert!(names.contains(&"skill-two"));
534    }
535
536    #[test]
537    fn test_load_skills_single_skill_mode() {
538        let temp_dir = TempDir::new().unwrap();
539        let skill_dir = temp_dir.path().join("single-skill");
540        fs::create_dir(&skill_dir).unwrap();
541
542        // SKILL.md in root = single skill mode
543        fs::write(
544            skill_dir.join("SKILL.md"),
545            r#"---
546name: single
547description: Single skill
548---
549Content
550"#,
551        )
552        .unwrap();
553
554        let skills = load_skills_from_directory(&skill_dir, SkillSource::Project);
555
556        assert_eq!(skills.len(), 1);
557        assert_eq!(skills[0].display_name, "single");
558    }
559
560    #[test]
561    fn test_find_supporting_files() {
562        let temp_dir = TempDir::new().unwrap();
563        let skill_dir = temp_dir.path();
564
565        let skill_file = skill_dir.join("SKILL.md");
566        fs::write(&skill_file, "content").unwrap();
567        fs::write(skill_dir.join("helper.py"), "code").unwrap();
568        fs::write(skill_dir.join("config.json"), "{}").unwrap();
569
570        let sub_dir = skill_dir.join("templates");
571        fs::create_dir(&sub_dir).unwrap();
572        fs::write(sub_dir.join("template.txt"), "template").unwrap();
573
574        let files = find_supporting_files(skill_dir, &skill_file);
575
576        assert_eq!(files.len(), 3);
577    }
578
579    // ==================== 新增字段解析测试 ====================
580
581    #[test]
582    fn test_parse_frontmatter_with_execution_mode() {
583        let content = r#"---
584name: workflow-skill
585description: A workflow skill
586execution-mode: workflow
587---
588
589# Workflow Skill
590
591Instructions here.
592"#;
593        let (fm, body) = parse_frontmatter(content);
594        assert_eq!(fm.name, Some("workflow-skill".to_string()));
595        assert_eq!(fm.execution_mode, Some("workflow".to_string()));
596        assert!(body.contains("# Workflow Skill"));
597    }
598
599    #[test]
600    fn test_parse_frontmatter_with_provider() {
601        let content = r#"---
602name: provider-skill
603description: A skill with provider
604provider: openai
605---
606
607# Provider Skill
608
609Content here.
610"#;
611        let (fm, _) = parse_frontmatter(content);
612        assert_eq!(fm.name, Some("provider-skill".to_string()));
613        assert_eq!(fm.provider, Some("openai".to_string()));
614    }
615
616    #[test]
617    fn test_parse_frontmatter_with_execution_mode_and_provider() {
618        let content = r#"---
619name: full-skill
620description: A skill with all new fields
621execution-mode: agent
622provider: claude
623model: claude-3-opus
624---
625
626# Full Skill
627
628Content.
629"#;
630        let (fm, _) = parse_frontmatter(content);
631        assert_eq!(fm.name, Some("full-skill".to_string()));
632        assert_eq!(fm.execution_mode, Some("agent".to_string()));
633        assert_eq!(fm.provider, Some("claude".to_string()));
634        assert_eq!(fm.model, Some("claude-3-opus".to_string()));
635    }
636
637    #[test]
638    fn test_parse_frontmatter_execution_mode_default() {
639        // 不指定 execution-mode 时应为 None
640        let content = r#"---
641name: simple-skill
642description: A simple skill
643---
644
645Content.
646"#;
647        let (fm, _) = parse_frontmatter(content);
648        assert_eq!(fm.name, Some("simple-skill".to_string()));
649        assert!(fm.execution_mode.is_none());
650        assert!(fm.provider.is_none());
651    }
652
653    #[test]
654    fn test_parse_frontmatter_with_workflow_yaml() {
655        let content = r#"---
656name: workflow-skill
657description: A workflow skill with steps
658execution-mode: workflow
659provider: openai
660workflow:
661  steps:
662    - id: analyze
663      name: 分析代码
664      prompt: "分析以下代码:${user_input}"
665      output: analysis_result
666    - id: generate
667      name: 生成代码
668      prompt: "基于分析结果生成代码:${analysis_result}"
669      output: generated_code
670      dependencies:
671        - analyze
672  max_retries: 3
673  continue_on_failure: true
674---
675
676# Workflow Skill
677
678This skill runs a multi-step workflow.
679"#;
680        let (fm, body) = parse_frontmatter(content);
681
682        // 验证基本字段
683        assert_eq!(fm.name, Some("workflow-skill".to_string()));
684        assert_eq!(fm.execution_mode, Some("workflow".to_string()));
685        assert_eq!(fm.provider, Some("openai".to_string()));
686
687        // 验证 workflow 定义
688        assert!(fm.workflow.is_some());
689        let workflow = fm.workflow.unwrap();
690        assert_eq!(workflow.steps.len(), 2);
691        assert_eq!(workflow.max_retries, 3);
692        assert!(workflow.continue_on_failure);
693
694        // 验证步骤
695        assert_eq!(workflow.steps[0].id, "analyze");
696        assert_eq!(workflow.steps[0].name, "分析代码");
697        assert_eq!(workflow.steps[1].id, "generate");
698        assert_eq!(workflow.steps[1].dependencies, vec!["analyze"]);
699
700        // 验证 body
701        assert!(body.contains("# Workflow Skill"));
702    }
703
704    #[test]
705    fn test_load_skill_with_execution_mode() {
706        let temp_dir = TempDir::new().unwrap();
707        let skill_dir = temp_dir.path().join("workflow-skill");
708        fs::create_dir(&skill_dir).unwrap();
709
710        let skill_file = skill_dir.join("SKILL.md");
711        fs::write(
712            &skill_file,
713            r#"---
714name: workflow-skill
715description: A workflow skill
716execution-mode: workflow
717provider: gemini
718---
719
720# Workflow Skill
721
722Instructions here.
723"#,
724        )
725        .unwrap();
726
727        let skill =
728            load_skill_from_file("user:workflow-skill", &skill_file, SkillSource::User).unwrap();
729
730        assert_eq!(skill.skill_name, "user:workflow-skill");
731        assert_eq!(skill.execution_mode, SkillExecutionMode::Workflow);
732        assert_eq!(skill.provider, Some("gemini".to_string()));
733    }
734
735    #[test]
736    fn test_load_skill_with_workflow_definition() {
737        let temp_dir = TempDir::new().unwrap();
738        let skill_dir = temp_dir.path().join("full-workflow");
739        fs::create_dir(&skill_dir).unwrap();
740
741        let skill_file = skill_dir.join("SKILL.md");
742        fs::write(
743            &skill_file,
744            r#"---
745name: full-workflow
746description: A complete workflow skill
747execution-mode: workflow
748provider: openai
749workflow:
750  steps:
751    - id: step1
752      name: 第一步
753      prompt: "处理输入:${user_input}"
754      output: result1
755    - id: step2
756      name: 第二步
757      prompt: "继续处理:${result1}"
758      output: result2
759      dependencies:
760        - step1
761  max_retries: 2
762  continue_on_failure: false
763---
764
765# Full Workflow Skill
766
767This is a complete workflow skill.
768"#,
769        )
770        .unwrap();
771
772        let skill =
773            load_skill_from_file("user:full-workflow", &skill_file, SkillSource::User).unwrap();
774
775        assert_eq!(skill.execution_mode, SkillExecutionMode::Workflow);
776        assert_eq!(skill.provider, Some("openai".to_string()));
777
778        // 验证 workflow 定义
779        assert!(skill.workflow.is_some());
780        let workflow = skill.workflow.unwrap();
781        assert_eq!(workflow.steps.len(), 2);
782        assert_eq!(workflow.max_retries, 2);
783        assert!(!workflow.continue_on_failure);
784
785        // 验证步骤依赖
786        assert_eq!(workflow.steps[1].dependencies, vec!["step1"]);
787    }
788
789    #[test]
790    fn test_load_skill_default_execution_mode() {
791        let temp_dir = TempDir::new().unwrap();
792        let skill_dir = temp_dir.path().join("simple-skill");
793        fs::create_dir(&skill_dir).unwrap();
794
795        let skill_file = skill_dir.join("SKILL.md");
796        fs::write(
797            &skill_file,
798            r#"---
799name: simple-skill
800description: A simple skill without execution-mode
801---
802
803# Simple Skill
804
805Content.
806"#,
807        )
808        .unwrap();
809
810        let skill =
811            load_skill_from_file("user:simple-skill", &skill_file, SkillSource::User).unwrap();
812
813        // 默认执行模式应为 Prompt
814        assert_eq!(skill.execution_mode, SkillExecutionMode::Prompt);
815        assert!(skill.provider.is_none());
816        assert!(skill.workflow.is_none());
817    }
818
819    // ==================== Workflow YAML 解析综合测试 ====================
820    // Task 2.2: 添加 YAML workflow 解析逻辑
821    // Requirements: 3.1, 3.2, 3.3, 3.4
822
823    #[test]
824    fn test_parse_workflow_with_multiple_steps() {
825        // 测试多步骤工作流解析
826        // Requirements: 3.1, 3.2
827        let content = r#"---
828name: multi-step-workflow
829description: A workflow with multiple steps
830execution-mode: workflow
831workflow:
832  steps:
833    - id: step1
834      name: 第一步
835      prompt: "处理输入:${user_input}"
836      output: result1
837    - id: step2
838      name: 第二步
839      prompt: "继续处理:${result1}"
840      output: result2
841    - id: step3
842      name: 第三步
843      prompt: "最终处理:${result2}"
844      output: final_result
845---
846
847# Multi-Step Workflow
848"#;
849        let (fm, _) = parse_frontmatter(content);
850
851        assert!(fm.workflow.is_some());
852        let workflow = fm.workflow.unwrap();
853        assert_eq!(workflow.steps.len(), 3);
854
855        // 验证每个步骤
856        assert_eq!(workflow.steps[0].id, "step1");
857        assert_eq!(workflow.steps[0].name, "第一步");
858        assert_eq!(workflow.steps[0].output, "result1");
859
860        assert_eq!(workflow.steps[1].id, "step2");
861        assert_eq!(workflow.steps[1].name, "第二步");
862        assert!(workflow.steps[1].prompt.contains("${result1}"));
863
864        assert_eq!(workflow.steps[2].id, "step3");
865        assert_eq!(workflow.steps[2].output, "final_result");
866    }
867
868    #[test]
869    fn test_parse_workflow_with_complex_dependencies() {
870        // 测试复杂依赖关系的工作流
871        // Requirements: 3.2
872        let content = r#"---
873name: dependency-workflow
874execution-mode: workflow
875workflow:
876  steps:
877    - id: fetch_data
878      name: 获取数据
879      prompt: "获取数据:${user_input}"
880      output: raw_data
881    - id: validate
882      name: 验证数据
883      prompt: "验证:${raw_data}"
884      output: validated_data
885      dependencies:
886        - fetch_data
887    - id: transform
888      name: 转换数据
889      prompt: "转换:${raw_data}"
890      output: transformed_data
891      dependencies:
892        - fetch_data
893    - id: merge
894      name: 合并结果
895      prompt: "合并:${validated_data} 和 ${transformed_data}"
896      output: merged_result
897      dependencies:
898        - validate
899        - transform
900---
901
902Content
903"#;
904        let (fm, _) = parse_frontmatter(content);
905
906        let workflow = fm.workflow.unwrap();
907        assert_eq!(workflow.steps.len(), 4);
908
909        // 验证依赖关系
910        let fetch = &workflow.steps[0];
911        assert!(fetch.dependencies.is_empty());
912
913        let validate = &workflow.steps[1];
914        assert_eq!(validate.dependencies, vec!["fetch_data"]);
915
916        let transform = &workflow.steps[2];
917        assert_eq!(transform.dependencies, vec!["fetch_data"]);
918
919        let merge = &workflow.steps[3];
920        assert_eq!(merge.dependencies.len(), 2);
921        assert!(merge.dependencies.contains(&"validate".to_string()));
922        assert!(merge.dependencies.contains(&"transform".to_string()));
923    }
924
925    #[test]
926    fn test_parse_workflow_with_max_retries() {
927        // 测试 max_retries 配置
928        // Requirements: 3.3
929        let content = r#"---
930name: retry-workflow
931execution-mode: workflow
932workflow:
933  steps:
934    - id: step1
935      name: 步骤
936      prompt: "执行"
937      output: result
938  max_retries: 5
939---
940
941Content
942"#;
943        let (fm, _) = parse_frontmatter(content);
944
945        let workflow = fm.workflow.unwrap();
946        assert_eq!(workflow.max_retries, 5);
947    }
948
949    #[test]
950    fn test_parse_workflow_with_default_max_retries() {
951        // 测试 max_retries 默认值
952        // Requirements: 3.3
953        let content = r#"---
954name: default-retry-workflow
955execution-mode: workflow
956workflow:
957  steps:
958    - id: step1
959      name: 步骤
960      prompt: "执行"
961      output: result
962---
963
964Content
965"#;
966        let (fm, _) = parse_frontmatter(content);
967
968        let workflow = fm.workflow.unwrap();
969        assert_eq!(workflow.max_retries, 2); // 默认值为 2
970    }
971
972    #[test]
973    fn test_parse_workflow_with_continue_on_failure_true() {
974        // 测试 continue_on_failure = true
975        // Requirements: 3.4
976        let content = r#"---
977name: continue-workflow
978execution-mode: workflow
979workflow:
980  steps:
981    - id: step1
982      name: 步骤
983      prompt: "执行"
984      output: result
985  continue_on_failure: true
986---
987
988Content
989"#;
990        let (fm, _) = parse_frontmatter(content);
991
992        let workflow = fm.workflow.unwrap();
993        assert!(workflow.continue_on_failure);
994    }
995
996    #[test]
997    fn test_parse_workflow_with_default_continue_on_failure() {
998        // 测试 continue_on_failure 默认值
999        // Requirements: 3.4
1000        let content = r#"---
1001name: default-continue-workflow
1002execution-mode: workflow
1003workflow:
1004  steps:
1005    - id: step1
1006      name: 步骤
1007      prompt: "执行"
1008      output: result
1009---
1010
1011Content
1012"#;
1013        let (fm, _) = parse_frontmatter(content);
1014
1015        let workflow = fm.workflow.unwrap();
1016        assert!(!workflow.continue_on_failure); // 默认值为 false
1017    }
1018
1019    #[test]
1020    fn test_parse_workflow_with_all_settings() {
1021        // 测试所有配置项组合
1022        // Requirements: 3.3, 3.4
1023        let content = r#"---
1024name: full-config-workflow
1025execution-mode: workflow
1026workflow:
1027  steps:
1028    - id: analyze
1029      name: 分析
1030      prompt: "分析:${user_input}"
1031      output: analysis
1032    - id: generate
1033      name: 生成
1034      prompt: "生成:${analysis}"
1035      output: result
1036      dependencies:
1037        - analyze
1038  max_retries: 10
1039  continue_on_failure: true
1040---
1041
1042Content
1043"#;
1044        let (fm, _) = parse_frontmatter(content);
1045
1046        let workflow = fm.workflow.unwrap();
1047        assert_eq!(workflow.steps.len(), 2);
1048        assert_eq!(workflow.max_retries, 10);
1049        assert!(workflow.continue_on_failure);
1050    }
1051
1052    #[test]
1053    fn test_parse_workflow_with_empty_steps() {
1054        // 测试空步骤列表(边界情况)
1055        let content = r#"---
1056name: empty-workflow
1057execution-mode: workflow
1058workflow:
1059  steps: []
1060  max_retries: 3
1061---
1062
1063Content
1064"#;
1065        let (fm, _) = parse_frontmatter(content);
1066
1067        let workflow = fm.workflow.unwrap();
1068        assert!(workflow.steps.is_empty());
1069        assert_eq!(workflow.max_retries, 3);
1070    }
1071
1072    #[test]
1073    fn test_parse_workflow_step_with_optional_input() {
1074        // 测试步骤的可选 input 字段
1075        // Requirements: 3.2
1076        let content = r#"---
1077name: input-workflow
1078execution-mode: workflow
1079workflow:
1080  steps:
1081    - id: step1
1082      name: 带输入的步骤
1083      prompt: "处理"
1084      input: user_data
1085      output: result
1086---
1087
1088Content
1089"#;
1090        let (fm, _) = parse_frontmatter(content);
1091
1092        let workflow = fm.workflow.unwrap();
1093        assert_eq!(workflow.steps[0].input, Some("user_data".to_string()));
1094    }
1095
1096    #[test]
1097    fn test_parse_workflow_step_without_optional_fields() {
1098        // 测试步骤缺少可选字段时的默认值
1099        // Requirements: 3.2
1100        let content = r#"---
1101name: minimal-step-workflow
1102execution-mode: workflow
1103workflow:
1104  steps:
1105    - id: minimal
1106      name: 最小步骤
1107      prompt: "执行任务"
1108      output: result
1109---
1110
1111Content
1112"#;
1113        let (fm, _) = parse_frontmatter(content);
1114
1115        let workflow = fm.workflow.unwrap();
1116        let step = &workflow.steps[0];
1117
1118        assert_eq!(step.id, "minimal");
1119        assert_eq!(step.name, "最小步骤");
1120        assert_eq!(step.prompt, "执行任务");
1121        assert_eq!(step.output, "result");
1122        assert!(step.input.is_none()); // 可选字段默认为 None
1123        assert!(step.dependencies.is_empty()); // 可选字段默认为空
1124        assert!(!step.parallel); // 可选字段默认为 false
1125    }
1126
1127    #[test]
1128    fn test_parse_workflow_step_with_parallel_flag() {
1129        // 测试步骤的 parallel 标志(预留字段)
1130        let content = r#"---
1131name: parallel-workflow
1132execution-mode: workflow
1133workflow:
1134  steps:
1135    - id: step1
1136      name: 并行步骤
1137      prompt: "执行"
1138      output: result
1139      parallel: true
1140---
1141
1142Content
1143"#;
1144        let (fm, _) = parse_frontmatter(content);
1145
1146        let workflow = fm.workflow.unwrap();
1147        assert!(workflow.steps[0].parallel);
1148    }
1149
1150    #[test]
1151    fn test_parse_workflow_with_variable_interpolation_patterns() {
1152        // 测试各种变量插值模式
1153        // Requirements: 3.2
1154        let content = r#"---
1155name: interpolation-workflow
1156execution-mode: workflow
1157workflow:
1158  steps:
1159    - id: step1
1160      name: 用户输入
1161      prompt: "处理用户输入:${user_input}"
1162      output: processed
1163    - id: step2
1164      name: 引用前一步
1165      prompt: "基于 ${processed} 继续处理"
1166      output: continued
1167      dependencies:
1168        - step1
1169    - id: step3
1170      name: 多变量引用
1171      prompt: "合并 ${user_input} 和 ${processed} 以及 ${continued}"
1172      output: final
1173      dependencies:
1174        - step1
1175        - step2
1176---
1177
1178Content
1179"#;
1180        let (fm, _) = parse_frontmatter(content);
1181
1182        let workflow = fm.workflow.unwrap();
1183        assert_eq!(workflow.steps.len(), 3);
1184
1185        // 验证变量插值模式被正确保留
1186        assert!(workflow.steps[0].prompt.contains("${user_input}"));
1187        assert!(workflow.steps[1].prompt.contains("${processed}"));
1188        assert!(workflow.steps[2].prompt.contains("${user_input}"));
1189        assert!(workflow.steps[2].prompt.contains("${processed}"));
1190        assert!(workflow.steps[2].prompt.contains("${continued}"));
1191    }
1192
1193    #[test]
1194    fn test_parse_workflow_without_execution_mode() {
1195        // 测试:即使 execution-mode 不是 workflow,也应该解析 workflow 定义
1196        // Requirements: 3.5 (IF workflow is specified but execution-mode is not "workflow",
1197        // THE Skill_Loader SHALL still parse and store the workflow definition)
1198        let content = r#"---
1199name: prompt-with-workflow
1200execution-mode: prompt
1201workflow:
1202  steps:
1203    - id: step1
1204      name: 步骤
1205      prompt: "执行"
1206      output: result
1207---
1208
1209Content
1210"#;
1211        let (fm, _) = parse_frontmatter(content);
1212
1213        assert_eq!(fm.execution_mode, Some("prompt".to_string()));
1214        assert!(fm.workflow.is_some()); // workflow 仍然被解析
1215        let workflow = fm.workflow.unwrap();
1216        assert_eq!(workflow.steps.len(), 1);
1217    }
1218
1219    #[test]
1220    fn test_load_skill_workflow_with_chinese_content() {
1221        // 测试中文内容的工作流
1222        let temp_dir = TempDir::new().unwrap();
1223        let skill_dir = temp_dir.path().join("chinese-workflow");
1224        fs::create_dir(&skill_dir).unwrap();
1225
1226        let skill_file = skill_dir.join("SKILL.md");
1227        fs::write(
1228            &skill_file,
1229            r#"---
1230name: 中文工作流
1231description: 一个包含中文的工作流技能
1232execution-mode: workflow
1233provider: openai
1234workflow:
1235  steps:
1236    - id: 分析步骤
1237      name: 代码分析
1238      prompt: "请分析以下代码:${user_input}"
1239      output: 分析结果
1240    - id: 生成步骤
1241      name: 代码生成
1242      prompt: "基于分析结果生成代码:${分析结果}"
1243      output: 生成代码
1244      dependencies:
1245        - 分析步骤
1246  max_retries: 3
1247  continue_on_failure: false
1248---
1249
1250# 中文工作流技能
1251
1252这是一个支持中文的工作流技能。
1253"#,
1254        )
1255        .unwrap();
1256
1257        let skill =
1258            load_skill_from_file("user:chinese-workflow", &skill_file, SkillSource::User).unwrap();
1259
1260        assert_eq!(skill.display_name, "中文工作流");
1261        assert_eq!(skill.execution_mode, SkillExecutionMode::Workflow);
1262
1263        let workflow = skill.workflow.unwrap();
1264        assert_eq!(workflow.steps.len(), 2);
1265        assert_eq!(workflow.steps[0].id, "分析步骤");
1266        assert_eq!(workflow.steps[0].name, "代码分析");
1267        assert_eq!(workflow.steps[1].dependencies, vec!["分析步骤"]);
1268        assert_eq!(workflow.max_retries, 3);
1269        assert!(!workflow.continue_on_failure);
1270    }
1271
1272    #[test]
1273    fn test_load_skill_workflow_preserves_prompt_whitespace() {
1274        // 测试多行 prompt 的空白保留
1275        let temp_dir = TempDir::new().unwrap();
1276        let skill_dir = temp_dir.path().join("multiline-prompt");
1277        fs::create_dir(&skill_dir).unwrap();
1278
1279        let skill_file = skill_dir.join("SKILL.md");
1280        fs::write(
1281            &skill_file,
1282            r#"---
1283name: multiline-prompt-skill
1284execution-mode: workflow
1285workflow:
1286  steps:
1287    - id: step1
1288      name: 多行提示
1289      prompt: |
1290        这是第一行
1291        这是第二行
1292        变量:${user_input}
1293      output: result
1294---
1295
1296Content
1297"#,
1298        )
1299        .unwrap();
1300
1301        let skill =
1302            load_skill_from_file("user:multiline-prompt", &skill_file, SkillSource::User).unwrap();
1303
1304        let workflow = skill.workflow.unwrap();
1305        let prompt = &workflow.steps[0].prompt;
1306
1307        // 验证多行内容被保留
1308        assert!(prompt.contains("这是第一行"));
1309        assert!(prompt.contains("这是第二行"));
1310        assert!(prompt.contains("${user_input}"));
1311    }
1312
1313    #[test]
1314    fn test_parse_workflow_max_retries_zero() {
1315        // 测试 max_retries = 0 的边界情况
1316        let content = r#"---
1317name: no-retry-workflow
1318execution-mode: workflow
1319workflow:
1320  steps:
1321    - id: step1
1322      name: 步骤
1323      prompt: "执行"
1324      output: result
1325  max_retries: 0
1326---
1327
1328Content
1329"#;
1330        let (fm, _) = parse_frontmatter(content);
1331
1332        let workflow = fm.workflow.unwrap();
1333        assert_eq!(workflow.max_retries, 0);
1334    }
1335
1336    #[test]
1337    fn test_parse_workflow_single_step_with_self_reference() {
1338        // 测试单步骤工作流(无依赖)
1339        let content = r#"---
1340name: single-step-workflow
1341execution-mode: workflow
1342workflow:
1343  steps:
1344    - id: only_step
1345      name: 唯一步骤
1346      prompt: "处理输入:${user_input}"
1347      output: final_output
1348---
1349
1350Content
1351"#;
1352        let (fm, _) = parse_frontmatter(content);
1353
1354        let workflow = fm.workflow.unwrap();
1355        assert_eq!(workflow.steps.len(), 1);
1356        assert_eq!(workflow.steps[0].id, "only_step");
1357        assert!(workflow.steps[0].dependencies.is_empty());
1358    }
1359}