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    )
178}
179
180#[cfg(test)]
181mod tests {
182    use super::super::parser::parse_canonical;
183    use super::*;
184
185    const VALID: &str = r#"
186name: demo
187version: 1.0.0
188publisher: human:test
189description: d
190category: context
191content:
192  abstract: hi
193  context: body
194"#;
195
196    #[test]
197    fn valid_manifest_passes() {
198        let m = parse_canonical(VALID).unwrap();
199        validate(&m).unwrap();
200    }
201
202    #[test]
203    fn rejects_uppercase_name() {
204        let mut m = parse_canonical(VALID).unwrap();
205        m.name = "Demo".into();
206        assert!(matches!(validate(&m), Err(ValidationError::InvalidName(_))));
207    }
208
209    #[test]
210    fn rejects_bad_version() {
211        let mut m = parse_canonical(VALID).unwrap();
212        m.version = "1.0".into();
213        assert!(matches!(
214            validate(&m),
215            Err(ValidationError::InvalidVersion(_))
216        ));
217    }
218
219    #[test]
220    fn rejects_bad_publisher() {
221        let mut m = parse_canonical(VALID).unwrap();
222        m.publisher = "anon".into();
223        assert!(matches!(
224            validate(&m),
225            Err(ValidationError::InvalidPublisher(_))
226        ));
227    }
228
229    #[test]
230    fn rejects_category_mode_mismatch() {
231        let yaml = r#"
232name: demo
233version: 1.0.0
234publisher: human:test
235description: d
236category: workflow
237content:
238  abstract: hi
239  context: oops
240"#;
241        let m = parse_canonical(yaml).unwrap();
242        assert!(matches!(
243            validate(&m),
244            Err(ValidationError::ContentModeMismatch { .. })
245        ));
246    }
247
248    // ── M6a: mcp_requirements ──
249
250    #[test]
251    fn valid_mcp_requirements_passes() {
252        let yaml = r#"
253name: demo
254version: 1.0.0
255publisher: human:test
256description: d
257category: workflow
258content:
259  abstract: hi
260  procedure:
261    steps:
262      - description: test
263mcp_requirements:
264  - tool_pattern: "browser.*"
265    capability: network_http
266"#;
267        let m = parse_canonical(yaml).unwrap();
268        validate(&m).unwrap();
269    }
270
271    #[test]
272    fn empty_mcp_requirements_passes() {
273        let yaml = r#"
274name: demo
275version: 1.0.0
276publisher: human:test
277description: d
278category: context
279content:
280  abstract: hi
281  context: body
282"#;
283        let m = parse_canonical(yaml).unwrap();
284        validate(&m).unwrap();
285    }
286
287    #[test]
288    fn rejects_duplicate_mcp_requirements() {
289        let yaml = r#"
290name: demo
291version: 1.0.0
292publisher: human:test
293description: d
294category: workflow
295content:
296  abstract: hi
297  procedure:
298    steps:
299      - description: test
300mcp_requirements:
301  - tool_pattern: "fs.*"
302    capability: read_file
303  - tool_pattern: "fs.*"
304    capability: read_file
305"#;
306        let m = parse_canonical(yaml).unwrap();
307        assert!(matches!(
308            validate(&m),
309            Err(ValidationError::McpRequirements(1, _))
310        ));
311    }
312
313    #[test]
314    fn rejects_empty_mcp_pattern() {
315        let yaml = r#"
316name: demo
317version: 1.0.0
318publisher: human:test
319description: d
320category: workflow
321content:
322  abstract: hi
323  procedure:
324    steps:
325      - description: test
326mcp_requirements:
327  - tool_pattern: ""
328    capability: read_file
329"#;
330        let m = parse_canonical(yaml).unwrap();
331        assert!(matches!(
332            validate(&m),
333            Err(ValidationError::McpRequirements(0, _))
334        ));
335    }
336
337    #[test]
338    fn command_trigger_requires_pattern() {
339        let yaml = r#"
340name: demo
341version: 1.0.0
342publisher: human:test
343description: d
344category: context
345content:
346  abstract: hi
347  context: body
348triggers:
349  - type: command
350"#;
351        let m = parse_canonical(yaml).unwrap();
352        assert!(matches!(
353            validate(&m),
354            Err(ValidationError::TriggerMissingPattern(TriggerKind::Command))
355        ));
356    }
357
358    #[test]
359    fn valid_note_manifest_passes() {
360        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
361                    category: note\ndescription: d\n\
362                    content:\n  abstract: a\n  note: |\n    # body\n";
363        let m = parse_canonical(yaml).unwrap();
364        assert!(validate(&m).is_ok());
365    }
366
367    #[test]
368    fn note_category_with_context_mode_is_mismatch() {
369        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
370                    category: note\ndescription: d\n\
371                    content:\n  abstract: a\n  context: c\n";
372        let m = parse_canonical(yaml).unwrap();
373        assert!(matches!(
374            validate(&m),
375            Err(ValidationError::ContentModeMismatch { .. })
376        ));
377    }
378
379    #[test]
380    fn note_plus_command_is_multiple_modes() {
381        let yaml = "name: rust-notes\nversion: 1.0.0\npublisher: human:test\n\
382                    category: note\ndescription: d\n\
383                    content:\n  abstract: a\n  note: x\n  command: y\n";
384        let m = parse_canonical(yaml).unwrap();
385        assert!(matches!(
386            validate(&m),
387            Err(ValidationError::MultipleContentModes)
388        ));
389    }
390}