Skip to main content

a3s_code_core/tools/
skill.rs

1//! Skill System
2//!
3//! Provides support for loading skills in markdown format.
4//! Skills use a simple format focused on prompts/instructions
5//! with optional tool permissions.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashSet;
9use std::path::Path;
10
11/// Skill kind classification
12///
13/// Determines how the skill is injected into the agent session:
14/// - `Instruction`: Prompt/instruction content injected into system prompt
15/// - `Tool`: Registers executable tools via the skill loader
16/// - `Agent`: Agent definition (future: registered in AgentRegistry)
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum SkillKind {
20    #[default]
21    Instruction,
22    Tool,
23    Agent,
24}
25
26/// Skill definition
27///
28/// Represents a skill with:
29/// - name: skill identifier
30/// - description: what the skill does
31/// - allowed_tools: tool permissions (e.g., "Bash(gh:*)")
32/// - disable_model_invocation: whether to disable model calls
33/// - content: the prompt/instruction content
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct Skill {
36    /// Skill name (from frontmatter or filename)
37    #[serde(default)]
38    pub name: String,
39
40    /// Skill description
41    #[serde(default)]
42    pub description: String,
43
44    /// Allowed tools (Claude Code format: "Bash(pattern:*)")
45    #[serde(default, rename = "allowed-tools")]
46    pub allowed_tools: Option<String>,
47
48    /// Whether to disable model invocation
49    #[serde(default, rename = "disable-model-invocation")]
50    pub disable_model_invocation: bool,
51
52    /// Skill kind (instruction, tool, or agent)
53    #[serde(default)]
54    pub kind: SkillKind,
55
56    /// Skill content (markdown instructions)
57    #[serde(skip)]
58    pub content: String,
59}
60
61impl Skill {
62    /// Parse a skill from markdown content
63    pub fn parse(content: &str) -> Option<Self> {
64        // Parse frontmatter (YAML between --- markers)
65        let parts: Vec<&str> = content.splitn(3, "---").collect();
66
67        if parts.len() < 3 {
68            return None;
69        }
70
71        let frontmatter = parts[1].trim();
72        let body = parts[2].trim();
73
74        // Parse YAML frontmatter
75        let mut skill: Skill = serde_yaml::from_str(frontmatter).ok()?;
76        skill.content = body.to_string();
77
78        Some(skill)
79    }
80
81    /// Parse allowed tools into a set of tool patterns
82    ///
83    /// Claude Code format: "Bash(gh issue view:*), Bash(gh search:*)"
84    /// Returns patterns like: ["Bash:gh issue view:*", "Bash:gh search:*"]
85    pub fn parse_allowed_tools(&self) -> HashSet<ToolPermission> {
86        let mut permissions = HashSet::new();
87
88        let Some(allowed) = &self.allowed_tools else {
89            return permissions;
90        };
91
92        // Parse comma-separated tool permissions
93        for part in allowed.split(',') {
94            let part = part.trim();
95            if let Some(perm) = ToolPermission::parse(part) {
96                permissions.insert(perm);
97            }
98        }
99
100        permissions
101    }
102
103    /// Check if a tool call is allowed by this skill
104    pub fn is_tool_allowed(&self, tool_name: &str, args: &str) -> bool {
105        let permissions = self.parse_allowed_tools();
106
107        // If no permissions specified, all tools are allowed
108        if permissions.is_empty() {
109            return true;
110        }
111
112        // Check if any permission matches
113        permissions.iter().any(|p| p.matches(tool_name, args))
114    }
115}
116
117/// Tool permission pattern
118#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct ToolPermission {
120    /// Tool name (e.g., "Bash")
121    pub tool: String,
122    /// Pattern to match (e.g., "gh issue view:*")
123    pub pattern: String,
124}
125
126impl ToolPermission {
127    /// Parse a tool permission from Claude Code format
128    ///
129    /// Format: "ToolName(pattern)" or "ToolName(pattern:*)"
130    pub fn parse(s: &str) -> Option<Self> {
131        let s = s.trim();
132
133        // Find the opening parenthesis
134        let paren_start = s.find('(')?;
135        let paren_end = s.rfind(')')?;
136
137        if paren_start >= paren_end {
138            return None;
139        }
140
141        let tool = s[..paren_start].trim().to_string();
142        let pattern = s[paren_start + 1..paren_end].trim().to_string();
143
144        Some(Self { tool, pattern })
145    }
146
147    /// Check if this permission matches a tool call
148    pub fn matches(&self, tool_name: &str, args: &str) -> bool {
149        // Tool name must match
150        if self.tool != tool_name {
151            return false;
152        }
153
154        // Check pattern match
155        self.pattern_matches(args)
156    }
157
158    /// Check if the pattern matches the given arguments
159    fn pattern_matches(&self, args: &str) -> bool {
160        let pattern = &self.pattern;
161
162        // Handle wildcard patterns
163        if pattern == "*" {
164            return true;
165        }
166
167        // Handle prefix wildcard (e.g., "gh:*" matches "gh status")
168        if let Some(prefix) = pattern.strip_suffix(":*") {
169            return args.starts_with(prefix);
170        }
171
172        // Handle suffix wildcard (e.g., "*:view" matches "gh issue view")
173        if let Some(suffix) = pattern.strip_prefix("*:") {
174            return args.ends_with(suffix);
175        }
176
177        // Handle glob-style wildcards
178        if pattern.contains('*') {
179            return glob_match(pattern, args);
180        }
181
182        // Exact match
183        pattern == args
184    }
185}
186
187/// Simple glob matching for patterns with *
188fn glob_match(pattern: &str, text: &str) -> bool {
189    let parts: Vec<&str> = pattern.split('*').collect();
190
191    if parts.is_empty() {
192        return true;
193    }
194
195    let mut pos = 0;
196
197    // First part must match at start (if not empty)
198    if !parts[0].is_empty() {
199        if !text.starts_with(parts[0]) {
200            return false;
201        }
202        pos = parts[0].len();
203    }
204
205    // Middle parts must be found in order
206    for part in &parts[1..parts.len() - 1] {
207        if part.is_empty() {
208            continue;
209        }
210        if let Some(found) = text[pos..].find(part) {
211            pos += found + part.len();
212        } else {
213            return false;
214        }
215    }
216
217    // Last part must match at end (if not empty)
218    if let Some(last) = parts.last() {
219        if !last.is_empty() && !text[pos..].ends_with(last) {
220            return false;
221        }
222    }
223
224    true
225}
226
227/// Built-in skills compiled into the binary
228///
229/// These skills are always available without loading from disk.
230pub fn builtin_skills() -> Vec<Skill> {
231    let mut skills = Vec::new();
232
233    let find_skills_content = include_str!("../../skills/find-skills.md");
234    if let Some(skill) = Skill::parse(find_skills_content) {
235        skills.push(skill);
236    }
237
238    skills
239}
240
241/// Load skills from a directory
242///
243/// Scans for .md files and parses them as skills.
244/// Returns skills that have valid frontmatter.
245pub fn load_skills(dir: &Path) -> Vec<Skill> {
246    let mut skills = Vec::new();
247
248    let Ok(entries) = std::fs::read_dir(dir) else {
249        tracing::warn!("Failed to read skills directory: {}", dir.display());
250        return skills;
251    };
252
253    for entry in entries.flatten() {
254        let path = entry.path();
255
256        // Skip non-files
257        if !path.is_file() {
258            continue;
259        }
260
261        // Only process .md files
262        let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
263            continue;
264        };
265
266        if ext != "md" {
267            continue;
268        }
269
270        // Read and parse the skill file
271        let Ok(content) = std::fs::read_to_string(&path) else {
272            tracing::warn!("Failed to read skill file: {}", path.display());
273            continue;
274        };
275
276        if let Some(mut skill) = Skill::parse(&content) {
277            // Use filename as name if not specified
278            if skill.name.is_empty() {
279                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
280                    skill.name = stem.to_string();
281                }
282            }
283            tracing::debug!("Loaded Claude Code skill: {}", skill.name);
284            skills.push(skill);
285        }
286    }
287
288    skills
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_parse_skill() {
297        let content = r#"---
298name: test-skill
299description: A test skill
300allowed-tools: Bash(gh:*)
301---
302This is the skill content.
303"#;
304
305        let skill = Skill::parse(content).unwrap();
306        assert_eq!(skill.name, "test-skill");
307        assert_eq!(skill.description, "A test skill");
308        assert_eq!(skill.allowed_tools, Some("Bash(gh:*)".to_string()));
309        assert!(!skill.disable_model_invocation);
310        assert_eq!(skill.content, "This is the skill content.");
311    }
312
313    #[test]
314    fn test_parse_skill_with_disable_model() {
315        let content = r#"---
316name: restricted-skill
317disable-model-invocation: true
318---
319Content here.
320"#;
321
322        let skill = Skill::parse(content).unwrap();
323        assert_eq!(skill.name, "restricted-skill");
324        assert!(skill.disable_model_invocation);
325    }
326
327    #[test]
328    fn test_parse_tool_permission() {
329        let perm = ToolPermission::parse("Bash(gh issue view:*)").unwrap();
330        assert_eq!(perm.tool, "Bash");
331        assert_eq!(perm.pattern, "gh issue view:*");
332    }
333
334    #[test]
335    fn test_parse_tool_permission_simple() {
336        let perm = ToolPermission::parse("Read(*)").unwrap();
337        assert_eq!(perm.tool, "Read");
338        assert_eq!(perm.pattern, "*");
339    }
340
341    #[test]
342    fn test_tool_permission_matches_wildcard() {
343        let perm = ToolPermission::parse("Bash(*)").unwrap();
344        assert!(perm.matches("Bash", "any command"));
345        assert!(perm.matches("Bash", ""));
346        assert!(!perm.matches("Read", "file.txt"));
347    }
348
349    #[test]
350    fn test_tool_permission_matches_prefix() {
351        let perm = ToolPermission::parse("Bash(gh:*)").unwrap();
352        assert!(perm.matches("Bash", "gh status"));
353        assert!(perm.matches("Bash", "gh pr view"));
354        assert!(!perm.matches("Bash", "git status"));
355    }
356
357    #[test]
358    fn test_parse_allowed_tools() {
359        let content = r#"---
360name: multi-tool
361allowed-tools: Bash(gh issue view:*), Bash(gh pr:*), Read(*)
362---
363"#;
364
365        let skill = Skill::parse(content).unwrap();
366        let permissions = skill.parse_allowed_tools();
367
368        assert_eq!(permissions.len(), 3);
369    }
370
371    #[test]
372    fn test_is_tool_allowed() {
373        let content = r#"---
374name: github-skill
375allowed-tools: Bash(gh:*)
376---
377"#;
378
379        let skill = Skill::parse(content).unwrap();
380
381        assert!(skill.is_tool_allowed("Bash", "gh status"));
382        assert!(skill.is_tool_allowed("Bash", "gh pr view 123"));
383        assert!(!skill.is_tool_allowed("Bash", "rm -rf /"));
384        assert!(!skill.is_tool_allowed("Read", "file.txt"));
385    }
386
387    #[test]
388    fn test_is_tool_allowed_no_restrictions() {
389        let content = r#"---
390name: open-skill
391---
392"#;
393
394        let skill = Skill::parse(content).unwrap();
395
396        // No restrictions means all tools allowed
397        assert!(skill.is_tool_allowed("Bash", "any command"));
398        assert!(skill.is_tool_allowed("Read", "any file"));
399    }
400
401    #[test]
402    fn test_glob_match() {
403        assert!(glob_match("gh*", "gh status"));
404        assert!(glob_match("*view", "gh pr view"));
405        assert!(glob_match("gh*view", "gh pr view"));
406        assert!(glob_match("*", "anything"));
407        assert!(!glob_match("gh*", "git status"));
408    }
409
410    #[test]
411    fn test_load_skills() {
412        let temp_dir = tempfile::tempdir().unwrap();
413
414        // Create a Claude Code skill file
415        std::fs::write(
416            temp_dir.path().join("github.md"),
417            r#"---
418name: github-commands
419description: GitHub CLI commands
420allowed-tools: Bash(gh:*)
421---
422Use gh CLI for GitHub operations.
423"#,
424        )
425        .unwrap();
426
427        // Create another skill
428        std::fs::write(
429            temp_dir.path().join("code-review.md"),
430            r#"---
431name: code-review
432description: Code review skill
433allowed-tools: Bash(gh pr:*), Read(*)
434disable-model-invocation: false
435---
436Review pull requests.
437"#,
438        )
439        .unwrap();
440
441        let skills = load_skills(temp_dir.path());
442        assert_eq!(skills.len(), 2);
443
444        let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
445        assert!(names.contains(&"github-commands"));
446        assert!(names.contains(&"code-review"));
447    }
448
449    #[test]
450    fn test_parse_skill_minimal() {
451        let content = r#"---
452name: minimal
453---
454Content only.
455"#;
456        let skill = Skill::parse(content).unwrap();
457        assert_eq!(skill.name, "minimal");
458        assert_eq!(skill.description, "");
459        assert!(skill.allowed_tools.is_none());
460        assert!(!skill.disable_model_invocation);
461    }
462
463    #[test]
464    fn test_parse_skill_invalid_frontmatter() {
465        let content = r#"---
466invalid yaml: [
467---
468Content
469"#;
470        let skill = Skill::parse(content);
471        assert!(skill.is_none());
472    }
473
474    #[test]
475    fn test_parse_skill_no_frontmatter() {
476        let content = "Just content without frontmatter";
477        let skill = Skill::parse(content);
478        assert!(skill.is_none());
479    }
480
481    #[test]
482    fn test_parse_skill_single_separator() {
483        let content = r#"---
484name: test
485"#;
486        let skill = Skill::parse(content);
487        assert!(skill.is_none());
488    }
489
490    #[test]
491    fn test_tool_permission_parse_invalid() {
492        assert!(ToolPermission::parse("NoParenthesis").is_none());
493        assert!(ToolPermission::parse("Missing(").is_none());
494        assert!(ToolPermission::parse("Reversed)pattern(").is_none());
495        assert!(ToolPermission::parse("").is_none());
496    }
497
498    #[test]
499    fn test_tool_permission_matches_exact() {
500        let perm = ToolPermission::parse("Bash(gh status)").unwrap();
501        assert!(perm.matches("Bash", "gh status"));
502        assert!(!perm.matches("Bash", "gh pr"));
503        assert!(!perm.matches("Read", "gh status"));
504    }
505
506    #[test]
507    fn test_tool_permission_matches_suffix_wildcard() {
508        let perm = ToolPermission::parse("Bash(*:view)").unwrap();
509        assert!(perm.matches("Bash", "gh issue view"));
510        assert!(perm.matches("Bash", "gh pr view"));
511        assert!(!perm.matches("Bash", "gh issue list"));
512    }
513
514    #[test]
515    fn test_tool_permission_matches_middle_wildcard() {
516        let perm = ToolPermission::parse("Bash(gh*view)").unwrap();
517        assert!(perm.matches("Bash", "gh issue view"));
518        assert!(perm.matches("Bash", "gh pr view"));
519        assert!(!perm.matches("Bash", "gh status"));
520    }
521
522    #[test]
523    fn test_glob_match_only_wildcard() {
524        assert!(glob_match("*", "anything"));
525        assert!(glob_match("*", ""));
526    }
527
528    #[test]
529    fn test_glob_match_multiple_wildcards() {
530        assert!(glob_match("*test*file*", "my test data file here"));
531        assert!(!glob_match("*test*file*", "my data here"));
532    }
533
534    #[test]
535    fn test_glob_match_start_wildcard() {
536        assert!(glob_match("*end", "start middle end"));
537        assert!(!glob_match("*end", "start middle"));
538    }
539
540    #[test]
541    fn test_glob_match_end_wildcard() {
542        assert!(glob_match("start*", "start middle end"));
543        assert!(!glob_match("start*", "middle end"));
544    }
545
546    #[test]
547    fn test_parse_allowed_tools_empty() {
548        let content = r#"---
549name: test
550allowed-tools: ""
551---
552"#;
553        let skill = Skill::parse(content).unwrap();
554        let permissions = skill.parse_allowed_tools();
555        assert_eq!(permissions.len(), 0);
556    }
557
558    #[test]
559    fn test_parse_allowed_tools_whitespace() {
560        let content = r#"---
561name: test
562allowed-tools: "  Bash(gh:*)  ,  Read(*)  "
563---
564"#;
565        let skill = Skill::parse(content).unwrap();
566        let permissions = skill.parse_allowed_tools();
567        assert_eq!(permissions.len(), 2);
568    }
569
570    #[test]
571    fn test_is_tool_allowed_multiple_patterns() {
572        let content = r#"---
573name: test
574allowed-tools: Bash(gh:*), Bash(git:*), Read(*)
575---
576"#;
577        let skill = Skill::parse(content).unwrap();
578        assert!(skill.is_tool_allowed("Bash", "gh status"));
579        assert!(skill.is_tool_allowed("Bash", "git log"));
580        assert!(skill.is_tool_allowed("Read", "file.txt"));
581        assert!(!skill.is_tool_allowed("Write", "file.txt"));
582    }
583
584    #[test]
585    fn test_tool_permission_equality() {
586        let perm1 = ToolPermission {
587            tool: "Bash".to_string(),
588            pattern: "gh:*".to_string(),
589        };
590        let perm2 = ToolPermission {
591            tool: "Bash".to_string(),
592            pattern: "gh:*".to_string(),
593        };
594        let perm3 = ToolPermission {
595            tool: "Read".to_string(),
596            pattern: "*".to_string(),
597        };
598        assert_eq!(perm1, perm2);
599        assert_ne!(perm1, perm3);
600    }
601
602    #[test]
603    fn test_tool_permission_clone() {
604        let perm = ToolPermission {
605            tool: "Bash".to_string(),
606            pattern: "test:*".to_string(),
607        };
608        let cloned = perm.clone();
609        assert_eq!(perm, cloned);
610    }
611
612    #[test]
613    fn test_tool_permission_debug() {
614        let perm = ToolPermission {
615            tool: "Bash".to_string(),
616            pattern: "gh:*".to_string(),
617        };
618        let debug_str = format!("{:?}", perm);
619        assert!(debug_str.contains("Bash"));
620        assert!(debug_str.contains("gh:*"));
621    }
622
623    #[test]
624    fn test_skill_clone() {
625        let skill = Skill {
626            name: "test".to_string(),
627            description: "desc".to_string(),
628            allowed_tools: Some("Bash(*)".to_string()),
629            disable_model_invocation: true,
630            kind: SkillKind::Instruction,
631            content: "content".to_string(),
632        };
633        let cloned = skill.clone();
634        assert_eq!(skill.name, cloned.name);
635        assert_eq!(skill.description, cloned.description);
636        assert_eq!(
637            skill.disable_model_invocation,
638            cloned.disable_model_invocation
639        );
640    }
641
642    #[test]
643    fn test_skill_debug() {
644        let skill = Skill {
645            name: "test".to_string(),
646            description: "desc".to_string(),
647            allowed_tools: None,
648            disable_model_invocation: false,
649            kind: SkillKind::Instruction,
650            content: "content".to_string(),
651        };
652        let debug_str = format!("{:?}", skill);
653        assert!(debug_str.contains("test"));
654    }
655
656    #[test]
657    fn test_load_skills_nonexistent_dir() {
658        let skills = load_skills(std::path::Path::new("/nonexistent/path"));
659        assert_eq!(skills.len(), 0);
660    }
661
662    #[test]
663    fn test_load_skills_skip_non_md() {
664        let temp_dir = tempfile::tempdir().unwrap();
665        std::fs::write(
666            temp_dir.path().join("skill.txt"),
667            r#"---
668name: test
669---
670"#,
671        )
672        .unwrap();
673        let skills = load_skills(temp_dir.path());
674        assert_eq!(skills.len(), 0);
675    }
676
677    #[test]
678    fn test_load_skills_use_filename() {
679        let temp_dir = tempfile::tempdir().unwrap();
680        std::fs::write(
681            temp_dir.path().join("my-skill.md"),
682            r#"---
683description: Test skill
684---
685Content
686"#,
687        )
688        .unwrap();
689        let skills = load_skills(temp_dir.path());
690        assert_eq!(skills.len(), 1);
691        assert_eq!(skills[0].name, "my-skill");
692    }
693
694    #[test]
695    fn test_load_skills_skip_subdirs() {
696        let temp_dir = tempfile::tempdir().unwrap();
697        let subdir = temp_dir.path().join("subdir");
698        std::fs::create_dir(&subdir).unwrap();
699        std::fs::write(
700            subdir.join("skill.md"),
701            r#"---
702name: test
703---
704"#,
705        )
706        .unwrap();
707        let skills = load_skills(temp_dir.path());
708        assert_eq!(skills.len(), 0);
709    }
710
711    #[test]
712    fn test_parse_allowed_tools_invalid_format() {
713        let content = r#"---
714name: test
715allowed-tools: InvalidFormat, AlsoInvalid
716---
717"#;
718        let skill = Skill::parse(content).unwrap();
719        let permissions = skill.parse_allowed_tools();
720        assert_eq!(permissions.len(), 0);
721    }
722
723    // ===================
724    // Built-in Skills Tests
725    // ===================
726
727    #[test]
728    fn test_builtin_skills() {
729        let skills = builtin_skills();
730        assert!(
731            !skills.is_empty(),
732            "Should have at least one built-in Claude Code skill"
733        );
734
735        // Verify find-skills is present
736        let find_skills = skills.iter().find(|s| s.name == "find-skills");
737        assert!(find_skills.is_some(), "find-skills skill should be present");
738
739        let skill = find_skills.unwrap();
740        assert!(
741            !skill.description.is_empty(),
742            "find-skills should have a description"
743        );
744        assert!(!skill.content.is_empty(), "find-skills should have content");
745    }
746
747    #[test]
748    fn test_builtin_find_skills_content() {
749        let skills = builtin_skills();
750        let skill = skills.iter().find(|s| s.name == "find-skills").unwrap();
751
752        // Verify key content sections exist
753        assert!(
754            skill.content.contains("search_skills"),
755            "Should reference search_skills tool"
756        );
757        assert!(
758            skill.content.contains("install_skill"),
759            "Should reference install_skill tool"
760        );
761        assert!(
762            skill.content.contains("skills.sh"),
763            "Should reference skills.sh"
764        );
765    }
766
767    // ===================
768    // SkillKind Tests
769    // ===================
770
771    #[test]
772    fn test_skill_kind_default_is_instruction() {
773        let kind = SkillKind::default();
774        assert_eq!(kind, SkillKind::Instruction);
775    }
776
777    #[test]
778    fn test_parse_skill_kind_instruction() {
779        let content = r#"---
780name: guide
781kind: instruction
782---
783Some instructions.
784"#;
785        let skill = Skill::parse(content).unwrap();
786        assert_eq!(skill.kind, SkillKind::Instruction);
787    }
788
789    #[test]
790    fn test_parse_skill_kind_tool() {
791        let content = r#"---
792name: my-tool
793kind: tool
794---
795Tool content.
796"#;
797        let skill = Skill::parse(content).unwrap();
798        assert_eq!(skill.kind, SkillKind::Tool);
799    }
800
801    #[test]
802    fn test_parse_skill_kind_agent() {
803        let content = r#"---
804name: my-agent
805kind: agent
806---
807Agent content.
808"#;
809        let skill = Skill::parse(content).unwrap();
810        assert_eq!(skill.kind, SkillKind::Agent);
811    }
812
813    #[test]
814    fn test_parse_skill_kind_missing_defaults_to_instruction() {
815        let content = r#"---
816name: old-skill
817description: No kind field
818---
819Content here.
820"#;
821        let skill = Skill::parse(content).unwrap();
822        assert_eq!(skill.kind, SkillKind::Instruction);
823    }
824
825    #[test]
826    fn test_skill_kind_serialize() {
827        assert_eq!(
828            serde_json::to_string(&SkillKind::Instruction).unwrap(),
829            "\"instruction\""
830        );
831        assert_eq!(serde_json::to_string(&SkillKind::Tool).unwrap(), "\"tool\"");
832        assert_eq!(
833            serde_json::to_string(&SkillKind::Agent).unwrap(),
834            "\"agent\""
835        );
836    }
837
838    #[test]
839    fn test_skill_kind_clone_copy() {
840        let kind = SkillKind::Tool;
841        let cloned = kind.clone();
842        let copied = kind;
843        assert_eq!(kind, cloned);
844        assert_eq!(kind, copied);
845    }
846}