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"
38            ),
39            MultipleContentModes => write!(
40                f,
41                "content must populate only one of: context / procedure / command"
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        ]
75        .iter()
76        .filter(|b| **b)
77        .count();
78        if populated > 1 {
79            ValidationError::MultipleContentModes
80        } else {
81            ValidationError::NoContentMode
82        }
83    })?;
84
85    if !mode_matches_category(m.category, mode) {
86        return Err(ValidationError::ContentModeMismatch {
87            category: m.category,
88            mode,
89        });
90    }
91
92    for t in &m.triggers {
93        if matches!(t.kind, TriggerKind::Command | TriggerKind::Keyword) && t.pattern.is_none() {
94            return Err(ValidationError::TriggerMissingPattern(t.kind));
95        }
96    }
97
98    if let Err((idx, msg)) = mcp::validate_requirements(&m.mcp_requirements) {
99        return Err(ValidationError::McpRequirements(idx, msg));
100    }
101
102    // Validate intent + tool_hint on procedure steps (v2.2).
103    if let Some(proc) = &m.content.procedure {
104        for (idx, step) in proc.steps.iter().enumerate() {
105            if let Some(hint) = &step.tool_hint
106                && hint.is_empty()
107            {
108                return Err(ValidationError::McpRequirements(
109                    idx,
110                    "tool_hint must not be empty when present".into(),
111                ));
112            }
113            if let Some(intent) = &step.intent
114                && intent.is_empty()
115            {
116                return Err(ValidationError::McpRequirements(
117                    idx,
118                    "intent must not be empty when present".into(),
119                ));
120            }
121        }
122    }
123
124    Ok(())
125}
126
127fn validate_name(name: &str) -> Result<(), ValidationError> {
128    if name.is_empty() || name.len() > 64 {
129        return Err(ValidationError::InvalidName(name.into()));
130    }
131    if !name
132        .chars()
133        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
134    {
135        return Err(ValidationError::InvalidName(name.into()));
136    }
137    if name.starts_with('-') || name.ends_with('-') {
138        return Err(ValidationError::InvalidName(name.into()));
139    }
140    Ok(())
141}
142
143fn validate_version(v: &str) -> Result<(), ValidationError> {
144    let parts: Vec<&str> = v.split('.').collect();
145    if parts.len() != 3
146        || parts
147            .iter()
148            .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
149    {
150        return Err(ValidationError::InvalidVersion(v.into()));
151    }
152    Ok(())
153}
154
155fn validate_publisher(p: &str) -> Result<(), ValidationError> {
156    let (kind, rest) = p
157        .split_once(':')
158        .ok_or_else(|| ValidationError::InvalidPublisher(p.into()))?;
159    if rest.is_empty() {
160        return Err(ValidationError::InvalidPublisher(p.into()));
161    }
162    match kind {
163        "human" | "agent" => Ok(()),
164        _ => Err(ValidationError::InvalidPublisher(p.into())),
165    }
166}
167
168fn mode_matches_category(cat: Category, mode: ContentMode) -> bool {
169    matches!(
170        (cat, mode),
171        (Category::Workflow, ContentMode::Workflow)
172            | (Category::Command, ContentMode::Command)
173            | (Category::Context, ContentMode::Context)
174            | (Category::Meta, ContentMode::Context)
175    )
176}
177
178#[cfg(test)]
179mod tests {
180    use super::super::parser::parse_canonical;
181    use super::*;
182
183    const VALID: &str = r#"
184name: demo
185version: 1.0.0
186publisher: human:test
187description: d
188category: context
189content:
190  abstract: hi
191  context: body
192"#;
193
194    #[test]
195    fn valid_manifest_passes() {
196        let m = parse_canonical(VALID).unwrap();
197        validate(&m).unwrap();
198    }
199
200    #[test]
201    fn rejects_uppercase_name() {
202        let mut m = parse_canonical(VALID).unwrap();
203        m.name = "Demo".into();
204        assert!(matches!(validate(&m), Err(ValidationError::InvalidName(_))));
205    }
206
207    #[test]
208    fn rejects_bad_version() {
209        let mut m = parse_canonical(VALID).unwrap();
210        m.version = "1.0".into();
211        assert!(matches!(
212            validate(&m),
213            Err(ValidationError::InvalidVersion(_))
214        ));
215    }
216
217    #[test]
218    fn rejects_bad_publisher() {
219        let mut m = parse_canonical(VALID).unwrap();
220        m.publisher = "anon".into();
221        assert!(matches!(
222            validate(&m),
223            Err(ValidationError::InvalidPublisher(_))
224        ));
225    }
226
227    #[test]
228    fn rejects_category_mode_mismatch() {
229        let yaml = r#"
230name: demo
231version: 1.0.0
232publisher: human:test
233description: d
234category: workflow
235content:
236  abstract: hi
237  context: oops
238"#;
239        let m = parse_canonical(yaml).unwrap();
240        assert!(matches!(
241            validate(&m),
242            Err(ValidationError::ContentModeMismatch { .. })
243        ));
244    }
245
246    // ── M6a: mcp_requirements ──
247
248    #[test]
249    fn valid_mcp_requirements_passes() {
250        let yaml = r#"
251name: demo
252version: 1.0.0
253publisher: human:test
254description: d
255category: workflow
256content:
257  abstract: hi
258  procedure:
259    steps:
260      - description: test
261mcp_requirements:
262  - tool_pattern: "browser.*"
263    capability: network_http
264"#;
265        let m = parse_canonical(yaml).unwrap();
266        validate(&m).unwrap();
267    }
268
269    #[test]
270    fn empty_mcp_requirements_passes() {
271        let yaml = r#"
272name: demo
273version: 1.0.0
274publisher: human:test
275description: d
276category: context
277content:
278  abstract: hi
279  context: body
280"#;
281        let m = parse_canonical(yaml).unwrap();
282        validate(&m).unwrap();
283    }
284
285    #[test]
286    fn rejects_duplicate_mcp_requirements() {
287        let yaml = r#"
288name: demo
289version: 1.0.0
290publisher: human:test
291description: d
292category: workflow
293content:
294  abstract: hi
295  procedure:
296    steps:
297      - description: test
298mcp_requirements:
299  - tool_pattern: "fs.*"
300    capability: read_file
301  - tool_pattern: "fs.*"
302    capability: read_file
303"#;
304        let m = parse_canonical(yaml).unwrap();
305        assert!(matches!(
306            validate(&m),
307            Err(ValidationError::McpRequirements(1, _))
308        ));
309    }
310
311    #[test]
312    fn rejects_empty_mcp_pattern() {
313        let yaml = r#"
314name: demo
315version: 1.0.0
316publisher: human:test
317description: d
318category: workflow
319content:
320  abstract: hi
321  procedure:
322    steps:
323      - description: test
324mcp_requirements:
325  - tool_pattern: ""
326    capability: read_file
327"#;
328        let m = parse_canonical(yaml).unwrap();
329        assert!(matches!(
330            validate(&m),
331            Err(ValidationError::McpRequirements(0, _))
332        ));
333    }
334
335    #[test]
336    fn command_trigger_requires_pattern() {
337        let yaml = r#"
338name: demo
339version: 1.0.0
340publisher: human:test
341description: d
342category: context
343content:
344  abstract: hi
345  context: body
346triggers:
347  - type: command
348"#;
349        let m = parse_canonical(yaml).unwrap();
350        assert!(matches!(
351            validate(&m),
352            Err(ValidationError::TriggerMissingPattern(TriggerKind::Command))
353        ));
354    }
355}