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 )
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 #[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}