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::Workflow, ContentMode::Context)
176 | (Category::Command, ContentMode::Command)
177 | (Category::Context, ContentMode::Context)
178 | (Category::Meta, ContentMode::Context)
179 | (Category::Note, ContentMode::Note)
180 | (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 #[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 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 #[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}