mur_common/skill/
validate.rs1use 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 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 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 | (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 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 #[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}