1use super::types::{SkillDefinition, SkillExecutionMode, SkillFrontmatter, SkillSource};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub fn parse_frontmatter(content: &str) -> (SkillFrontmatter, String) {
25 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 if let Ok(frontmatter) = serde_yaml::from_str::<SkillFrontmatter>(frontmatter_text) {
35 return (frontmatter, body);
36 }
37
38 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 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 "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
82pub 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
101pub 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
111pub 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 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
137pub 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 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
197pub 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 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 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
254pub 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
276pub 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 assert_eq!(skill.execution_mode, SkillExecutionMode::Prompt);
815 assert!(skill.provider.is_none());
816 assert!(skill.workflow.is_none());
817 }
818
819 #[test]
824 fn test_parse_workflow_with_multiple_steps() {
825 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 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 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 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 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 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); }
971
972 #[test]
973 fn test_parse_workflow_with_continue_on_failure_true() {
974 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 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); }
1018
1019 #[test]
1020 fn test_parse_workflow_with_all_settings() {
1021 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 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 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 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()); assert!(step.dependencies.is_empty()); assert!(!step.parallel); }
1126
1127 #[test]
1128 fn test_parse_workflow_step_with_parallel_flag() {
1129 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 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 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 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()); 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 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 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 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 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 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}