Skip to main content

matrixcode_core/
skills.rs

1//! Skill discovery and loading.
2//!
3//! A "skill" is a markdown file with a YAML frontmatter block, or a directory
4//! containing skill files. Two formats are supported:
5//!
6//! **Format 1: Single-file skill (SKILL.md)**
7//! ```text
8//! skills/
9//!   my-skill/
10//!     SKILL.md            # required, with frontmatter
11//!     helper.py           # optional support files
12//!     templates/...       # optional subdirs
13//! ```
14//!
15//! **Format 2: Multi-file skills (like Claude Code plugins)**
16//! ```text
17//! skills/
18//!   om/
19//!     debug.md            # each .md file is a separate skill
20//!     feature.md
21//!     plan.md
22//!     ...
23//! ```
24//!
25//! Skills are surfaced to the model in two stages:
26//! 1. At startup every skill's name + description is injected into the
27//!    system prompt so the model knows what's available.
28//! 2. The `skill` tool loads the full skill body (and lists the
29//!    skill's files) when the model decides to use one. This keeps
30//!    baseline token cost low even with many skills installed.
31//!
32//! ## Skill Priority and Type
33//!
34//! Skills have priority levels that determine invocation order:
35//! - **Process** (priority 1): brainstorming, debugging, planning
36//! - **Implementation** (priority 2): frontend-design, mcp-builder, code-review
37//!
38//! Skills also have types that determine flexibility:
39//! - **Rigid**: Must be followed exactly (TDD, debugging workflows)
40//! - **Flexible**: Can be adapted to context (patterns, style guides)
41
42use std::path::{Path, PathBuf};
43
44use anyhow::{Context, Result};
45
46/// Skill type determines how strictly the skill should be followed.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum SkillType {
49    /// Rigid skill - must be followed exactly (TDD, debugging)
50    Rigid,
51    /// Flexible skill - can be adapted to context (patterns)
52    #[default]
53    Flexible,
54}
55
56impl SkillType {
57    /// Parse from string
58    pub fn from_str(s: &str) -> Self {
59        match s.trim().to_lowercase().as_str() {
60            "rigid" => Self::Rigid,
61            "flexible" => Self::Flexible,
62            _ => Self::default(),
63        }
64    }
65
66    /// Convert to string
67    pub fn as_str(&self) -> &'static str {
68        match self {
69            Self::Rigid => "rigid",
70            Self::Flexible => "flexible",
71        }
72    }
73}
74
75/// Skill priority determines invocation order.
76/// Lower number = higher priority (processed first).
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
78pub enum SkillPriority {
79    /// Process skills (brainstorming, debugging, planning) - highest priority
80    Process = 1,
81    /// Implementation skills (frontend, mcp-builder) - second priority
82    #[default]
83    Implementation = 2,
84}
85
86impl SkillPriority {
87    /// Parse from string
88    pub fn from_str(s: &str) -> Self {
89        match s.trim().to_lowercase().as_str() {
90            "process" => Self::Process,
91            "implementation" => Self::Implementation,
92            _ => Self::default(),
93        }
94    }
95
96    /// Convert to string
97    pub fn as_str(&self) -> &'static str {
98        match self {
99            Self::Process => "process",
100            Self::Implementation => "implementation",
101        }
102    }
103
104    /// Get display label for prompt
105    pub fn display_label(&self) -> &'static str {
106        match self {
107            Self::Process => "Process",
108            Self::Implementation => "Implementation",
109        }
110    }
111}
112
113/// A loaded skill ready to be advertised to the model.
114#[derive(Debug, Clone, PartialEq)]
115pub struct Skill {
116    /// Canonical identifier, taken from frontmatter `name` or file name.
117    /// Used as the argument to the `skill` tool.
118    pub name: String,
119    /// Short one-line description shown in the system prompt.
120    pub description: String,
121    /// Trigger conditions (optional). When to use this skill.
122    /// Example: "代码审查请求"、"用户说 'review'、"修改多个文件后"
123    pub trigger: Option<String>,
124    /// Skill type: Rigid (must follow exactly) or Flexible (adapt to context)
125    pub skill_type: SkillType,
126    /// Skill priority: Process (first) or Implementation (second)
127    pub priority: SkillPriority,
128    /// Whether this skill is mandatory to invoke when triggered
129    pub mandatory: bool,
130    /// Absolute path to the skill directory.
131    pub dir: PathBuf,
132    /// Full markdown body (without frontmatter).
133    pub body: String,
134    /// The source .md file path (SKILL.md or other .md file).
135    pub source_file: PathBuf,
136}
137
138impl Skill {
139    /// Path to this skill's source file.
140    pub fn skill_md(&self) -> PathBuf {
141        self.source_file.clone()
142    }
143}
144
145/// Walk the given roots and load skills from all supported formats:
146/// - Format 1: directories with `SKILL.md` (backward compatible)
147/// - Format 2: directories with multiple `.md` files (Claude Code style)
148/// - Format 3: standalone `.md` files directly in the root directory
149///
150/// Missing roots are silently skipped so users can keep a personal
151/// `~/.matrix/skills` directory without the project-local one (or
152/// vice versa). Skills with duplicate names: first one wins, later ones
153/// are dropped with a stderr warning so precedence is predictable.
154pub fn discover_skills(roots: &[PathBuf]) -> Vec<Skill> {
155    let mut out: Vec<Skill> = Vec::new();
156
157    for root in roots {
158        if !root.is_dir() {
159            continue;
160        }
161        let entries = match std::fs::read_dir(root) {
162            Ok(e) => e,
163            Err(e) => {
164                eprintln!("[warn] could not read skills dir {}: {e}", root.display());
165                continue;
166            }
167        };
168        for entry in entries.flatten() {
169            let path = entry.path();
170
171            if path.is_dir() {
172                // Try Format 1: SKILL.md exists (package index)
173                let skill_md = path.join("SKILL.md");
174                if skill_md.is_file() {
175                    match load_skill_from_file(&skill_md, &path) {
176                        Ok(skill) => {
177                            add_skill(&mut out, skill);
178                        }
179                        Err(e) => {
180                            eprintln!("[warn] skipping skill at {}: {e}", path.display());
181                        }
182                    }
183                    // Don't continue here - also load other .md files in the directory
184                    // (SKILL.md acts as package index, other .md files are individual skills)
185                }
186
187                // Try Format 2: multiple .md files in directory (including alongside SKILL.md)
188                load_multi_file_skills(&path, &mut out);
189            } else if path.is_file() {
190                // Format 3: standalone .md file directly in root
191                let ext = path.extension().and_then(|e| e.to_str());
192                if ext != Some("md") {
193                    continue;
194                }
195                match load_skill_from_file(&path, root) {
196                    Ok(skill) => {
197                        add_skill(&mut out, skill);
198                    }
199                    Err(e) => {
200                        let raw = std::fs::read_to_string(&path).unwrap_or_default();
201                        if raw.trim_start().starts_with("---") {
202                            eprintln!("[warn] skipping skill file {}: {e}", path.display());
203                        }
204                    }
205                }
206            }
207        }
208    }
209
210    out.sort_by(|a, b| a.name.cmp(&b.name));
211    out
212}
213
214/// Add a skill to the list, checking for duplicates.
215fn add_skill(out: &mut Vec<Skill>, skill: Skill) {
216    if out.iter().any(|s| s.name == skill.name) {
217        eprintln!(
218            "[warn] duplicate skill name '{}' at {} (ignored)",
219            skill.name,
220            skill.source_file.display()
221        );
222        return;
223    }
224    out.push(skill);
225}
226
227/// Load skills from a directory containing multiple .md files.
228/// Each .md file with frontmatter becomes a separate skill.
229/// Note: SKILL.md is skipped here (handled separately as package index),
230/// but other .md files are loaded even when SKILL.md exists.
231fn load_multi_file_skills(dir: &Path, out: &mut Vec<Skill>) {
232    let entries = match std::fs::read_dir(dir) {
233        Ok(e) => e,
234        Err(e) => {
235            eprintln!("[warn] could not read skill dir {}: {e}", dir.display());
236            return;
237        }
238    };
239
240    for entry in entries.flatten() {
241        let path = entry.path();
242        if !path.is_file() {
243            continue;
244        }
245
246        // Only process .md files
247        let ext = path.extension().and_then(|e| e.to_str());
248        if ext != Some("md") {
249            continue;
250        }
251
252        // Skip SKILL.md (already handled in Format 1)
253        if path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
254            continue;
255        }
256
257        match load_skill_from_file(&path, dir) {
258            Ok(skill) => {
259                add_skill(out, skill);
260            }
261            Err(e) => {
262                // Only warn if the file has frontmatter (indicating it's meant to be a skill)
263                let raw = std::fs::read_to_string(&path).unwrap_or_default();
264                if raw.trim_start().starts_with("---") {
265                    eprintln!("[warn] skipping skill file {}: {e}", path.display());
266                }
267            }
268        }
269    }
270}
271
272/// Load a skill from a specific .md file.
273/// Public so tests and the `skill` tool can reload a specific skill.
274pub fn load_skill_from_file(md_path: &Path, dir: &Path) -> Result<Skill> {
275    let raw = std::fs::read_to_string(md_path)
276        .with_context(|| format!("reading {}", md_path.display()))?;
277    let (front, body) = split_frontmatter(&raw)
278        .with_context(|| format!("parsing frontmatter of {}", md_path.display()))?;
279
280    // Name: frontmatter > filename (without .md) > directory name
281    let name = front
282        .get("name")
283        .cloned()
284        .filter(|s| !s.is_empty())
285        .or_else(|| {
286            md_path
287                .file_stem()
288                .and_then(|n| n.to_str())
289                .map(|s| s.to_string())
290        })
291        .or_else(|| {
292            dir.file_name()
293                .and_then(|n| n.to_str())
294                .map(|s| s.to_string())
295        })
296        .ok_or_else(|| anyhow::anyhow!("skill has no 'name' in frontmatter"))?;
297
298    let description = front
299        .get("description")
300        .cloned()
301        .unwrap_or_else(|| "(no description)".to_string());
302
303    // Extract trigger field (optional)
304    let trigger = front.get("trigger").cloned();
305
306    // Extract skill_type (optional, default to Flexible)
307    let skill_type = front
308        .get("type")
309        .map(|s| SkillType::from_str(s))
310        .unwrap_or_default();
311
312    // Extract priority (optional, default to Implementation)
313    let priority = front
314        .get("priority")
315        .map(|s| SkillPriority::from_str(s))
316        .unwrap_or_default();
317
318    // Extract mandatory flag (optional, default to false)
319    let mandatory = front
320        .get("mandatory")
321        .map(|s| s.trim().to_lowercase() == "true")
322        .unwrap_or(false);
323
324    Ok(Skill {
325        name,
326        description,
327        trigger,
328        skill_type,
329        priority,
330        mandatory,
331        dir: dir.to_path_buf(),
332        body: body.to_string(),
333        source_file: md_path.to_path_buf(),
334    })
335}
336
337/// Load a skill from a directory with SKILL.md (backward compatible).
338pub fn load_skill(dir: &Path) -> Result<Skill> {
339    let md_path = dir.join("SKILL.md");
340    load_skill_from_file(&md_path, dir)
341}
342
343/// Minimal YAML-frontmatter parser: supports the shape
344/// ```text
345/// ---
346/// name: foo
347/// description: bar baz
348/// ---
349/// body...
350/// ```
351/// Only flat `key: value` pairs are recognised. Values may be wrapped
352/// in matching single or double quotes. Missing frontmatter is treated
353/// as empty (body == whole file), which keeps bare markdown files from
354/// being rejected outright — though they'll fall back to the directory
355/// name and generic description.
356fn split_frontmatter(raw: &str) -> Result<(std::collections::BTreeMap<String, String>, &str)> {
357    let mut front = std::collections::BTreeMap::new();
358
359    let trimmed = raw.trim_start_matches('\u{feff}'); // strip BOM if any
360    let Some(rest) = trimmed.strip_prefix("---") else {
361        return Ok((front, trimmed));
362    };
363    // Require newline right after opening ---
364    let rest = rest
365        .strip_prefix('\n')
366        .or_else(|| rest.strip_prefix("\r\n"));
367    let Some(rest) = rest else {
368        return Ok((front, trimmed));
369    };
370
371    // Find closing --- on its own line.
372    let mut end_idx: Option<usize> = None;
373    let mut cursor = 0usize;
374    for line in rest.split_inclusive('\n') {
375        let trimmed_line = line.trim_end_matches(['\n', '\r']);
376        if trimmed_line == "---" {
377            end_idx = Some(cursor + line.len());
378            break;
379        }
380        cursor += line.len();
381    }
382    let Some(end) = end_idx else {
383        // No closing delimiter — treat whole file as body, no frontmatter.
384        return Ok((front, trimmed));
385    };
386
387    let front_block = &rest[..cursor];
388    let body = rest[end..].trim_start_matches(['\n', '\r']);
389
390    for line in front_block.lines() {
391        let line = line.trim();
392        if line.is_empty() || line.starts_with('#') {
393            continue;
394        }
395        let Some((k, v)) = line.split_once(':') else {
396            continue;
397        };
398        let key = k.trim().to_string();
399        let val = unquote(v.trim());
400        if !key.is_empty() {
401            front.insert(key, val);
402        }
403    }
404
405    Ok((front, body))
406}
407
408fn unquote(s: &str) -> String {
409    let bytes = s.as_bytes();
410    if bytes.len() >= 2
411        && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
412            || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
413    {
414        return s[1..s.len() - 1].to_string();
415    }
416    s.to_string()
417}
418
419/// Render the skills catalogue body for insertion into a prompt section.
420/// Returns `None` if there are no skills, so callers can skip injection
421/// entirely rather than bolting on an empty section.
422///
423/// Skills are sorted by priority (Process first, then Implementation).
424/// Mandatory skills are marked with ⚠️ indicator.
425pub fn format_catalogue(skills: &[Skill]) -> Option<String> {
426    if skills.is_empty() {
427        return None;
428    }
429
430    // Sort by priority (Process first)
431    let mut sorted_skills = skills.to_vec();
432    sorted_skills.sort_by_key(|s| s.priority);
433
434    let mut s = String::from(
435        "Use the `skill` tool with the skill's name to load its full instructions.\n\n",
436    );
437
438    // Group by priority
439    let process_skills: Vec<_> = sorted_skills.iter().filter(|s| s.priority == SkillPriority::Process).collect();
440    let impl_skills: Vec<_> = sorted_skills.iter().filter(|s| s.priority == SkillPriority::Implementation).collect();
441
442    if !process_skills.is_empty() {
443        s.push_str("**Process Skills** (invoke first for workflow guidance):\n");
444        for sk in process_skills {
445            let mandatory_marker = if sk.mandatory { "⚠️ " } else { "" };
446            let type_marker = if sk.skill_type == SkillType::Rigid { "[rigid] " } else { "" };
447            s.push_str(&format!(
448                "- {}{}{}: {}\n",
449                mandatory_marker, type_marker, sk.name, sk.description
450            ));
451        }
452        s.push_str("\n");
453    }
454
455    if !impl_skills.is_empty() {
456        s.push_str("**Implementation Skills** (invoke after process skills):\n");
457        for sk in impl_skills {
458            let mandatory_marker = if sk.mandatory { "⚠️ " } else { "" };
459            let type_marker = if sk.skill_type == SkillType::Rigid { "[rigid] " } else { "" };
460            s.push_str(&format!(
461                "- {}{}{}: {}\n",
462                mandatory_marker, type_marker, sk.name, sk.description
463            ));
464        }
465    }
466
467    Some(s)
468}
469
470/// List files inside a skill directory (recursive), relative to the
471/// skill root. Used by the `skill` tool so the model knows what
472/// supporting files it can `read` next.
473pub fn list_skill_files(dir: &Path) -> Vec<String> {
474    let mut out = Vec::new();
475    walk(dir, dir, &mut out);
476    out.sort();
477    out
478}
479
480fn walk(root: &Path, cur: &Path, out: &mut Vec<String>) {
481    let entries = match std::fs::read_dir(cur) {
482        Ok(e) => e,
483        Err(_) => return,
484    };
485    for entry in entries.flatten() {
486        let p = entry.path();
487        let file_type = match entry.file_type() {
488            Ok(t) => t,
489            Err(_) => continue,
490        };
491        if file_type.is_dir() {
492            walk(root, &p, out);
493        } else if file_type.is_file()
494            && let Ok(rel) = p.strip_prefix(root)
495        {
496            out.push(rel.display().to_string());
497        }
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use tempfile::tempdir;
505
506    fn write_file(path: &Path, body: &str) {
507        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
508        std::fs::write(path, body).unwrap();
509    }
510
511    #[test]
512    fn parses_basic_frontmatter() {
513        let (front, body) =
514            split_frontmatter("---\nname: foo\ndescription: hi there\n---\nbody text\n").unwrap();
515        assert_eq!(front.get("name").unwrap(), "foo");
516        assert_eq!(front.get("description").unwrap(), "hi there");
517        assert_eq!(body, "body text\n");
518    }
519
520    #[test]
521    fn quoted_values_are_unwrapped() {
522        let (front, _) =
523            split_frontmatter("---\nname: 'foo bar'\ndescription: \"baz\"\n---\nx").unwrap();
524        assert_eq!(front.get("name").unwrap(), "foo bar");
525        assert_eq!(front.get("description").unwrap(), "baz");
526    }
527
528    #[test]
529    fn missing_frontmatter_returns_whole_body() {
530        let (front, body) = split_frontmatter("just markdown\nno front").unwrap();
531        assert!(front.is_empty());
532        assert_eq!(body, "just markdown\nno front");
533    }
534
535    #[test]
536    fn unclosed_frontmatter_falls_back_to_body() {
537        let (front, body) = split_frontmatter("---\nname: foo\nbody without close").unwrap();
538        assert!(front.is_empty());
539        assert!(body.starts_with("---"));
540    }
541
542    #[test]
543    fn discover_loads_skill_directory() {
544        let tmp = tempdir().unwrap();
545        let root = tmp.path().join("skills");
546        write_file(
547            &root.join("greet/SKILL.md"),
548            "---\nname: greet\ndescription: say hi\n---\nSay hello to the user.\n",
549        );
550        write_file(&root.join("greet/extra.txt"), "support file");
551
552        let skills = discover_skills(&[root]);
553        assert_eq!(skills.len(), 1);
554        assert_eq!(skills[0].name, "greet");
555        assert_eq!(skills[0].description, "say hi");
556        assert!(skills[0].body.contains("Say hello"));
557        let files = list_skill_files(&skills[0].dir);
558        assert!(files.iter().any(|f| f == "SKILL.md"));
559        assert!(files.iter().any(|f| f == "extra.txt"));
560    }
561
562    #[test]
563    fn discover_loads_multi_file_skills() {
564        let tmp = tempdir().unwrap();
565        let root = tmp.path().join("skills");
566        write_file(
567            &root.join("om/debug.md"),
568            "---\nname: debug\ndescription: debug issues\n---\nDebug workflow.\n",
569        );
570        write_file(
571            &root.join("om/feature.md"),
572            "---\nname: feature\ndescription: build features\n---\nFeature workflow.\n",
573        );
574
575        let skills = discover_skills(&[root]);
576        assert_eq!(skills.len(), 2);
577
578        let debug_skill = skills.iter().find(|s| s.name == "debug").unwrap();
579        assert_eq!(debug_skill.description, "debug issues");
580        assert!(debug_skill.body.contains("Debug workflow"));
581
582        let feature_skill = skills.iter().find(|s| s.name == "feature").unwrap();
583        assert_eq!(feature_skill.description, "build features");
584        assert!(feature_skill.body.contains("Feature workflow"));
585    }
586
587    #[test]
588    fn multi_file_skill_name_from_filename() {
589        let tmp = tempdir().unwrap();
590        let root = tmp.path().join("skills");
591        // No 'name' in frontmatter - should use filename
592        write_file(
593            &root.join("utils/helper.md"),
594            "---\ndescription: a helper\n---\nHelper content.\n",
595        );
596
597        let skills = discover_skills(&[root]);
598        assert_eq!(skills.len(), 1);
599        assert_eq!(skills[0].name, "helper");
600    }
601
602    #[test]
603    fn duplicate_names_are_dropped() {
604        let tmp = tempdir().unwrap();
605        let a = tmp.path().join("a");
606        let b = tmp.path().join("b");
607        write_file(
608            &a.join("x/SKILL.md"),
609            "---\nname: x\ndescription: first\n---\nA\n",
610        );
611        write_file(
612            &b.join("x/SKILL.md"),
613            "---\nname: x\ndescription: second\n---\nB\n",
614        );
615        let skills = discover_skills(&[a, b]);
616        assert_eq!(skills.len(), 1);
617        assert_eq!(skills[0].description, "first");
618    }
619
620    #[test]
621    fn missing_root_is_skipped() {
622        let skills = discover_skills(&[PathBuf::from("/definitely/not/here")]);
623        assert!(skills.is_empty());
624    }
625
626    #[test]
627    fn discover_loads_standalone_md_files() {
628        let tmp = tempdir().unwrap();
629        let root = tmp.path().join("skills");
630        // Standalone .md file directly in root
631        write_file(
632            &root.join("om.md"),
633            "---\nname: om\ndescription: main entry\n---\nOpenMatrix entry point.\n",
634        );
635        write_file(
636            &root.join("openmatrix.md"),
637            "---\nname: openmatrix\ndescription: detect dev tasks\n---\nDetect development tasks.\n",
638        );
639
640        let skills = discover_skills(&[root]);
641        assert_eq!(skills.len(), 2);
642
643        let om = skills.iter().find(|s| s.name == "om").unwrap();
644        assert_eq!(om.description, "main entry");
645        assert!(om.body.contains("OpenMatrix entry point"));
646
647        let openmatrix = skills.iter().find(|s| s.name == "openmatrix").unwrap();
648        assert_eq!(openmatrix.description, "detect dev tasks");
649    }
650
651    #[test]
652    fn discover_mixed_formats() {
653        let tmp = tempdir().unwrap();
654        let root = tmp.path().join("skills");
655        // Format 1: directory with SKILL.md
656        write_file(
657            &root.join("debug/SKILL.md"),
658            "---\nname: debug\ndescription: debug tool\n---\nDebug.\n",
659        );
660        // Format 2: directory with multiple .md files
661        write_file(
662            &root.join("om/feature.md"),
663            "---\nname: om:feature\ndescription: build features\n---\nFeature.\n",
664        );
665        // Format 3: standalone .md file
666        write_file(
667            &root.join("openmatrix.md"),
668            "---\nname: openmatrix\ndescription: detect tasks\n---\nDetect.\n",
669        );
670
671        let skills = discover_skills(&[root]);
672        assert_eq!(skills.len(), 3);
673        assert!(skills.iter().any(|s| s.name == "debug"));
674        assert!(skills.iter().any(|s| s.name == "om:feature"));
675        assert!(skills.iter().any(|s| s.name == "openmatrix"));
676    }
677
678    #[test]
679    fn catalogue_renders_or_skips() {
680        assert!(format_catalogue(&[]).is_none());
681        let s = Skill {
682            name: "demo".into(),
683            description: "does stuff".into(),
684            trigger: None,
685            skill_type: SkillType::Flexible,
686            priority: SkillPriority::Implementation,
687            mandatory: false,
688            dir: PathBuf::from("/tmp"),
689            body: String::new(),
690            source_file: PathBuf::from("/tmp/demo.md"),
691        };
692        let cat = format_catalogue(&[s]).unwrap();
693        assert!(cat.contains("Use the `skill` tool"));
694        assert!(cat.contains("demo: does stuff"));
695        assert!(cat.contains("Implementation Skills"));
696    }
697
698    #[test]
699    fn catalogue_groups_by_priority() {
700        let process_skill = Skill {
701            name: "brainstorm".into(),
702            description: "brainstorm ideas".into(),
703            trigger: None,
704            skill_type: SkillType::Flexible,
705            priority: SkillPriority::Process,
706            mandatory: false,
707            dir: PathBuf::from("/tmp"),
708            body: String::new(),
709            source_file: PathBuf::from("/tmp/brainstorm.md"),
710        };
711        let impl_skill = Skill {
712            name: "frontend".into(),
713            description: "frontend design".into(),
714            trigger: None,
715            skill_type: SkillType::Rigid,
716            priority: SkillPriority::Implementation,
717            mandatory: true,
718            dir: PathBuf::from("/tmp"),
719            body: String::new(),
720            source_file: PathBuf::from("/tmp/frontend.md"),
721        };
722        let cat = format_catalogue(&[impl_skill, process_skill]).unwrap();
723        // Process skills should appear first
724        let process_idx = cat.find("Process Skills").unwrap();
725        let impl_idx = cat.find("Implementation Skills").unwrap();
726        assert!(process_idx < impl_idx, "Process skills should come before Implementation");
727        // Mandatory marker should appear
728        assert!(cat.contains("⚠️"), "Mandatory skill should have marker");
729        // Rigid marker should appear
730        assert!(cat.contains("[rigid]"), "Rigid skill should have marker");
731    }
732
733    #[test]
734    fn skill_type_parsing() {
735        assert_eq!(SkillType::from_str("rigid"), SkillType::Rigid);
736        assert_eq!(SkillType::from_str("RIGID"), SkillType::Rigid);
737        assert_eq!(SkillType::from_str("flexible"), SkillType::Flexible);
738        assert_eq!(SkillType::from_str("unknown"), SkillType::Flexible);
739    }
740
741    #[test]
742    fn skill_priority_parsing() {
743        assert_eq!(SkillPriority::from_str("process"), SkillPriority::Process);
744        assert_eq!(SkillPriority::from_str("PROCESS"), SkillPriority::Process);
745        assert_eq!(SkillPriority::from_str("implementation"), SkillPriority::Implementation);
746        assert_eq!(SkillPriority::from_str("unknown"), SkillPriority::Implementation);
747    }
748
749    #[test]
750    fn skill_priority_ordering() {
751        assert!(SkillPriority::Process < SkillPriority::Implementation);
752    }
753}