1use anyhow::{Context, Result};
8use std::path::PathBuf;
9use zeroclaw_config::schema::SkillCreationConfig;
10use zeroclaw_memory::embeddings::EmbeddingProvider;
11use zeroclaw_memory::vector::cosine_similarity;
12
13#[derive(Debug, Clone)]
15pub struct ToolCallRecord {
16 pub name: String,
17 pub args: serde_json::Value,
18}
19
20pub struct SkillCreator {
22 workspace_dir: PathBuf,
23 config: SkillCreationConfig,
24}
25
26impl SkillCreator {
27 pub fn new(workspace_dir: PathBuf, config: SkillCreationConfig) -> Self {
28 Self {
29 workspace_dir,
30 config,
31 }
32 }
33
34 pub async fn create_from_execution(
38 &self,
39 task_description: &str,
40 tool_calls: &[ToolCallRecord],
41 embedding_provider: Option<&dyn EmbeddingProvider>,
42 ) -> Result<Option<String>> {
43 if !self.config.enabled {
44 return Ok(None);
45 }
46
47 if tool_calls.len() < 2 {
48 return Ok(None);
49 }
50
51 if let Some(model_provider) = embedding_provider
53 && model_provider.name() != "none"
54 && self.is_duplicate(task_description, model_provider).await?
55 {
56 return Ok(None);
57 }
58
59 let slug = Self::generate_slug(task_description);
60 if !Self::validate_slug(&slug) {
61 return Ok(None);
62 }
63
64 self.enforce_lru_limit().await?;
66
67 let skill_dir = self.skills_dir().join(&slug);
68 tokio::fs::create_dir_all(&skill_dir)
69 .await
70 .with_context(|| {
71 format!(
72 "Failed to create skill directory: {}",
73 skill_dir.display().to_string()
74 )
75 })?;
76
77 let toml_content = Self::generate_skill_toml(&slug, task_description, tool_calls);
78 let toml_path = skill_dir.join("SKILL.toml");
79 tokio::fs::write(&toml_path, toml_content.as_bytes())
80 .await
81 .with_context(|| format!("Failed to write {}", toml_path.display().to_string()))?;
82
83 Ok(Some(slug))
84 }
85
86 fn generate_slug(description: &str) -> String {
89 let slug: String = description
90 .to_lowercase()
91 .chars()
92 .map(|c| if c.is_alphanumeric() { c } else { '-' })
93 .collect();
94
95 let mut collapsed = String::with_capacity(slug.len());
97 let mut prev_hyphen = false;
98 for c in slug.chars() {
99 if c == '-' {
100 if !prev_hyphen {
101 collapsed.push('-');
102 }
103 prev_hyphen = true;
104 } else {
105 collapsed.push(c);
106 prev_hyphen = false;
107 }
108 }
109
110 let trimmed = collapsed.trim_matches('-');
112 if trimmed.len() > 64 {
113 let safe_index = trimmed
115 .char_indices()
116 .map(|(i, _)| i)
117 .take_while(|&i| i <= 64)
118 .last()
119 .unwrap_or(0);
120 let truncated = &trimmed[..safe_index];
121 truncated.trim_end_matches('-').to_string()
122 } else {
123 trimmed.to_string()
124 }
125 }
126
127 fn validate_slug(slug: &str) -> bool {
129 !slug.is_empty()
130 && slug.len() <= 64
131 && slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
132 && !slug.starts_with('-')
133 && !slug.ends_with('-')
134 }
135
136 fn generate_skill_toml(slug: &str, description: &str, tool_calls: &[ToolCallRecord]) -> String {
138 use std::fmt::Write;
139 let mut toml = String::new();
140 toml.push_str("[skill]\n");
141 let _ = writeln!(toml, "name = {}", toml_escape(slug));
142 let _ = writeln!(
143 toml,
144 "description = {}",
145 toml_escape(&format!("Auto-generated: {description}"))
146 );
147 toml.push_str("version = \"0.1.0\"\n");
148 toml.push_str("author = \"zeroclaw-auto\"\n");
149 toml.push_str("tags = [\"auto-generated\"]\n");
150
151 for call in tool_calls {
152 toml.push('\n');
153 toml.push_str("[[tools]]\n");
154 let _ = writeln!(toml, "name = {}", toml_escape(&call.name));
155 let _ = writeln!(
156 toml,
157 "description = {}",
158 toml_escape(&format!("Tool used in task: {}", call.name))
159 );
160 toml.push_str("kind = \"shell\"\n");
161
162 let command = call
164 .args
165 .get("command")
166 .and_then(serde_json::Value::as_str)
167 .unwrap_or(&call.name);
168 let _ = writeln!(toml, "command = {}", toml_escape(command));
169 }
170
171 toml
172 }
173
174 async fn is_duplicate(
176 &self,
177 description: &str,
178 embedding_provider: &dyn EmbeddingProvider,
179 ) -> Result<bool> {
180 let new_embedding = embedding_provider.embed_one(description).await?;
181 if new_embedding.is_empty() {
182 return Ok(false);
183 }
184
185 let skills_dir = self.skills_dir();
186 if !skills_dir.exists() {
187 return Ok(false);
188 }
189
190 let mut entries = tokio::fs::read_dir(&skills_dir).await?;
191 while let Some(entry) = entries.next_entry().await? {
192 let toml_path = entry.path().join("SKILL.toml");
193 if !toml_path.exists() {
194 continue;
195 }
196
197 let content = tokio::fs::read_to_string(&toml_path).await?;
198 if let Some(desc) = extract_description_from_toml(&content) {
200 let existing_embedding = embedding_provider.embed_one(&desc).await?;
201 if !existing_embedding.is_empty() {
202 #[allow(clippy::cast_possible_truncation)]
203 let similarity =
204 f64::from(cosine_similarity(&new_embedding, &existing_embedding));
205 if similarity > self.config.similarity_threshold {
206 return Ok(true);
207 }
208 }
209 }
210 }
211
212 Ok(false)
213 }
214
215 async fn enforce_lru_limit(&self) -> Result<()> {
217 let skills_dir = self.skills_dir();
218 if !skills_dir.exists() {
219 return Ok(());
220 }
221
222 let mut auto_skills: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
223
224 let mut entries = tokio::fs::read_dir(&skills_dir).await?;
225 while let Some(entry) = entries.next_entry().await? {
226 let toml_path = entry.path().join("SKILL.toml");
227 if !toml_path.exists() {
228 continue;
229 }
230
231 let content = tokio::fs::read_to_string(&toml_path).await?;
232 if content.contains("\"zeroclaw-auto\"") || content.contains("\"auto-generated\"") {
233 let modified = tokio::fs::metadata(&toml_path)
234 .await?
235 .modified()
236 .unwrap_or(std::time::UNIX_EPOCH);
237 auto_skills.push((entry.path(), modified));
238 }
239 }
240
241 if auto_skills.len() >= self.config.max_skills {
243 auto_skills.sort_by_key(|(_, modified)| *modified);
244 if let Some((oldest_dir, _)) = auto_skills.first() {
245 tokio::fs::remove_dir_all(oldest_dir)
246 .await
247 .with_context(|| {
248 format!(
249 "Failed to remove oldest auto-generated skill: {}",
250 oldest_dir.display()
251 )
252 })?;
253 }
254 }
255
256 Ok(())
257 }
258
259 fn skills_dir(&self) -> PathBuf {
260 self.workspace_dir.join("skills")
261 }
262}
263
264fn toml_escape(s: &str) -> String {
266 let escaped = s
267 .replace('\\', "\\\\")
268 .replace('"', "\\\"")
269 .replace('\n', "\\n")
270 .replace('\r', "\\r")
271 .replace('\t', "\\t");
272 format!("\"{escaped}\"")
273}
274
275fn extract_description_from_toml(content: &str) -> Option<String> {
277 #[derive(serde::Deserialize)]
278 struct Partial {
279 skill: PartialSkill,
280 }
281 #[derive(serde::Deserialize)]
282 struct PartialSkill {
283 description: Option<String>,
284 }
285 toml::from_str::<Partial>(content)
286 .ok()
287 .and_then(|p| p.skill.description)
288}
289
290pub fn extract_tool_calls_from_history(
295 history: &[zeroclaw_providers::ChatMessage],
296) -> Vec<ToolCallRecord> {
297 let mut records = Vec::new();
298
299 for msg in history {
300 if msg.role != "assistant" {
301 continue;
302 }
303
304 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg.content)
306 && let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array())
307 {
308 for call in tool_calls {
309 if let Some(function) = call.get("function") {
310 let name = function
311 .get("name")
312 .and_then(serde_json::Value::as_str)
313 .unwrap_or("")
314 .to_string();
315 let args_str = function
316 .get("arguments")
317 .and_then(serde_json::Value::as_str)
318 .unwrap_or("{}");
319 let args = serde_json::from_str(args_str).unwrap_or_default();
320 if !name.is_empty() {
321 records.push(ToolCallRecord { name, args });
322 }
323 }
324 }
325 }
326
327 let content = &msg.content;
330 let mut pos = 0;
331 while pos < content.len() {
332 if let Some(start) = content[pos..].find('<') {
333 let abs_start = pos + start;
334 if let Some(end) = content[abs_start..].find('>') {
335 let tag = &content[abs_start + 1..abs_start + end];
336 if tag.starts_with('/') || tag.starts_with('!') || tag.starts_with('?') {
338 pos = abs_start + end + 1;
339 continue;
340 }
341 let tag_name = tag.split_whitespace().next().unwrap_or(tag);
342 let close_tag = format!("</{tag_name}>");
343 if let Some(close_pos) = content[abs_start + end + 1..].find(&close_tag) {
344 let inner = &content[abs_start + end + 1..abs_start + end + 1 + close_pos];
345 let args: serde_json::Value =
346 serde_json::from_str(inner.trim()).unwrap_or_default();
347 if tag_name != "tool_result"
349 && tag_name != "tool_results"
350 && !tag_name.contains(':')
351 && args.is_object()
352 && !args.as_object().is_none_or(|o| o.is_empty())
353 {
354 records.push(ToolCallRecord {
355 name: tag_name.to_string(),
356 args,
357 });
358 }
359 pos = abs_start + end + 1 + close_pos + close_tag.len();
360 } else {
361 pos = abs_start + end + 1;
362 }
363 } else {
364 break;
365 }
366 } else {
367 break;
368 }
369 }
370 }
371
372 records
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use async_trait::async_trait;
379 use zeroclaw_memory::embeddings::{EmbeddingProvider, NoopEmbedding};
380
381 #[test]
384 fn slug_basic() {
385 assert_eq!(
386 SkillCreator::generate_slug("Deploy to production"),
387 "deploy-to-production"
388 );
389 }
390
391 #[test]
392 fn slug_special_characters() {
393 assert_eq!(
394 SkillCreator::generate_slug("Build & test (CI/CD) pipeline!"),
395 "build-test-ci-cd-pipeline"
396 );
397 }
398
399 #[test]
400 fn slug_max_length() {
401 let long_desc = "a".repeat(100);
402 let slug = SkillCreator::generate_slug(&long_desc);
403 assert!(slug.len() <= 64);
404 }
405
406 #[test]
407 fn slug_leading_trailing_hyphens() {
408 let slug = SkillCreator::generate_slug("---hello world---");
409 assert!(!slug.starts_with('-'));
410 assert!(!slug.ends_with('-'));
411 }
412
413 #[test]
414 fn slug_consecutive_spaces() {
415 assert_eq!(SkillCreator::generate_slug("hello world"), "hello-world");
416 }
417
418 #[test]
419 fn slug_empty_input() {
420 let slug = SkillCreator::generate_slug("");
421 assert!(slug.is_empty());
422 }
423
424 #[test]
425 fn slug_only_symbols() {
426 let slug = SkillCreator::generate_slug("!@#$%^&*()");
427 assert!(slug.is_empty());
428 }
429
430 #[test]
431 fn slug_unicode() {
432 let slug = SkillCreator::generate_slug("Deploy cafe app");
433 assert_eq!(slug, "deploy-cafe-app");
434 }
435
436 #[test]
439 fn validate_slug_valid() {
440 assert!(SkillCreator::validate_slug("deploy-to-production"));
441 assert!(SkillCreator::validate_slug("a"));
442 assert!(SkillCreator::validate_slug("abc123"));
443 }
444
445 #[test]
446 fn validate_slug_invalid() {
447 assert!(!SkillCreator::validate_slug(""));
448 assert!(!SkillCreator::validate_slug("-starts-with-hyphen"));
449 assert!(!SkillCreator::validate_slug("ends-with-hyphen-"));
450 assert!(!SkillCreator::validate_slug("has spaces"));
451 assert!(!SkillCreator::validate_slug("has_underscores"));
452 assert!(!SkillCreator::validate_slug(&"a".repeat(65)));
453 }
454
455 #[test]
458 fn toml_generation_valid_format() {
459 let calls = vec![
460 ToolCallRecord {
461 name: "shell".into(),
462 args: serde_json::json!({"command": "cargo build"}),
463 },
464 ToolCallRecord {
465 name: "shell".into(),
466 args: serde_json::json!({"command": "cargo test"}),
467 },
468 ];
469 let toml_str = SkillCreator::generate_skill_toml(
470 "build-and-test",
471 "Build and test the project",
472 &calls,
473 );
474
475 let parsed: toml::Value =
477 toml::from_str(&toml_str).expect("Generated TOML should be valid");
478 let skill = parsed.get("skill").expect("Should have [skill] section");
479 assert_eq!(
480 skill.get("name").and_then(toml::Value::as_str),
481 Some("build-and-test")
482 );
483 assert_eq!(
484 skill.get("author").and_then(toml::Value::as_str),
485 Some("zeroclaw-auto")
486 );
487 assert_eq!(
488 skill.get("version").and_then(toml::Value::as_str),
489 Some("0.1.0")
490 );
491
492 let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
493 assert_eq!(tools.len(), 2);
494 assert_eq!(
495 tools[0].get("command").and_then(toml::Value::as_str),
496 Some("cargo build")
497 );
498 }
499
500 #[test]
501 fn toml_generation_escapes_quotes() {
502 let calls = vec![ToolCallRecord {
503 name: "shell".into(),
504 args: serde_json::json!({"command": "echo \"hello\""}),
505 }];
506 let toml_str =
507 SkillCreator::generate_skill_toml("echo-test", "Test \"quoted\" description", &calls);
508 let parsed: toml::Value =
509 toml::from_str(&toml_str).expect("TOML with quotes should be valid");
510 let desc = parsed
511 .get("skill")
512 .and_then(|s| s.get("description"))
513 .and_then(toml::Value::as_str)
514 .unwrap();
515 assert!(desc.contains("quoted"));
516 }
517
518 #[test]
519 fn toml_generation_no_command_arg() {
520 let calls = vec![ToolCallRecord {
521 name: "memory_store".into(),
522 args: serde_json::json!({"key": "foo", "value": "bar"}),
523 }];
524 let toml_str = SkillCreator::generate_skill_toml("memory-op", "Store to memory", &calls);
525 let parsed: toml::Value = toml::from_str(&toml_str).expect("TOML should be valid");
526 let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
527 assert_eq!(
529 tools[0].get("command").and_then(toml::Value::as_str),
530 Some("memory_store")
531 );
532 }
533
534 #[test]
537 fn extract_description_from_valid_toml() {
538 let content = r#"
539[skill]
540name = "test"
541description = "Auto-generated: Build project"
542version = "0.1.0"
543"#;
544 assert_eq!(
545 extract_description_from_toml(content),
546 Some("Auto-generated: Build project".into())
547 );
548 }
549
550 #[test]
551 fn extract_description_from_invalid_toml() {
552 assert_eq!(extract_description_from_toml("not valid toml {{"), None);
553 }
554
555 struct MockEmbeddingProvider {
563 similarity: f32,
564 call_count: std::sync::atomic::AtomicUsize,
565 }
566
567 impl MockEmbeddingProvider {
568 fn new(similarity: f32) -> Self {
569 Self {
570 similarity,
571 call_count: std::sync::atomic::AtomicUsize::new(0),
572 }
573 }
574 }
575
576 #[async_trait]
577 impl EmbeddingProvider for MockEmbeddingProvider {
578 fn name(&self) -> &str {
579 "mock"
580 }
581 fn dimensions(&self) -> usize {
582 3
583 }
584 async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
585 Ok(texts
586 .iter()
587 .map(|_| {
588 let call = self
589 .call_count
590 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
591 if call == 0 {
592 vec![1.0, 0.0, 0.0]
594 } else {
595 vec![
598 self.similarity,
599 (1.0 - self.similarity * self.similarity).sqrt(),
600 0.0,
601 ]
602 }
603 })
604 .collect())
605 }
606 }
607
608 #[tokio::test]
609 async fn dedup_skips_similar_descriptions() {
610 let dir = tempfile::tempdir().unwrap();
611 let skills_dir = dir.path().join("skills").join("existing-skill");
612 tokio::fs::create_dir_all(&skills_dir).await.unwrap();
613 tokio::fs::write(
614 skills_dir.join("SKILL.toml"),
615 r#"
616[skill]
617name = "existing-skill"
618description = "Auto-generated: Build the project"
619version = "0.1.0"
620author = "zeroclaw-auto"
621tags = ["auto-generated"]
622"#,
623 )
624 .await
625 .unwrap();
626
627 let config = SkillCreationConfig {
628 enabled: true,
629 max_skills: 500,
630 similarity_threshold: 0.85,
631 };
632
633 let model_provider = MockEmbeddingProvider::new(0.95);
635 let creator = SkillCreator::new(dir.path().to_path_buf(), config.clone());
636 assert!(
637 creator
638 .is_duplicate("Build the project", &model_provider)
639 .await
640 .unwrap()
641 );
642
643 let provider_low = MockEmbeddingProvider::new(0.3);
645 let creator2 = SkillCreator::new(dir.path().to_path_buf(), config);
646 assert!(
647 !creator2
648 .is_duplicate("Completely different task", &provider_low)
649 .await
650 .unwrap()
651 );
652 }
653
654 #[tokio::test]
657 async fn lru_eviction_removes_oldest() {
658 let dir = tempfile::tempdir().unwrap();
659 let config = SkillCreationConfig {
660 enabled: true,
661 max_skills: 2,
662 similarity_threshold: 0.85,
663 };
664
665 let skills_dir = dir.path().join("skills");
666
667 for (i, name) in ["old-skill", "new-skill"].iter().enumerate() {
669 let skill_dir = skills_dir.join(name);
670 tokio::fs::create_dir_all(&skill_dir).await.unwrap();
671 tokio::fs::write(
672 skill_dir.join("SKILL.toml"),
673 format!(
674 r#"[skill]
675name = "{name}"
676description = "Auto-generated: Skill {i}"
677version = "0.1.0"
678author = "zeroclaw-auto"
679tags = ["auto-generated"]
680"#
681 ),
682 )
683 .await
684 .unwrap();
685 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
687 }
688
689 let creator = SkillCreator::new(dir.path().to_path_buf(), config);
690 creator.enforce_lru_limit().await.unwrap();
691
692 assert!(!skills_dir.join("old-skill").exists());
694 assert!(skills_dir.join("new-skill").exists());
695 }
696
697 #[tokio::test]
700 async fn create_from_execution_disabled() {
701 let dir = tempfile::tempdir().unwrap();
702 let config = SkillCreationConfig {
703 enabled: false,
704 ..Default::default()
705 };
706 let creator = SkillCreator::new(dir.path().to_path_buf(), config);
707 let calls = vec![
708 ToolCallRecord {
709 name: "shell".into(),
710 args: serde_json::json!({"command": "ls"}),
711 },
712 ToolCallRecord {
713 name: "shell".into(),
714 args: serde_json::json!({"command": "pwd"}),
715 },
716 ];
717 let result = creator
718 .create_from_execution("List files", &calls, None)
719 .await
720 .unwrap();
721 assert!(result.is_none());
722 }
723
724 #[tokio::test]
725 async fn create_from_execution_insufficient_steps() {
726 let dir = tempfile::tempdir().unwrap();
727 let config = SkillCreationConfig {
728 enabled: true,
729 ..Default::default()
730 };
731 let creator = SkillCreator::new(dir.path().to_path_buf(), config);
732 let calls = vec![ToolCallRecord {
733 name: "shell".into(),
734 args: serde_json::json!({"command": "ls"}),
735 }];
736 let result = creator
737 .create_from_execution("List files", &calls, None)
738 .await
739 .unwrap();
740 assert!(result.is_none());
741 }
742
743 #[tokio::test]
744 async fn create_from_execution_success() {
745 let dir = tempfile::tempdir().unwrap();
746 let config = SkillCreationConfig {
747 enabled: true,
748 max_skills: 500,
749 similarity_threshold: 0.85,
750 };
751 let creator = SkillCreator::new(dir.path().to_path_buf(), config);
752 let calls = vec![
753 ToolCallRecord {
754 name: "shell".into(),
755 args: serde_json::json!({"command": "cargo build"}),
756 },
757 ToolCallRecord {
758 name: "shell".into(),
759 args: serde_json::json!({"command": "cargo test"}),
760 },
761 ];
762
763 let noop = NoopEmbedding;
765 let result = creator
766 .create_from_execution("Build and test", &calls, Some(&noop))
767 .await
768 .unwrap();
769 assert_eq!(result, Some("build-and-test".into()));
770
771 let skill_dir = dir.path().join("skills").join("build-and-test");
773 assert!(skill_dir.exists());
774 let toml_content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
775 .await
776 .unwrap();
777 assert!(toml_content.contains("build-and-test"));
778 assert!(toml_content.contains("zeroclaw-auto"));
779 }
780
781 #[tokio::test]
782 async fn create_from_execution_with_dedup() {
783 let dir = tempfile::tempdir().unwrap();
784 let config = SkillCreationConfig {
785 enabled: true,
786 max_skills: 500,
787 similarity_threshold: 0.85,
788 };
789
790 let skills_dir = dir.path().join("skills").join("existing");
792 tokio::fs::create_dir_all(&skills_dir).await.unwrap();
793 tokio::fs::write(
794 skills_dir.join("SKILL.toml"),
795 r#"[skill]
796name = "existing"
797description = "Auto-generated: Build and test"
798version = "0.1.0"
799author = "zeroclaw-auto"
800tags = ["auto-generated"]
801"#,
802 )
803 .await
804 .unwrap();
805
806 let model_provider = MockEmbeddingProvider::new(0.95);
808 let creator = SkillCreator::new(dir.path().to_path_buf(), config);
809 let calls = vec![
810 ToolCallRecord {
811 name: "shell".into(),
812 args: serde_json::json!({"command": "cargo build"}),
813 },
814 ToolCallRecord {
815 name: "shell".into(),
816 args: serde_json::json!({"command": "cargo test"}),
817 },
818 ];
819 let result = creator
820 .create_from_execution("Build and test", &calls, Some(&model_provider))
821 .await
822 .unwrap();
823 assert!(result.is_none());
824 }
825
826 #[test]
829 fn extract_from_empty_history() {
830 let history = vec![];
831 let records = extract_tool_calls_from_history(&history);
832 assert!(records.is_empty());
833 }
834
835 #[test]
836 fn extract_from_user_messages_only() {
837 use zeroclaw_providers::ChatMessage;
838 let history = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
839 let records = extract_tool_calls_from_history(&history);
840 assert!(records.is_empty());
841 }
842
843 #[test]
846 fn slug_fuzz_various_inputs() {
847 let inputs = [
848 "",
849 " ",
850 "---",
851 "a",
852 "hello world!",
853 "UPPER CASE",
854 "with-hyphens-already",
855 "with__underscores",
856 "123 numbers 456",
857 "emoji: cafe",
858 &"x".repeat(200),
859 "a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-0-1-2-3-4-5",
860 ];
861
862 for input in &inputs {
863 let slug = SkillCreator::generate_slug(input);
864 if !slug.is_empty() {
866 assert!(
867 SkillCreator::validate_slug(&slug),
868 "Generated slug '{slug}' from '{input}' failed validation"
869 );
870 }
871 }
872 }
873
874 #[test]
877 fn toml_fuzz_various_inputs() {
878 let descriptions = [
879 "simple task",
880 "task with \"quotes\" and \\ backslashes",
881 "task with\nnewlines\r\nand tabs\there",
882 "",
883 &"long ".repeat(100),
884 ];
885
886 let args_variants = [
887 serde_json::json!({}),
888 serde_json::json!({"command": "echo hello"}),
889 serde_json::json!({"command": "echo \"hello world\"", "extra": 42}),
890 ];
891
892 for desc in &descriptions {
893 for args in &args_variants {
894 let calls = vec![
895 ToolCallRecord {
896 name: "tool1".into(),
897 args: args.clone(),
898 },
899 ToolCallRecord {
900 name: "tool2".into(),
901 args: args.clone(),
902 },
903 ];
904 let toml_str = SkillCreator::generate_skill_toml("test-slug", desc, &calls);
905 let _parsed: toml::Value = toml::from_str(&toml_str)
907 .unwrap_or_else(|e| panic!("Invalid TOML for desc '{desc}': {e}\n{toml_str}"));
908 }
909 }
910 }
911}