Skip to main content

mur_common/skill/
validate.rs

1//! Schema validation enforced after parsing.
2
3use super::manifest::SkillManifest;
4use super::mcp;
5use super::types::{Category, ContentMode, TriggerKind};
6use std::fmt;
7
8#[derive(Debug, PartialEq, Eq)]
9pub enum ValidationError {
10    InvalidName(String),
11    InvalidVersion(String),
12    InvalidPublisher(String),
13    NoContentMode,
14    MultipleContentModes,
15    ContentModeMismatch {
16        category: Category,
17        mode: ContentMode,
18    },
19    TriggerMissingPattern(TriggerKind),
20    EmptyAbstract,
21    /// Invalid mcp_requirements entry. Fields: index, message.
22    McpRequirements(usize, String),
23}
24
25impl fmt::Display for ValidationError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        use ValidationError::*;
28        match self {
29            InvalidName(n) => write!(f, "invalid skill name '{n}' (must match [a-z0-9-]{{1,64}})"),
30            InvalidVersion(v) => write!(f, "invalid version '{v}' (expected MAJOR.MINOR.PATCH)"),
31            InvalidPublisher(p) => write!(
32                f,
33                "invalid publisher '{p}' (expected 'human:<n>' or 'agent:<id>')"
34            ),
35            NoContentMode => write!(
36                f,
37                "content must populate exactly one of: context / procedure / command / note"
38            ),
39            MultipleContentModes => write!(
40                f,
41                "content must populate only one of: context / procedure / command / note"
42            ),
43            ContentModeMismatch { category, mode } => {
44                write!(
45                    f,
46                    "category {category:?} does not match content mode {mode:?}"
47                )
48            }
49            TriggerMissingPattern(k) => write!(f, "trigger '{k:?}' requires a `pattern` field"),
50            EmptyAbstract => write!(f, "content.abstract must not be empty"),
51            McpRequirements(idx, msg) => {
52                write!(f, "mcp_requirements[{idx}]: {msg}")
53            }
54        }
55    }
56}
57
58impl std::error::Error for ValidationError {}
59
60pub fn validate(m: &SkillManifest) -> Result<(), ValidationError> {
61    validate_name(&m.name)?;
62    validate_version(&m.version)?;
63    validate_publisher(&m.publisher)?;
64
65    if m.content.r#abstract.trim().is_empty() {
66        return Err(ValidationError::EmptyAbstract);
67    }
68
69    let mode = m.content.mode().ok_or_else(|| {
70        let populated = [
71            m.content.context.is_some(),
72            m.content.procedure.is_some(),
73            m.content.command.is_some(),
74            m.content.note.is_some(),
75        ]
76        .iter()
77        .filter(|b| **b)
78        .count();
79        if populated > 1 {
80            ValidationError::MultipleContentModes
81        } else {
82            ValidationError::NoContentMode
83        }
84    })?;
85
86    if !mode_matches_category(m.category, mode) {
87        return Err(ValidationError::ContentModeMismatch {
88            category: m.category,
89            mode,
90        });
91    }
92
93    for t in &m.triggers {
94        if matches!(t.kind, TriggerKind::Command | TriggerKind::Keyword) && t.pattern.is_none() {
95            return Err(ValidationError::TriggerMissingPattern(t.kind));
96        }
97    }
98
99    if let Err((idx, msg)) = mcp::validate_requirements(&m.mcp_requirements) {
100        return Err(ValidationError::McpRequirements(idx, msg));
101    }
102
103    // Validate intent + tool_hint on procedure steps (v2.2).
104    if let Some(proc) = &m.content.procedure {
105        for (idx, step) in proc.steps.iter().enumerate() {
106            if let Some(hint) = &step.tool_hint
107                && hint.is_empty()
108            {
109                return Err(ValidationError::McpRequirements(
110                    idx,
111                    "tool_hint must not be empty when present".into(),
112                ));
113            }
114            if let Some(intent) = &step.intent
115                && intent.is_empty()
116            {
117                return Err(ValidationError::McpRequirements(
118                    idx,
119                    "intent must not be empty when present".into(),
120                ));
121            }
122        }
123    }
124
125    Ok(())
126}
127
128fn validate_name(name: &str) -> Result<(), ValidationError> {
129    if name.is_empty() || name.len() > 64 {
130        return Err(ValidationError::InvalidName(name.into()));
131    }
132    if !name
133        .chars()
134        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
135    {
136        return Err(ValidationError::InvalidName(name.into()));
137    }
138    if name.starts_with('-') || name.ends_with('-') {
139        return Err(ValidationError::InvalidName(name.into()));
140    }
141    Ok(())
142}
143
144fn validate_version(v: &str) -> Result<(), ValidationError> {
145    let parts: Vec<&str> = v.split('.').collect();
146    if parts.len() != 3
147        || parts
148            .iter()
149            .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
150    {
151        return Err(ValidationError::InvalidVersion(v.into()));
152    }
153    Ok(())
154}
155
156fn validate_publisher(p: &str) -> Result<(), ValidationError> {
157    let (kind, rest) = p
158        .split_once(':')
159        .ok_or_else(|| ValidationError::InvalidPublisher(p.into()))?;
160    if rest.is_empty() {
161        return Err(ValidationError::InvalidPublisher(p.into()));
162    }
163    match kind {
164        "human" | "agent" => Ok(()),
165        _ => Err(ValidationError::InvalidPublisher(p.into())),
166    }
167}
168
169fn mode_matches_category(cat: Category, mode: ContentMode) -> bool {
170    matches!(
171        (cat, mode),
172        (Category::Workflow, ContentMode::Workflow)
173            | (Category::Command, ContentMode::Command)
174            | (Category::Context, ContentMode::Context)
175            | (Category::Meta, ContentMode::Context)
176            | (Category::Note, ContentMode::Note)
177            // Media skills carry a `context` body (the when/why prose the agent
178            // reads); there is no dedicated ContentMode::Media.
179            | (Category::Media, ContentMode::Context)
180    )
181}
182
183#[cfg(test)]
184mod tests {
185    use super::super::parser::parse_canonical;
186    use super::*;
187
188    const VALID: &str = r#"
189name: demo
190version: 1.0.0
191publisher: human:test
192description: d
193category: context
194content:
195  abstract: hi
196  context: body
197"#;
198
199    #[test]
200    fn valid_manifest_passes() {
201        let m = parse_canonical(VALID).unwrap();
202        validate(&m).unwrap();
203    }
204
205    #[test]
206    fn rejects_uppercase_name() {
207        let mut m = parse_canonical(VALID).unwrap();
208        m.name = "Demo".into();
209        assert!(matches!(validate(&m), Err(ValidationError::InvalidName(_))));
210    }
211
212    #[test]
213    fn rejects_bad_version() {
214        let mut m = parse_canonical(VALID).unwrap();
215        m.version = "1.0".into();
216        assert!(matches!(
217            validate(&m),
218            Err(ValidationError::InvalidVersion(_))
219        ));
220    }
221
222    #[test]
223    fn rejects_bad_publisher() {
224        let mut m = parse_canonical(VALID).unwrap();
225        m.publisher = "anon".into();
226        assert!(matches!(
227            validate(&m),
228            Err(ValidationError::InvalidPublisher(_))
229        ));
230    }
231
232    #[test]
233    fn rejects_category_mode_mismatch() {
234        let yaml = r#"
235name: demo
236version: 1.0.0
237publisher: human:test
238description: d
239category: workflow
240content:
241  abstract: hi
242  context: oops
243"#;
244        let m = parse_canonical(yaml).unwrap();
245        assert!(matches!(
246            validate(&m),
247            Err(ValidationError::ContentModeMismatch { .. })
248        ));
249    }
250
251    #[test]
252    fn media_category_with_context_validates() {
253        // The shipped media skills (video-analyze/scene-explain/vlc-control/
254        // watch-together) declare `category: media` with a `context` body;
255        // validation must accept them so `mur agent skill add` doesn't reject them.
256        let yaml = r#"
257name: video-analyze
258version: 1.0.0
259publisher: human:test
260description: analyze a video
261category: media
262content:
263  abstract: hi
264  context: when and why to use video_analyze
265"#;
266        let m = parse_canonical(yaml).unwrap();
267        validate(&m).expect("media + context should validate");
268    }
269
270    // ── M6a: mcp_requirements ──
271
272    #[test]
273    fn valid_mcp_requirements_passes() {
274        let yaml = r#"
275name: demo
276version: 1.0.0
277publisher: human:test
278description: d
279category: workflow
280content:
281  abstract: hi
282  procedure:
283    steps:
284      - description: test
285mcp_requirements:
286  - tool_pattern: "browser.*"
287    capability: network_http
288"#;
289        let m = parse_canonical(yaml).unwrap();
290        validate(&m).unwrap();
291    }
292
293    #[test]
294    fn empty_mcp_requirements_passes() {
295        let yaml = r#"
296name: demo
297version: 1.0.0
298publisher: human:test
299description: d
300category: context
301content:
302  abstract: hi
303  context: body
304"#;
305        let m = parse_canonical(yaml).unwrap();
306        validate(&m).unwrap();
307    }
308
309    #[test]
310    fn rejects_duplicate_mcp_requirements() {
311        let yaml = r#"
312name: demo
313version: 1.0.0
314publisher: human:test
315description: d
316category: workflow
317content:
318  abstract: hi
319  procedure:
320    steps:
321      - description: test
322mcp_requirements:
323  - tool_pattern: "fs.*"
324    capability: read_file
325  - tool_pattern: "fs.*"
326    capability: read_file
327"#;
328        let m = parse_canonical(yaml).unwrap();
329        assert!(matches!(
330            validate(&m),
331            Err(ValidationError::McpRequirements(1, _))
332        ));
333    }
334
335    #[test]
336    fn rejects_empty_mcp_pattern() {
337        let yaml = r#"
338name: demo
339version: 1.0.0
340publisher: human:test
341description: d
342category: workflow
343content:
344  abstract: hi
345  procedure:
346    steps:
347      - description: test
348mcp_requirements:
349  - tool_pattern: ""
350    capability: read_file
351"#;
352        let m = parse_canonical(yaml).unwrap();
353        assert!(matches!(
354            validate(&m),
355            Err(ValidationError::McpRequirements(0, _))
356        ));
357    }
358
359    #[test]
360    fn command_trigger_requires_pattern() {
361        let yaml = r#"
362name: demo
363version: 1.0.0
364publisher: human:test
365description: d
366category: context
367content:
368  abstract: hi
369  context: body
370triggers:
371  - type: command
372"#;
373        let m = parse_canonical(yaml).unwrap();
374        assert!(matches!(
375            validate(&m),
376            Err(ValidationError::TriggerMissingPattern(TriggerKind::Command))
377        ));
378    }
379
380    #[test]
381    fn valid_note_manifest_passes() {
382        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
383                    category: note\ndescription: d\n\
384                    content:\n  abstract: a\n  note: |\n    # body\n";
385        let m = parse_canonical(yaml).unwrap();
386        assert!(validate(&m).is_ok());
387    }
388
389    #[test]
390    fn note_category_with_context_mode_is_mismatch() {
391        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
392                    category: note\ndescription: d\n\
393                    content:\n  abstract: a\n  context: c\n";
394        let m = parse_canonical(yaml).unwrap();
395        assert!(matches!(
396            validate(&m),
397            Err(ValidationError::ContentModeMismatch { .. })
398        ));
399    }
400
401    #[test]
402    fn note_plus_command_is_multiple_modes() {
403        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
404                    category: note\ndescription: d\n\
405                    content:\n  abstract: a\n  note: x\n  command: y\n";
406        let m = parse_canonical(yaml).unwrap();
407        assert!(matches!(
408            validate(&m),
409            Err(ValidationError::MultipleContentModes)
410        ));
411    }
412}