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