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            // Dev-discipline builtins are workflow-category skills whose body
174            // is method prose (`context`), not executable steps.
175            | (Category::Workflow, ContentMode::Context)
176            | (Category::Command, ContentMode::Command)
177            | (Category::Context, ContentMode::Context)
178            | (Category::Meta, ContentMode::Context)
179            | (Category::Note, ContentMode::Note)
180            // Media skills carry a `context` body (the when/why prose the agent
181            // reads); there is no dedicated ContentMode::Media.
182            | (Category::Media, ContentMode::Context)
183    )
184}
185
186#[cfg(test)]
187mod tests {
188    use super::super::parser::parse_canonical;
189    use super::*;
190
191    const VALID: &str = r#"
192name: demo
193version: 1.0.0
194publisher: human:test
195description: d
196category: context
197content:
198  abstract: hi
199  context: body
200"#;
201
202    #[test]
203    fn valid_manifest_passes() {
204        let m = parse_canonical(VALID).unwrap();
205        validate(&m).unwrap();
206    }
207
208    /// Workflow-category skills may carry a `context` body (dev-discipline
209    /// builtin convention: method prose, not executable steps).
210    #[test]
211    fn workflow_category_accepts_context_mode() {
212        let m = parse_canonical(&VALID.replace("category: context", "category: workflow")).unwrap();
213        validate(&m).unwrap();
214    }
215
216    #[test]
217    fn rejects_uppercase_name() {
218        let mut m = parse_canonical(VALID).unwrap();
219        m.name = "Demo".into();
220        assert!(matches!(validate(&m), Err(ValidationError::InvalidName(_))));
221    }
222
223    #[test]
224    fn rejects_bad_version() {
225        let mut m = parse_canonical(VALID).unwrap();
226        m.version = "1.0".into();
227        assert!(matches!(
228            validate(&m),
229            Err(ValidationError::InvalidVersion(_))
230        ));
231    }
232
233    #[test]
234    fn rejects_bad_publisher() {
235        let mut m = parse_canonical(VALID).unwrap();
236        m.publisher = "anon".into();
237        assert!(matches!(
238            validate(&m),
239            Err(ValidationError::InvalidPublisher(_))
240        ));
241    }
242
243    #[test]
244    fn rejects_category_mode_mismatch() {
245        let yaml = r#"
246name: demo
247version: 1.0.0
248publisher: human:test
249description: d
250category: note
251content:
252  abstract: hi
253  context: oops
254"#;
255        let m = parse_canonical(yaml).unwrap();
256        assert!(matches!(
257            validate(&m),
258            Err(ValidationError::ContentModeMismatch { .. })
259        ));
260    }
261
262    #[test]
263    fn media_category_with_context_validates() {
264        // The shipped media skills (video-analyze/scene-explain/vlc-control/
265        // watch-together) declare `category: media` with a `context` body;
266        // validation must accept them so `mur agent skill add` doesn't reject them.
267        let yaml = r#"
268name: video-analyze
269version: 1.0.0
270publisher: human:test
271description: analyze a video
272category: media
273content:
274  abstract: hi
275  context: when and why to use video_analyze
276"#;
277        let m = parse_canonical(yaml).unwrap();
278        validate(&m).expect("media + context should validate");
279    }
280
281    // ── M6a: mcp_requirements ──
282
283    #[test]
284    fn valid_mcp_requirements_passes() {
285        let yaml = r#"
286name: demo
287version: 1.0.0
288publisher: human:test
289description: d
290category: workflow
291content:
292  abstract: hi
293  procedure:
294    steps:
295      - description: test
296mcp_requirements:
297  - tool_pattern: "browser.*"
298    capability: network_http
299"#;
300        let m = parse_canonical(yaml).unwrap();
301        validate(&m).unwrap();
302    }
303
304    #[test]
305    fn empty_mcp_requirements_passes() {
306        let yaml = r#"
307name: demo
308version: 1.0.0
309publisher: human:test
310description: d
311category: context
312content:
313  abstract: hi
314  context: body
315"#;
316        let m = parse_canonical(yaml).unwrap();
317        validate(&m).unwrap();
318    }
319
320    #[test]
321    fn rejects_duplicate_mcp_requirements() {
322        let yaml = r#"
323name: demo
324version: 1.0.0
325publisher: human:test
326description: d
327category: workflow
328content:
329  abstract: hi
330  procedure:
331    steps:
332      - description: test
333mcp_requirements:
334  - tool_pattern: "fs.*"
335    capability: read_file
336  - tool_pattern: "fs.*"
337    capability: read_file
338"#;
339        let m = parse_canonical(yaml).unwrap();
340        assert!(matches!(
341            validate(&m),
342            Err(ValidationError::McpRequirements(1, _))
343        ));
344    }
345
346    #[test]
347    fn rejects_empty_mcp_pattern() {
348        let yaml = r#"
349name: demo
350version: 1.0.0
351publisher: human:test
352description: d
353category: workflow
354content:
355  abstract: hi
356  procedure:
357    steps:
358      - description: test
359mcp_requirements:
360  - tool_pattern: ""
361    capability: read_file
362"#;
363        let m = parse_canonical(yaml).unwrap();
364        assert!(matches!(
365            validate(&m),
366            Err(ValidationError::McpRequirements(0, _))
367        ));
368    }
369
370    #[test]
371    fn command_trigger_requires_pattern() {
372        let yaml = r#"
373name: demo
374version: 1.0.0
375publisher: human:test
376description: d
377category: context
378content:
379  abstract: hi
380  context: body
381triggers:
382  - type: command
383"#;
384        let m = parse_canonical(yaml).unwrap();
385        assert!(matches!(
386            validate(&m),
387            Err(ValidationError::TriggerMissingPattern(TriggerKind::Command))
388        ));
389    }
390
391    #[test]
392    fn valid_note_manifest_passes() {
393        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
394                    category: note\ndescription: d\n\
395                    content:\n  abstract: a\n  note: |\n    # body\n";
396        let m = parse_canonical(yaml).unwrap();
397        assert!(validate(&m).is_ok());
398    }
399
400    #[test]
401    fn note_category_with_context_mode_is_mismatch() {
402        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
403                    category: note\ndescription: d\n\
404                    content:\n  abstract: a\n  context: c\n";
405        let m = parse_canonical(yaml).unwrap();
406        assert!(matches!(
407            validate(&m),
408            Err(ValidationError::ContentModeMismatch { .. })
409        ));
410    }
411
412    #[test]
413    fn note_plus_command_is_multiple_modes() {
414        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
415                    category: note\ndescription: d\n\
416                    content:\n  abstract: a\n  note: x\n  command: y\n";
417        let m = parse_canonical(yaml).unwrap();
418        assert!(matches!(
419            validate(&m),
420            Err(ValidationError::MultipleContentModes)
421        ));
422    }
423}